add encrypted dm attachments
This commit is contained in:
264
src/dm_attachment_handler.mjs
Normal file
264
src/dm_attachment_handler.mjs
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* 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 } 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 [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
|
||||
})}
|
||||
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) {
|
||||
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);
|
||||
|
||||
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) {
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ config.paths = {
|
||||
emojis: resolvePath('public/s/emojis'),
|
||||
koepfe: resolvePath('public/s/koepfe'),
|
||||
memes: resolvePath('public/memes'),
|
||||
e: resolvePath('e'),
|
||||
pending: resolvePath('pending'),
|
||||
deleted: resolvePath('deleted'),
|
||||
logs: resolvePath('logs'),
|
||||
|
||||
@@ -7,6 +7,7 @@ let trusted_uploads = 0;
|
||||
let bypass_duplicate_check = false;
|
||||
let protect_files = false;
|
||||
let private_messages = true;
|
||||
let dm_attachments = true;
|
||||
let default_layout = 'modern';
|
||||
let enable_pdf = false;
|
||||
let enable_cleanup = false;
|
||||
@@ -59,6 +60,9 @@ export const setProtectFiles = (val) => protect_files = !!val;
|
||||
export const getPrivateMessages = () => private_messages;
|
||||
export const setPrivateMessages = (val) => private_messages = !!val;
|
||||
|
||||
export const getDmAttachments = () => dm_attachments;
|
||||
export const setDmAttachments = (val) => dm_attachments = !!val;
|
||||
|
||||
export const getDefaultLayout = () => default_layout;
|
||||
export const setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern');
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ import { handleHallImageUpload, handleHallImageDelete, handleHallDelete, handleH
|
||||
import { handleMetaExtract } from "./meta_extract_handler.mjs";
|
||||
import { handleMetaStrip } from "./meta_strip_handler.mjs";
|
||||
import { handleCommentUpload } from "./comment_upload_handler.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs";
|
||||
import { handleDmAttachmentUpload, handleDmAttachmentDownload, handleDmAttachmentDelete } from "./dm_attachment_handler.mjs";
|
||||
import { getManualApproval, setManualApproval, getMinTags, setMinTags, getRegistrationOpen, setRegistrationOpen, getTrustedUploads, setTrustedUploads, getBypassDuplicateCheck, setBypassDuplicateCheck, getProtectFiles, setProtectFiles, getPrivateMessages, setPrivateMessages, getDmAttachments, setDmAttachments, getDefaultLayout, setDefaultLayout, getEnablePdf, setEnablePdf, getEnableCleanup, setEnableCleanup, getCleanupStartDate, setCleanupStartDate, getCleanupEndDate, setCleanupEndDate, getLogUserIps, setLogUserIps, getHashUserIps, setHashUserIps, getShitpostMode, setShitpostMode } from "./inc/settings.mjs";
|
||||
import { updateHallsCache, getHalls } from "./inc/halls_cache.mjs";
|
||||
import { createI18n } from "./inc/i18n.mjs";
|
||||
import security from "./inc/security.mjs";
|
||||
@@ -346,7 +347,7 @@ process.on('uncaughtException', err => {
|
||||
|
||||
// Ensure storage directories exist
|
||||
const initDirs = [
|
||||
cfg.paths.a, cfg.paths.b, cfg.paths.c, cfg.paths.t, cfg.paths.ca, cfg.paths.emojis, cfg.paths.memes, cfg.paths.tmp, cfg.paths.logs,
|
||||
cfg.paths.a, cfg.paths.b, cfg.paths.c, cfg.paths.t, cfg.paths.ca, cfg.paths.e, cfg.paths.emojis, cfg.paths.memes, cfg.paths.tmp, cfg.paths.logs,
|
||||
path.join(cfg.paths.pending, 'b'), path.join(cfg.paths.pending, 't'), path.join(cfg.paths.pending, 'ca'),
|
||||
path.join(cfg.paths.deleted, 'b'), path.join(cfg.paths.deleted, 't'), path.join(cfg.paths.deleted, 'ca')
|
||||
];
|
||||
@@ -712,6 +713,8 @@ process.on('uncaughtException', err => {
|
||||
app.use(async (req, res) => {
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return;
|
||||
if (['/login', '/register', '/api/v2/upload', '/api/v2/settings/uploadAvatar', '/api/v2/admin/memes', '/api/v2/admin/emojis', '/api/v2/meta/extract-file', '/api/v2/meta/strip-gps', '/api/v2/scroller/external/rehost-meta', '/api/v2/comments/upload'].includes(req.url.pathname)) return;
|
||||
// DM attachment upload validates CSRF internally
|
||||
if (req.url.pathname.match(/^\/api\/dm\/attachment\/upload\//)) return;
|
||||
// Hall manager routes are handled by bypass middleware with their own session auth
|
||||
if (cfg.websrv.halls_enabled !== false && req.url.pathname.match(/^\/api\/v2\/admin\/halls(\/|$)/)) return;
|
||||
// User hall image upload is handled by bypass middleware below
|
||||
@@ -834,6 +837,22 @@ process.on('uncaughtException', err => {
|
||||
}
|
||||
});
|
||||
|
||||
// Bypass middleware for DM encrypted attachment upload/download/delete
|
||||
app.use(async (req, res) => {
|
||||
const uploadMatch = req.url.pathname.match(/^\/api\/dm\/attachment\/upload\/(\d+)$/);
|
||||
const downloadMatch = req.url.pathname.match(/^\/api\/dm\/attachment\/(\d+)$/);
|
||||
if (req.method === 'POST' && uploadMatch) {
|
||||
await handleDmAttachmentUpload(req, res, uploadMatch[1]);
|
||||
req.url.pathname = '/handled_dm_attachment_upload_bypass';
|
||||
} else if (req.method === 'GET' && downloadMatch) {
|
||||
await handleDmAttachmentDownload(req, res, downloadMatch[1]);
|
||||
req.url.pathname = '/handled_dm_attachment_download_bypass';
|
||||
} else if (req.method === 'DELETE' && downloadMatch) {
|
||||
await handleDmAttachmentDelete(req, res, downloadMatch[1]);
|
||||
req.url.pathname = '/handled_dm_attachment_delete_bypass';
|
||||
}
|
||||
});
|
||||
|
||||
tpl.views = "views";
|
||||
tpl.debug = true;
|
||||
tpl.cache = false;
|
||||
@@ -982,7 +1001,11 @@ process.on('uncaughtException', err => {
|
||||
// Default is true; set to false to fully disable private messaging
|
||||
setPrivateMessages(cfg.websrv.private_messages !== false);
|
||||
console.log(`[BOOT] Private messaging: ${cfg.websrv.private_messages !== false ? 'ENABLED' : 'DISABLED'}`);
|
||||
|
||||
|
||||
// Load dm_attachments from config.json (static — not a DB setting)
|
||||
// Default is true; requires private_messages to also be enabled
|
||||
setDmAttachments(cfg.websrv.dm_attachments !== false);
|
||||
console.log(`[BOOT] DM attachments: ${cfg.websrv.dm_attachments !== false ? 'ENABLED' : 'DISABLED'}`);
|
||||
// Load default_layout from config.json (static)
|
||||
if (cfg.websrv.default_layout) {
|
||||
setDefaultLayout(cfg.websrv.default_layout);
|
||||
@@ -1069,6 +1092,7 @@ process.on('uncaughtException', err => {
|
||||
themes_json: JSON.stringify(cfg.websrv.themes || []),
|
||||
enable_profile_description: !!cfg.websrv.enable_profile_description,
|
||||
get private_messages() { return getPrivateMessages(); },
|
||||
get dm_attachments() { return getDmAttachments(); },
|
||||
get enable_pdf() { return getEnablePdf(); },
|
||||
get enable_cleanup() { return getEnableCleanup(); },
|
||||
get cleanup_start_date() { return getCleanupStartDate(); },
|
||||
|
||||
Reference in New Issue
Block a user