source: Klonkt/src/routes/prutter.js@ 5b22ea9

main
Last change on this file since 5b22ea9 was 7bc636b, checked in by Robin <robin@…>, 4 months ago

Initial commit — PrutFolio v1 source (pulled from Hetzner /srv/prutfolio)

  • Property mode set to 100644
File size: 5.2 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 } from '../middleware/auth.js';
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) => {
57 const targetUsername = (req.query.to || '').toString().trim();
58 if (!targetUsername) {
59 return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
60 }
61 const target = db.prepare('SELECT id, username FROM users WHERE username = ?').get(targetUsername);
62 if (!target) return res.status(404).send('User not found');
63 if (target.id === req.session.user.id) {
64 return res.redirect(`${res.locals.siteUrlBase || ''}/prutter`);
65 }
66
67 const prutter = req.app.locals.prutter;
68 const conv = prutter.getOrCreateConversation(req.session.user.id, target.id, res.locals.site.id);
69 res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
70});
71
72// ==================== CONVERSATION VIEW ====================
73router.get('/:id', requirePrutter, (req, res) => {
74 const prutter = req.app.locals.prutter;
75 const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
76 if (!conv) return res.status(404).send('Conversation not found');
77
78 // Auth: must be a participant
79 const me = req.session.user.id;
80 if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
81
82 // Scope: this conversation must belong to the resolved site
83 if (conv.site_id !== res.locals.site.id) return res.status(404).send('Conversation not on this site');
84
85 // Other party
86 const otherId = conv.user_a_id === me ? conv.user_b_id : conv.user_a_id;
87 const other = db.prepare('SELECT id, username, avatar_url FROM users WHERE id = ?').get(otherId);
88
89 // Messages (oldest first for natural reading order)
90 const messages = prutter.getMessages(conv.id, 200, 0).reverse();
91
92 // Mark inbound messages as read
93 prutter.markAsRead(conv.id, me);
94
95 renderPage(req, res, 'pages/prutter-conversation', {
96 pageTitle: 'Prutter — ' + (other?.username || ''),
97 bodyClass: 'on-special',
98 conversation: conv,
99 other,
100 messages,
101 });
102});
103
104// ==================== SEND MESSAGE ====================
105router.post('/:id/send', requirePrutter, (req, res) => {
106 const prutter = req.app.locals.prutter;
107 const conv = db.prepare('SELECT * FROM conversations WHERE id = ?').get(req.params.id);
108 if (!conv) return res.status(404).send('Not found');
109
110 const me = req.session.user.id;
111 if (conv.user_a_id !== me && conv.user_b_id !== me) return res.status(403).send('Not a participant');
112 if (conv.site_id !== res.locals.site.id) return res.status(404).send('Wrong site');
113
114 const content = (req.body.content || '').toString().trim();
115 if (!content) return res.status(400).send('Empty');
116 if (content.length > MAX_MESSAGE_LEN) return res.status(413).send('Too long');
117
118 const message = prutter.sendMessage(conv.id, me, content);
119
120 // HTMX request → return the single rendered message HTML, appended to the thread
121 if (req.headers['hx-request']) {
122 return res.send(
123 `<li class="prutter-msg prutter-msg--mine" data-msg-id="${message.id}">` +
124 `<div class="prutter-msg-bubble">${escapeHtml(content)}</div>` +
125 `</li>`
126 );
127 }
128
129 res.redirect(`${res.locals.siteUrlBase || ''}/prutter/${conv.id}`);
130});
131
132function escapeHtml(s) {
133 return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
134 .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
135}
136
137export default router;
Note: See TracBrowser for help on using the repository browser.