| 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 |
|
|---|
| 17 | import express from 'express';
|
|---|
| 18 | import db from '../config/database.js';
|
|---|
| 19 | import { renderPage } from '../middleware/render.js';
|
|---|
| 20 | import { requireAuth, isViewer } from '../middleware/auth.js';
|
|---|
| 21 | import { getTenancy } from '../services/SettingsService.js';
|
|---|
| 22 | import { premiumUnlocked } from '../services/PatreonService.js';
|
|---|
| 23 | import PermissionsService from '../services/PermissionsService.js';
|
|---|
| 24 |
|
|---|
| 25 | const router = express.Router();
|
|---|
| 26 |
|
|---|
| 27 | const MAX_MESSAGE_LEN = 2000;
|
|---|
| 28 |
|
|---|
| 29 | function 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
|
|---|
| 40 | function 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 ====================
|
|---|
| 50 | router.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 ====================
|
|---|
| 66 | router.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 ====================
|
|---|
| 89 | router.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 | // on-chat → full-height chat-view (geen artiest-profielkop, alleen de thread
|
|---|
| 115 | // scrollt). Zie chrome.ejs (_headerless) + de page-CSS hieronder.
|
|---|
| 116 | bodyClass: 'on-special on-chat',
|
|---|
| 117 | conversation: conv,
|
|---|
| 118 | other,
|
|---|
| 119 | messages,
|
|---|
| 120 | });
|
|---|
| 121 | });
|
|---|
| 122 |
|
|---|
| 123 | // ==================== SEND MESSAGE ====================
|
|---|
| 124 | router.post('/:id/send', requirePrutter, (req, res) => {
|
|---|
| 125 | const prutter = req.app.locals.prutter;
|
|---|
| 126 | const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
|
|---|
| 127 | if (!conv) return res.status(404).send('Not found');
|
|---|
| 128 |
|
|---|
| 129 | const me = req.session.user.id;
|
|---|
| 130 | if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
|
|---|
| 131 | if (conv.site_id !== res.locals.site.id) return res.status(404).send('Wrong site');
|
|---|
| 132 |
|
|---|
| 133 | const content = (req.body.content || '').toString().trim();
|
|---|
| 134 | if (!content) return res.status(400).send('Empty');
|
|---|
| 135 | if (content.length > MAX_MESSAGE_LEN) return res.status(413).send('Too long');
|
|---|
| 136 |
|
|---|
| 137 | const message = prutter.sendMessage(conv.id, me, content);
|
|---|
| 138 |
|
|---|
| 139 | // HTMX request → return the single rendered message HTML, appended to the thread
|
|---|
| 140 | if (req.headers['hx-request']) {
|
|---|
| 141 | return res.send(
|
|---|
| 142 | `<li class="prutter-msg prutter-msg--mine" data-msg-id="${message.id}">` +
|
|---|
| 143 | `<div class="prutter-msg-bubble">${escapeHtml(content)}</div>` +
|
|---|
| 144 | `</li>`
|
|---|
| 145 | );
|
|---|
| 146 | }
|
|---|
| 147 |
|
|---|
| 148 | res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
|
|---|
| 149 | });
|
|---|
| 150 |
|
|---|
| 151 | function escapeHtml(s) {
|
|---|
| 152 | return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|---|
| 153 | .replace(/"/g, '"').replace(/'/g, ''');
|
|---|
| 154 | }
|
|---|
| 155 |
|
|---|
| 156 | export default router;
|
|---|