Files
f0ckm/src/inc/routes/messages.mjs

494 lines
24 KiB
JavaScript

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\/(?<userId>\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\/(?<userId>\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\/(?<userId>\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\/(?<userId>\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\/(?<msgId>\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\/(?<msgId>\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\/(?<userId>\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\/(?<userId>\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\/(?<username>[^/]+)/, 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\/(?<username>[^/]+)/, 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;
};