263 lines
11 KiB
JavaScript
263 lines
11 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
}
|