Files
f0ckm/src/dm_attachment_handler.mjs

263 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* dm_attachment_handler.mjs — Server-side handler for encrypted DM attachments.
*
* All uploaded content is opaque AES-GCM ciphertext — the server cannot read it.
* Files are stored at /e/<id> (configured via DM_ATTACHMENT_DIR env or /e/).
*
* Routes (registered as bypass middlewares in index.mjs):
* POST /api/dm/attachment/upload/:recipientId — receive ciphertext blob, store on disk
* GET /api/dm/attachment/:id — stream ciphertext back (auth required)
* DELETE /api/dm/attachment/:id — delete (sender only or admin)
*/
import { promises as fs } from 'fs';
import path from 'path';
import db from './inc/sql.mjs';
import lib from './inc/lib.mjs';
import cfg from './inc/config.mjs';
import { collectBody } from './inc/multipart.mjs';
import { getDmAttachments, getDmAttachmentExpiryDays } from './inc/settings.mjs';
// ─── Config ──────────────────────────────────────────────────────────────────
const ATTACHMENT_DIR = process.env.DM_ATTACHMENT_DIR || cfg.paths.e;
const MAX_BYTES = 50 * 1024 * 1024; // 50 MB plaintext; ciphertext is ~1.33× due to base64url
// Ensure storage dir exists at startup
fs.mkdir(ATTACHMENT_DIR, { recursive: true }).catch(e =>
console.error('[DM_ATT] Failed to create attachment dir:', e.message)
);
// ─── Expiry cleanup ───────────────────────────────────────────────────────────
export async function cleanupExpiredAttachments() {
try {
const expired = await db`
SELECT id, file_path FROM dm_attachments
WHERE expires_at < now()
`;
if (!expired.length) return;
let deleted = 0;
for (const row of expired) {
await fs.unlink(row.file_path).catch(() => {}); // ignore already-missing files
deleted++;
}
const ids = expired.map(r => r.id);
await db`DELETE FROM dm_attachments WHERE id = ANY(${ids})`;
console.log(`[DM_ATT] Cleanup: removed ${deleted} expired attachment(s)`);
} catch (e) {
console.error('[DM_ATT] Cleanup error:', e.message);
}
}
// Run once at startup, then every 6 hours
cleanupExpiredAttachments();
setInterval(cleanupExpiredAttachments, 6 * 60 * 60 * 1000);
// ─── Session helper ───────────────────────────────────────────────────────────
async function resolveSession(req) {
if (!req.cookies?.session) return null;
try {
const rows = await db`
SELECT u.id, u.login, u.user, u.admin, u.is_moderator, s.csrf_token
FROM user_sessions s
LEFT JOIN "user" u ON u.id = s.user_id
WHERE s.session = ${lib.sha256(req.cookies.session)}
LIMIT 1
`;
return rows.length ? rows[0] : null;
} catch { return null; }
}
function sendJson(res, data, code = 200) {
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function validateCsrf(req, session) {
const token = req.headers['x-csrf-token'];
return session?.csrf_token && token && token === session.csrf_token;
}
// ─── Multipart parser (minimal — only parses fields + one binary file part) ───
function parseAttachmentMultipart(buffer, boundary) {
const boundaryBuf = Buffer.from(`--${boundary}`);
const segments = [];
let start = 0, idx;
while ((idx = buffer.indexOf(boundaryBuf, start)) !== -1) {
if (start !== 0) segments.push(buffer.slice(start, idx - 2));
start = idx + boundaryBuf.length + 2;
}
const result = { fields: {}, file: null };
for (const seg of segments) {
const hdrEnd = seg.indexOf('\r\n\r\n');
if (hdrEnd === -1) continue;
const headers = seg.slice(0, hdrEnd).toString();
const body = seg.slice(hdrEnd + 4);
const nameM = headers.match(/name="([^"]+)"/);
if (!nameM) continue;
const hasFile = headers.includes('filename=') || headers.includes('filename*=');
if (hasFile && !result.file) {
const ctM = headers.match(/Content-Type:\s*([^\r\n]+)/i);
result.file = {
data: body,
contentType: ctM ? ctM[1].trim() : 'application/octet-stream'
};
} else if (!hasFile) {
result.fields[nameM[1]] = body.toString().trim();
}
}
return result;
}
// ─── Upload handler ───────────────────────────────────────────────────────────
export async function handleDmAttachmentUpload(req, res, recipientId) {
if (!getDmAttachments()) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const session = await resolveSession(req);
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
const rid = parseInt(recipientId, 10);
if (!rid || rid === session.id) return sendJson(res, { success: false, msg: 'Invalid recipient' }, 400);
// Check recipient exists
const recip = await db`SELECT id FROM "user" WHERE id = ${rid} LIMIT 1`;
if (!recip.length) return sendJson(res, { success: false, msg: 'Recipient not found' }, 404);
const ct = req.headers['content-type'] || '';
const bndM = ct.match(/boundary=(?:"([^"]+)"|([^;]+))/);
if (!ct.includes('multipart/form-data') || !bndM) {
return sendJson(res, { success: false, msg: 'Expected multipart/form-data' }, 400);
}
let rawBody;
try {
// MAX_BYTES * 1.4 overhead for base64url encoding + multipart framing
rawBody = await collectBody(req, Math.ceil(MAX_BYTES * 1.4) + 4096);
} catch (e) {
if (e.code === 'BODY_TOO_LARGE') return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
throw e;
}
const boundary = bndM[1] || bndM[2];
const { fields, file } = parseAttachmentMultipart(rawBody, boundary);
if (!file) return sendJson(res, { success: false, msg: 'No file part found' }, 400);
const iv = (fields.iv || '').trim();
const originalName = (fields.original_name || '').slice(0, 255);
const mimeHint = (fields.mime_hint || '').slice(0, 100);
const sizeBytes = parseInt(fields.size_bytes || '0', 10) || 0;
if (!iv || iv.length > 32) return sendJson(res, { success: false, msg: 'Missing or invalid iv' }, 400);
if (file.data.length > Math.ceil(MAX_BYTES * 1.4)) {
return sendJson(res, { success: false, msg: 'Attachment too large (max 50 MB)' }, 413);
}
try {
// Insert DB record first to get an ID
const expiresAt = new Date(Date.now() + getDmAttachmentExpiryDays() * 86400000);
const [row] = await db`
INSERT INTO dm_attachments ${db({
sender_id: session.id,
recipient_id: rid,
iv,
file_path: '',
original_name: originalName,
mime_hint: mimeHint,
size_bytes: sizeBytes,
expires_at: expiresAt
})}
RETURNING id
`;
const filePath = path.join(ATTACHMENT_DIR, String(row.id));
await fs.writeFile(filePath, file.data);
// Update file_path now that we know the ID
await db`UPDATE dm_attachments SET file_path = ${filePath} WHERE id = ${row.id}`;
return sendJson(res, { success: true, id: String(row.id) });
} catch (err) {
console.error('[DM_ATT] Upload error:', err);
return sendJson(res, { success: false, msg: 'Server error' }, 500);
}
}
// ─── Download handler ─────────────────────────────────────────────────────────
export async function handleDmAttachmentDownload(req, res, attachmentId) {
const session = await resolveSession(req);
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
const id = parseInt(attachmentId, 10);
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
const rows = await db`
SELECT id, sender_id, recipient_id, iv, file_path, original_name, mime_hint
FROM dm_attachments
WHERE id = ${id}
LIMIT 1
`;
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const att = rows[0];
// Only sender or recipient may download
if (att.sender_id !== session.id && att.recipient_id !== session.id && !session.admin) {
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
}
try {
const data = await fs.readFile(att.file_path);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': String(data.length),
'Cache-Control': 'private, no-store',
'X-DM-IV': att.iv,
'X-Original-Name': encodeURIComponent(att.original_name || 'attachment'),
'X-Mime-Hint': att.mime_hint || ''
});
res.end(data);
} catch (err) {
console.error('[DM_ATT] Download error:', err);
return sendJson(res, { success: false, msg: 'File not found on disk' }, 404);
}
}
// ─── Delete handler ───────────────────────────────────────────────────────────
export async function handleDmAttachmentDelete(req, res, attachmentId) {
const session = await resolveSession(req);
if (!session) return sendJson(res, { success: false, msg: 'Unauthorized' }, 401);
if (!validateCsrf(req, session)) return sendJson(res, { success: false, msg: 'CSRF mismatch' }, 403);
const id = parseInt(attachmentId, 10);
if (!id) return sendJson(res, { success: false, msg: 'Invalid id' }, 400);
const rows = await db`SELECT id, sender_id, file_path FROM dm_attachments WHERE id = ${id} LIMIT 1`;
if (!rows.length) return sendJson(res, { success: false, msg: 'Not found' }, 404);
const att = rows[0];
if (att.sender_id !== session.id && !session.admin) {
return sendJson(res, { success: false, msg: 'Forbidden' }, 403);
}
try {
await fs.unlink(att.file_path).catch(() => {});
await db`DELETE FROM dm_attachments WHERE id = ${id}`;
return sendJson(res, { success: true });
} catch (err) {
console.error('[DM_ATT] Delete error:', err);
return sendJson(res, { success: false, msg: 'Server error' }, 500);
}
}