import db from "../sql.mjs"; import lib from "../lib.mjs"; import cfg from "../config.mjs"; import { getPrivateMessages } from "../settings.mjs"; export default (router, tpl) => { const json = (res, body, code = 200) => res.reply({ code, headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body) }); // Block all DM routes when private messaging is disabled in config const dmEnabled = (req, res, next) => { if (!getPrivateMessages()) return res.reply({ code: 404, body: 'Not found' }); if (next) next(); }; // ─── Public Key Management ─────────────────────────────────────────────── // Upload / refresh current user's public key router.post('/api/dm/pubkey', async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const body = req.post || {}; const pubkey = body.pubkey; const fingerprint = body.fingerprint || null; if (!pubkey || typeof pubkey !== 'string' || pubkey.length > 4096) { return json(res, { success: false, msg: 'Invalid pubkey' }, 400); } // Basic JWK validation — must look like JSON with kty field try { const parsed = JSON.parse(pubkey); if (!parsed.kty || parsed.kty !== 'EC' || parsed.crv !== 'P-256') { return json(res, { success: false, msg: 'Invalid key type — expected EC P-256' }, 400); } } catch { return json(res, { success: false, msg: 'Invalid JSON' }, 400); } try { await db` INSERT INTO user_pubkeys ${db({ user_id: req.session.id, pubkey, fingerprint, updated_at: db`NOW()` })} ON CONFLICT (user_id) DO UPDATE SET pubkey = EXCLUDED.pubkey, fingerprint = EXCLUDED.fingerprint, updated_at = NOW() `; return json(res, { success: true }); } catch (err) { console.error('[DM] pubkey upsert failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // Fetch a user's public key by user ID router.get(/\/api\/dm\/pubkey\/(?\d+)/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const userId = parseInt(req.params.userId, 10); try { const rows = await db`SELECT pubkey, fingerprint FROM user_pubkeys WHERE user_id = ${userId}`; if (!rows.length) return json(res, { success: false, msg: 'No key registered for this user' }, 404); return json(res, { success: true, pubkey: rows[0].pubkey, fingerprint: rows[0].fingerprint }); } catch (err) { console.error('[DM] pubkey fetch failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // ─── Key Vault (seed-phrase encrypted private key backup) ──────────────── // Upload or replace the encrypted private key blob router.post('/api/dm/keyvault', async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const body = req.post || {}; const { salt, iv, ciphertext } = body; if (!salt || !iv || !ciphertext || typeof salt !== 'string' || typeof iv !== 'string' || typeof ciphertext !== 'string') { return json(res, { success: false, msg: 'Missing salt, iv or ciphertext' }, 400); } // Length guards — salt: 24 chars (16 bytes b64), iv: 16 chars (12 bytes b64), ciphertext: privkey JWK is ~200 bytes + AES overhead → max 512 if (salt.length > 32 || iv.length > 24 || ciphertext.length > 1024) { return json(res, { success: false, msg: 'Payload out of bounds' }, 400); } try { await db` INSERT INTO user_dm_keyvault ${db({ user_id: req.session.id, salt, iv, ciphertext, updated_at: db`NOW()` })} ON CONFLICT (user_id) DO UPDATE SET salt = EXCLUDED.salt, iv = EXCLUDED.iv, ciphertext = EXCLUDED.ciphertext, updated_at = NOW() `; return json(res, { success: true }); } catch (err) { console.error('[DM] keyvault upsert failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // Fetch the encrypted blob for the current user router.get('/api/dm/keyvault', async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); try { const rows = await db`SELECT salt, iv, ciphertext, version FROM user_dm_keyvault WHERE user_id = ${req.session.id}`; if (!rows.length) return json(res, { success: false, msg: 'No vault found' }, 404); return json(res, { success: true, vault: rows[0] }); } catch (err) { console.error('[DM] keyvault fetch failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // Delete the vault entry (user wants to reset) router.delete('/api/dm/keyvault', async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); try { await db`DELETE FROM user_dm_keyvault WHERE user_id = ${req.session.id}`; return json(res, { success: true }); } catch (err) { console.error('[DM] keyvault delete failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // ─── Conversations List ────────────────────────────────────────────────── // Returns the list of unique users the current user has exchanged messages with, // plus last message metadata (no ciphertext exposed unnecessarily here — only metadata) router.get('/api/dm/conversations', async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); try { const rows = await db` WITH convos AS ( SELECT CASE WHEN sender_id = ${req.session.id} THEN recipient_id ELSE sender_id END AS other_id, MAX(id) AS last_msg_id FROM private_messages WHERE sender_id = ${req.session.id} OR recipient_id = ${req.session.id} GROUP BY other_id ), unread_counts AS ( SELECT sender_id AS other_id, COUNT(*) AS unread FROM private_messages WHERE recipient_id = ${req.session.id} AND is_read = false GROUP BY sender_id ) SELECT u.id AS user_id, u.user AS username, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name, pm.created_at AS last_message_at, COALESCE(uc.unread, 0) AS unread_count FROM convos c JOIN "user" u ON u.id = c.other_id LEFT JOIN user_options uo ON uo.user_id = u.id JOIN private_messages pm ON pm.id = c.last_msg_id LEFT JOIN unread_counts uc ON uc.other_id = c.other_id LEFT JOIN user_conversation_states ucs ON ucs.user_id = ${req.session.id} AND ucs.other_id = c.other_id WHERE (ucs.is_hidden IS NULL OR ucs.is_hidden = false) ORDER BY pm.created_at DESC `; return json(res, { success: true, conversations: rows }); } catch (err) { console.error('[DM] conversations failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // ─── Message Thread ────────────────────────────────────────────────────── // Fetch paginated messages between current user and another user // Supports ?before=id (older pages) and ?after=id (live updates since last known msg) router.get(/\/api\/dm\/thread\/(?\d+)/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const otherId = parseInt(req.params.userId, 10); const limit = 50; const before = req.url.qs?.before ? parseInt(req.url.qs.before, 10) : null; const after = req.url.qs?.after ? parseInt(req.url.qs.after, 10) : null; // Validate other user exists const other = await db`SELECT id, user FROM "user" WHERE id = ${otherId} LIMIT 1`; if (!other.length) return json(res, { success: false, msg: 'User not found' }, 404); try { // Unhide for the viewing user await db` INSERT INTO user_conversation_states (user_id, other_id, is_hidden) VALUES (${req.session.id}, ${otherId}, false) ON CONFLICT (user_id, other_id) DO UPDATE SET is_hidden = false `; const messages = await db` SELECT pm.id, pm.sender_id, pm.recipient_id, pm.ciphertext, pm.iv, pm.is_read, pm.created_at, pm.edited_at FROM private_messages pm WHERE ( (pm.sender_id = ${req.session.id} AND pm.recipient_id = ${otherId}) OR (pm.sender_id = ${otherId} AND pm.recipient_id = ${req.session.id}) ) ${after ? db`AND pm.id > ${after}` : db``} ${before ? db`AND pm.id < ${before}` : db``} ORDER BY pm.id ${after ? db`ASC` : db`DESC`} LIMIT ${after ? 200 : limit + 1} `; // Only applies to paginated (before) fetches const hasMore = !after && messages.length > limit; if (hasMore) messages.pop(); return json(res, { success: true, messages: after ? messages : messages.reverse(), hasMore, other: other[0] }); } catch (err) { console.error('[DM] thread fetch failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // Send a message (store ciphertext) router.post(/\/api\/dm\/send\/(?\d+)/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const recipientId = parseInt(req.params.userId, 10); if (recipientId === req.session.id) return json(res, { success: false, msg: "Can't DM yourself" }, 400); const body = req.post || {}; const { ciphertext, iv } = body; if (!ciphertext || !iv || typeof ciphertext !== 'string' || typeof iv !== 'string') { return json(res, { success: false, msg: 'Missing ciphertext or iv' }, 400); } // Sanity length bounds: ciphertext max ~64 KB encoded, iv is 16 chars (12 bytes base64url) if (ciphertext.length > 65536 || iv.length > 32) { return json(res, { success: false, msg: 'Payload too large' }, 413); } // Verify recipient exists const recip = await db`SELECT id FROM "user" WHERE id = ${recipientId} LIMIT 1`; if (!recip.length) return json(res, { success: false, msg: 'Recipient not found' }, 404); try { const msg = await db.begin(async sql => { const [m] = await sql` INSERT INTO private_messages ${db({ sender_id: req.session.id, recipient_id: recipientId, ciphertext, iv })} RETURNING id, created_at `; // Unhide for both parties await sql` INSERT INTO user_conversation_states (user_id, other_id, is_hidden) VALUES (${req.session.id}, ${recipientId}, false), (${recipientId}, ${req.session.id}, false) ON CONFLICT (user_id, other_id) DO UPDATE SET is_hidden = false `; return m; }); // Notify recipient via SSE (pg_notify wakes the db.listen in notifications.mjs) await db`SELECT pg_notify('private_message', ${JSON.stringify({ id: msg.id, sender_id: req.session.id, recipient_id: recipientId, created_at: msg.created_at })})`; return json(res, { success: true, id: msg.id, created_at: msg.created_at }); } catch (err) { console.error('[DM] send failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // Mark a conversation as read router.post(/\/api\/dm\/read\/(?\d+)/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const otherId = parseInt(req.params.userId, 10); try { await db`UPDATE private_messages SET is_read = true WHERE recipient_id = ${req.session.id} AND sender_id = ${otherId} AND is_read = false`; return json(res, { success: true }); } catch (err) { console.error('[DM] read mark failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // Edit own message (re-encrypt in browser, send new ciphertext + iv) router.patch(/\/api\/dm\/message\/(?\d+)/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const csrf = req.headers['x-csrf-token']; if (!csrf || csrf !== req.session.csrf_token) return json(res, { success: false, msg: 'CSRF mismatch' }, 403); const msgId = parseInt(req.params.msgId, 10); const body = req.post || {}; const { ciphertext, iv } = body; if (!ciphertext || !iv || typeof ciphertext !== 'string' || typeof iv !== 'string') { return json(res, { success: false, msg: 'Missing ciphertext or iv' }, 400); } if (ciphertext.length > 65536 || iv.length > 32) { return json(res, { success: false, msg: 'Payload too large' }, 413); } try { const result = await db` UPDATE private_messages SET ciphertext = ${ciphertext}, iv = ${iv}, edited_at = NOW() WHERE id = ${msgId} AND sender_id = ${req.session.id} RETURNING id, edited_at `; if (!result.length) return json(res, { success: false, msg: 'Not found or not yours' }, 404); return json(res, { success: true, id: result[0].id, edited_at: result[0].edited_at }); } catch (err) { console.error('[DM] edit message failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // Delete own message (and optionally its attachment blobs) router.delete(/\/api\/dm\/message\/(?\d+)/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const csrf = req.headers['x-csrf-token']; if (!csrf || csrf !== req.session.csrf_token) return json(res, { success: false, msg: 'CSRF mismatch' }, 403); const msgId = parseInt(req.params.msgId, 10); const body = req.post || {}; const rawIds = body['attachment_ids[]'] ?? body.attachment_ids; const attachmentIds = (Array.isArray(rawIds) ? rawIds : rawIds ? [rawIds] : []) .map(Number).filter(n => Number.isFinite(n) && n > 0); // Verify message belongs to sender const rows = await db`SELECT id FROM private_messages WHERE id = ${msgId} AND sender_id = ${req.session.id} LIMIT 1`; if (!rows.length) return json(res, { success: false, msg: 'Not found or not yours' }, 404); try { // Clean up attachments the client identified (verify sender ownership server-side) if (attachmentIds.length) { const { promises: fsP } = await import('fs'); const atts = await db` SELECT id, file_path FROM dm_attachments WHERE id = ANY(${attachmentIds}) AND sender_id = ${req.session.id} `; for (const att of atts) await fsP.unlink(att.file_path).catch(() => {}); if (atts.length) { const ids = atts.map(a => a.id); await db`DELETE FROM dm_attachments WHERE id = ANY(${ids})`; } } await db`DELETE FROM private_messages WHERE id = ${msgId} AND sender_id = ${req.session.id}`; return json(res, { success: true }); } catch (err) { console.error('[DM] delete message failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // Hide a whole conversation (Close DM) router.post(/\/api\/dm\/conversation\/(?\d+)\/delete/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const otherId = parseInt(req.params.userId, 10); try { await db` INSERT INTO user_conversation_states (user_id, other_id, is_hidden) VALUES (${req.session.id}, ${otherId}, true) ON CONFLICT (user_id, other_id) DO UPDATE SET is_hidden = true `; return json(res, { success: true }); } catch (err) { console.error('[DM] hide conversation failed:', err); return json(res, { success: false, msg: 'DB error' }, 500); } }); // Presence check — last_seen timestamp for a given user (online = seen < 5 min ago) router.get(/\/api\/dm\/presence\/(?\d+)/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false }, 404); if (!req.session) return json(res, { success: false }, 401); const userId = parseInt(req.params.userId, 10); try { const rows = await db`SELECT last_seen FROM "user" WHERE id = ${userId} AND banned = false LIMIT 1`; if (!rows.length) return json(res, { success: false, msg: 'User not found' }, 404); const lastSeen = rows[0].last_seen || 0; // unix seconds const now = ~~(Date.now() / 1000); const online = (now - lastSeen) < 300; // 5-minute window return json(res, { success: true, online, last_seen: lastSeen }); } catch (err) { console.error('[DM] presence failed:', err); return json(res, { success: false }, 500); } }); // Total unread DM count (for navbar badge polling) router.get('/api/dm/unread', async (req, res) => { if (!getPrivateMessages()) return json(res, { success: true, count: 0 }); if (!req.session) return json(res, { success: false, count: 0 }, 401); try { const [row] = await db` SELECT COUNT(*) AS count FROM private_messages WHERE recipient_id = ${req.session.id} AND is_read = false `; return json(res, { success: true, count: parseInt(row.count, 10) }); } catch (err) { return json(res, { success: false, count: 0 }, 500); } }); // Resolve username → user_id (needed for compose from user profile) router.get(/\/api\/dm\/resolve\/(?[^/]+)/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); if (!req.session) return json(res, { success: false, msg: 'Unauthorized' }, 401); const uname = decodeURIComponent(req.params.username); try { const rows = await db`SELECT id, user FROM "user" WHERE login = ${uname.toLowerCase()} LIMIT 1`; if (!rows.length) return json(res, { success: false, msg: 'User not found' }, 404); return json(res, { success: true, user_id: rows[0].id, username: rows[0].user }); } catch (err) { return json(res, { success: false, msg: 'DB error' }, 500); } }); // ─── Pages ─────────────────────────────────────────────────────────────── // Inbox page router.get('/messages', async (req, res) => { if (!getPrivateMessages()) return res.reply({ code: 404, body: 'Not found' }); if (!req.session) return res.redirect('/login'); return res.html(tpl.render('messages', { session: req.session, hidePagination: true, link: { main: '/messages', path: '/' }, domain: cfg.main.url.domain }, req)); }); // Conversation page — /messages/:username router.get(/\/messages\/(?[^/]+)/, async (req, res) => { if (!getPrivateMessages()) return res.reply({ code: 404, body: 'Not found' }); if (!req.session) return res.redirect('/login'); const uname = decodeURIComponent(req.params.username); const rows = await db` SELECT u.id, u.user, uo.avatar, uo.avatar_file, uo.username_color, uo.display_name FROM "user" u LEFT JOIN user_options uo ON uo.user_id = u.id WHERE u.login = ${uname.toLowerCase()} LIMIT 1 `; if (!rows.length) return res.reply({ code: 404, body: 'User not found' }); const other = rows[0]; return res.html(tpl.render('messages-conversation', { session: req.session, other, hidePagination: true, link: { main: '/messages', path: '/' }, domain: cfg.main.url.domain }, req)); }); return router; };