source: Klonkt/src/routes/prutter.js@ 1907a18

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

feat: viewer role + artists directory + Klonkt Hub Beta rebrand

Viewer role (replaces the separate view-mode/readonly toggle):

  • 'kijker' is now a real role (VALID_ROLES) selectable in Admin. May view EVERYTHING including Admin, but cannot modify anything.
  • isViewer(user) (= role kijker or legacy readonly flag) is the source; requireGod/requireSiteManager(BySlug) let a viewer through (viewing), the global guard 403s every write. Existing readonly accounts are migrated on each role change (readonly=0).
  • Write leaks via GET patched: /prutter/new (INSERT) blocks for viewers, /prutter/:id skips markAsRead (UPDATE); WS upgrade rejects viewers (the HTTP guard doesn't cover WebSockets).
  • Clean "Viewer mode" page (viewer-blocked) instead of raw 403 text; styled sticky banner; account page shows read-only UI instead of an upload button that silently 403s. canMutate hides write buttons.

Scalability (>50 artists):

  • Hub home shows max 24 (most active first) + "All N artists ->".
  • New searchable, paginated /artiesten directory (hub only).
  • 'user' + 'artiesten' reserved as slugs.

Rebrand PrutFolio v1 -> Klonkt Hub Beta (footer, PWA manifest, account/
admin texts, default page title, startup, README; internal package +
PWA id 'prutfolio' remain for stability).

Co-Authored-By: Claude <noreply@…>

  • Property mode set to 100644
File size: 5.7 KB
RevLine 
[7bc636b]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';
[8afbdd6]20import { requireAuth, isViewer } from '../middleware/auth.js';
[7bc636b]21import PermissionsService from '../services/PermissionsService.js';
22
23const router = express.Router();
24
25const MAX_MESSAGE_LEN = 2000;
26
27function siteAllowsDM(req, res) {
28 const site = res.locals.site;
29 if (!site) return false;
30 return site.enable_prutter !== 0;
31}
32
33// Middleware: require both auth + Prutter enabled
34function requirePrutter(req, res, next) {
35 if (!siteAllowsDM(req, res)) return res.status(404).send('Prutter not enabled on this site');
36 return requireAuth(req, res, next);
37}
38
39// ==================== INBOX ====================
40router.get('/', requirePrutter, (req, res) => {
41 const prutter = req.app.locals.prutter;
42 const conversations = prutter.getUserConversations(req.session.user.id);
43
44 // Filter to only conversations on THIS site (Prutter scope is per-site)
45 const siteId = res.locals.site.id;
46 const scoped = conversations.filter(c => c.site_id === siteId);
47
48 renderPage(req, res, 'pages/prutter-inbox', {
49 pageTitle: 'Prutter',
50 bodyClass: 'on-special',
51 conversations: scoped,
52 });
53});
54
55// ==================== START / RESUME CONVERSATION ====================
56router.get('/new', requirePrutter, (req, res) => {
[8afbdd6]57 // Een gesprek starten is een schrijf-actie (INSERT) — een kijker mag dat niet.
58 // De globale guard pakt dit niet omdat het een GET is, dus expliciet blokkeren.
59 if (isViewer(req.session.user)) {
60 res.status(403);
61 return renderPage(req, res, 'pages/viewer-blocked', { pageTitle: 'Kijker-modus', bodyClass: 'on-special' });
62 }
[7bc636b]63 const targetUsername = (req.query.to || '').toString().trim();
64 if (!targetUsername) {
65 return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
66 }
67 const target = db.prepare('SELECT id, username FROM users WHERE username = ?').get(targetUsername);
68 if (!target) return res.status(404).send('User not found');
69 if (target.id === req.session.user.id) {
70 return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
71 }
72
73 const prutter = req.app.locals.prutter;
74 const conv = prutter.getOrCreateConversation(req.session.user.id, target.id, res.locals.site.id);
75 res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
76});
77
78// ==================== CONVERSATION VIEW ====================
79router.get('/:id', requirePrutter, (req, res) => {
80 const prutter = req.app.locals.prutter;
81 const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
82 if (!conv) return res.status(404).send('Conversation not found');
83
84 // Auth: must be a participant
85 const me = req.session.user.id;
86 if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
87
88 // Scope: this conversation must belong to the resolved site
89 if (conv.site_id !== res.locals.site.id) return res.status(404).send('Conversation not on this site');
90
91 // Other party
92 const otherId = conv.user_a_id === me ? conv.user_b_id : conv.user_a_id;
93 const other = db.prepare('SELECT id, username, avatar_url FROM users WHERE id = ?').get(otherId);
94
95 // Messages (oldest first for natural reading order)
96 const messages = prutter.getMessages(conv.id, 200, 0).reverse();
97
[8afbdd6]98 // Mark inbound messages as read — sla over voor kijkers (markAsRead is een
99 // UPDATE; een GET valt buiten de globale guard, dus hier expliciet skippen).
100 if (!isViewer(req.session.user)) prutter.markAsRead(conv.id, me);
[7bc636b]101
102 renderPage(req, res, 'pages/prutter-conversation', {
103 pageTitle: 'Prutter — ' + (other?.username || ''),
104 bodyClass: 'on-special',
105 conversation: conv,
106 other,
107 messages,
108 });
109});
110
111// ==================== SEND MESSAGE ====================
112router.post('/:id/send', requirePrutter, (req, res) => {
113 const prutter = req.app.locals.prutter;
114 const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
115 if (!conv) return res.status(404).send('Not found');
116
117 const me = req.session.user.id;
118 if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
119 if (conv.site_id !== res.locals.site.id) return res.status(404).send('Wrong site');
120
121 const content = (req.body.content || '').toString().trim();
122 if (!content) return res.status(400).send('Empty');
123 if (content.length > MAX_MESSAGE_LEN) return res.status(413).send('Too long');
124
125 const message = prutter.sendMessage(conv.id, me, content);
126
127 // HTMX request → return the single rendered message HTML, appended to the thread
128 if (req.headers['hx-request']) {
129 return res.send(
130 `<li class="prutter-msg prutter-msg--mine" data-msg-id="${message.id}">` +
131 `<div class="prutter-msg-bubble">${escapeHtml(content)}</div>` +
132 `</li>`
133 );
134 }
135
136 res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
137});
138
139function escapeHtml(s) {
140 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
141 .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
142}
143
144export default router;
Note: See TracBrowser for help on using the repository browser.