source: Klonkt/src/routes/prutter.js@ fb02cc0

main
Last change on this file since fb02cc0 was 8aa85d0, checked in by roboburr <roboburr@…>, 3 months ago

Prutter = premium Hub feature: Hub-only + behind premium gate

Prutter (DMs) is now hard-scoped to Hub mode (route 404s outside hub) and
locked behind the premium gate via the new premiumUnlocked() (= premium off
→ freely available, demos keep working; premium on → Patreon required).
UI entry points (topnav/bottom-tab/Send DM) follow the same condition.
Added to the premium description in Admin → Settings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@…>

  • Property mode set to 100644
File size: 6.3 KB
Line 
1/**
2 * Prutter — Direct Messaging routes.
3 *
4 * GET /prutter -> inbox (list of your conversations on THIS site)
5 * GET /prutter/new?to=<username> -> start (or resume) a conversation, redirects to /prutter/:id
6 * GET /prutter/:id -> conversation view (messages + send form)
7 * POST /prutter/:id/send -> send a message (HTMX-friendly response)
8 *
9 * Scoping: conversations are per-site (PrutterService.getOrCreateConversation
10 * uses res.locals.site.id). Robin's quote: "Prutter = DM (per .com domain)".
11 *
12 * Per-site toggle: site.enable_prutter == 0 -> 404 the whole feature.
13 *
14 * No anonymous DMs — always requires login.
15 */
16
17import express from 'express';
18import db from '../config/database.js';
19import { renderPage } from '../middleware/render.js';
20import { requireAuth, isViewer } from '../middleware/auth.js';
21import { getTenancy } from '../services/SettingsService.js';
22import { premiumUnlocked } from '../services/PatreonService.js';
23import PermissionsService from '../services/PermissionsService.js';
24
25const router = express.Router();
26
27const MAX_MESSAGE_LEN = 2000;
28
29function siteAllowsDM(req, res) {
30 const site = res.locals.site;
31 if (!site) return false;
32 return site.enable_prutter !== 0;
33}
34
35// Middleware: Prutter = Hub-feature achter de premium-laag, plus login.
36// - alleen in Hub-modus (DM tussen artiesten/leden van het collectief)
37// - vergrendeld als de premium-laag aan staat maar Patreon niet gekoppeld is
38// (premium uit = niet gegate → huidige gedrag, demo's blijven werken)
39// - geen anonieme DMs
40function requirePrutter(req, res, next) {
41 if (!siteAllowsDM(req, res)) return res.status(404).send('Prutter not enabled on this site');
42 if (getTenancy() !== 'hub') return res.status(404).send('Prutter is alleen beschikbaar in Hub-modus');
43 if (!premiumUnlocked()) {
44 return res.status(403).send('Prutter is een premium-functie — koppel Patreon in Beheer → Instellingen.');
45 }
46 return requireAuth(req, res, next);
47}
48
49// ==================== INBOX ====================
50router.get('/', requirePrutter, (req, res) => {
51 const prutter = req.app.locals.prutter;
52 const conversations = prutter.getUserConversations(req.session.user.id);
53
54 // Filter to only conversations on THIS site (Prutter scope is per-site)
55 const siteId = res.locals.site.id;
56 const scoped = conversations.filter(c => c.site_id === siteId);
57
58 renderPage(req, res, 'pages/prutter-inbox', {
59 pageTitle: 'Prutter',
60 bodyClass: 'on-special',
61 conversations: scoped,
62 });
63});
64
65// ==================== START / RESUME CONVERSATION ====================
66router.get('/new', requirePrutter, (req, res) => {
67 // Een gesprek starten is een schrijf-actie (INSERT) — een kijker mag dat niet.
68 // De globale guard pakt dit niet omdat het een GET is, dus expliciet blokkeren.
69 if (isViewer(req.session.user)) {
70 res.status(403);
71 return renderPage(req, res, 'pages/viewer-blocked', { pageTitle: 'Kijker-modus', bodyClass: 'on-special' });
72 }
73 const targetUsername = (req.query.to || '').toString().trim();
74 if (!targetUsername) {
75 return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
76 }
77 const target = db.prepare('SELECT id, username FROM users WHERE username = ?').get(targetUsername);
78 if (!target) return res.status(404).send('User not found');
79 if (target.id === req.session.user.id) {
80 return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
81 }
82
83 const prutter = req.app.locals.prutter;
84 const conv = prutter.getOrCreateConversation(req.session.user.id, target.id, res.locals.site.id);
85 res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
86});
87
88// ==================== CONVERSATION VIEW ====================
89router.get('/:id', requirePrutter, (req, res) => {
90 const prutter = req.app.locals.prutter;
91 const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
92 if (!conv) return res.status(404).send('Conversation not found');
93
94 // Auth: must be a participant
95 const me = req.session.user.id;
96 if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
97
98 // Scope: this conversation must belong to the resolved site
99 if (conv.site_id !== res.locals.site.id) return res.status(404).send('Conversation not on this site');
100
101 // Other party
102 const otherId = conv.user_a_id === me ? conv.user_b_id : conv.user_a_id;
103 const other = db.prepare('SELECT id, username, avatar_url FROM users WHERE id = ?').get(otherId);
104
105 // Messages (oldest first for natural reading order)
106 const messages = prutter.getMessages(conv.id, 200, 0).reverse();
107
108 // Mark inbound messages as read — sla over voor kijkers (markAsRead is een
109 // UPDATE; een GET valt buiten de globale guard, dus hier expliciet skippen).
110 if (!isViewer(req.session.user)) prutter.markAsRead(conv.id, me);
111
112 renderPage(req, res, 'pages/prutter-conversation', {
113 pageTitle: 'Prutter — ' + (other?.username || ''),
114 bodyClass: 'on-special',
115 conversation: conv,
116 other,
117 messages,
118 });
119});
120
121// ==================== SEND MESSAGE ====================
122router.post('/:id/send', requirePrutter, (req, res) => {
123 const prutter = req.app.locals.prutter;
124 const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
125 if (!conv) return res.status(404).send('Not found');
126
127 const me = req.session.user.id;
128 if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
129 if (conv.site_id !== res.locals.site.id) return res.status(404).send('Wrong site');
130
131 const content = (req.body.content || '').toString().trim();
132 if (!content) return res.status(400).send('Empty');
133 if (content.length > MAX_MESSAGE_LEN) return res.status(413).send('Too long');
134
135 const message = prutter.sendMessage(conv.id, me, content);
136
137 // HTMX request → return the single rendered message HTML, appended to the thread
138 if (req.headers['hx-request']) {
139 return res.send(
140 `<li class="prutter-msg prutter-msg--mine" data-msg-id="${message.id}">` +
141 `<div class="prutter-msg-bubble">${escapeHtml(content)}</div>` +
142 `</li>`
143 );
144 }
145
146 res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
147});
148
149function escapeHtml(s) {
150 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
151 .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
152}
153
154export default router;
Note: See TracBrowser for help on using the repository browser.