diff --git a/config_example.json b/config_example.json index 0bc39ea..6405e3a 100644 --- a/config_example.json +++ b/config_example.json @@ -61,6 +61,7 @@ "enable_global_chat": true, "enable_danmaku": true, "private_messages": true, + "dm_attachments": true, "halls_enabled": true, "userhalls_enabled": true, "enable_userhall_image_upload": true, diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index e0c76f1..311feec 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -2759,4 +2759,22 @@ CREATE INDEX IF NOT EXISTS idx_user_ips_user_id ON user_ips(user_id); -- Add IP tracking to user_sessions for "current" IP view ALTER TABLE user_sessions ADD COLUMN IF NOT EXISTS ip TEXT; +-- DM encrypted attachments (Migration 005) +CREATE TABLE IF NOT EXISTS public.dm_attachments ( + id bigserial PRIMARY KEY, + sender_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE, + recipient_id integer NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE, + iv text NOT NULL, + file_path text NOT NULL, + original_name text NOT NULL DEFAULT '', + mime_hint text NOT NULL DEFAULT '', + size_bytes integer NOT NULL DEFAULT 0, + created_at timestamp with time zone DEFAULT now(), + expires_at timestamp with time zone DEFAULT (now() + interval '30 days') +); + +CREATE INDEX IF NOT EXISTS idx_dm_att_sender ON public.dm_attachments(sender_id); +CREATE INDEX IF NOT EXISTS idx_dm_att_recipient ON public.dm_attachments(recipient_id); +CREATE INDEX IF NOT EXISTS idx_dm_att_expires ON public.dm_attachments(expires_at); + \unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index 03c2d15..eecbe6d 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -11622,7 +11622,197 @@ span.dm-post-card--loading { border-color: rgba(255, 255, 255, 0.15); } -/* ── Global Chat Post Preview Card ──────────────────────────── */ +/* ── DM Encrypted Attachment Card ────────────────────────── */ +a.dm-attachment-card { + display: inline-flex; + align-items: center; + gap: 10px; + border-radius: 10px; + overflow: hidden; + text-decoration: none; + border: 1px solid rgba(255, 255, 255, 0.12); + background: rgba(0, 0, 0, 0.28); + margin: 6px 0 2px; + padding: 10px 14px 10px 12px; + max-width: 300px; + min-width: 180px; + transition: border-color 0.15s, background 0.15s; + cursor: pointer; + color: inherit; + position: relative; +} + +a.dm-attachment-card:hover { + border-color: var(--accent); + background: rgba(0, 0, 0, 0.4); +} + +.dm-attachment-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.07); + flex-shrink: 0; + font-size: 1.1em; + color: var(--accent, #aaa); +} + +.dm-attachment-card__info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.dm-attachment-card__name { + font-size: 0.82em; + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg, #ddd); +} + +.dm-attachment-card__size { + font-size: 0.72em; + color: rgba(255, 255, 255, 0.4); +} + +.dm-attachment-card__dl { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + font-size: 0.85em; + color: rgba(255, 255, 255, 0.35); + padding-left: 4px; + transition: color 0.15s; +} + +a.dm-attachment-card:hover .dm-attachment-card__dl { + color: var(--accent, #aaa); +} + +/* Lock badge — indicates E2EE */ +a.dm-attachment-card::after { + content: '\f023'; + font-family: 'Font Awesome 6 Free'; + font-weight: 900; + font-size: 0.6em; + position: absolute; + top: 4px; + right: 6px; + color: rgba(255, 255, 255, 0.2); + pointer-events: none; +} + +/* Inside "mine" bubble */ +.dm-msg-mine a.dm-attachment-card { + background: rgba(0, 0, 0, 0.18); + border-color: rgba(255, 255, 255, 0.14); +} + +/* ── Attach button (paperclip) ────────────────────────────── */ +.dm-attach-btn { + background: none; + border: none; + color: rgba(255, 255, 255, 0.4); + cursor: pointer; + padding: 0 6px; + font-size: 1em; + line-height: 1; + display: inline-flex; + align-items: center; + transition: color 0.15s; +} + +.dm-attach-btn:hover { + color: var(--accent, #aaa); +} + +.dm-attach-btn:disabled { + opacity: 0.4; + cursor: default; +} + +/* ── Inline attachment preview (image / video / audio) ───── */ +.dm-attachment-preview { + position: relative; + margin: 4px 0 2px; + border-radius: 10px; + overflow: hidden; + max-width: 320px; + background: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.dm-attachment-preview__img { + display: block; + max-width: 100%; + max-height: 360px; + width: auto; + height: auto; + object-fit: contain; + cursor: zoom-in; + border-radius: 10px; +} + +.dm-attachment-preview__video { + display: block; + max-width: 100%; + max-height: 300px; + width: 100%; + border-radius: 10px; + background: #000; +} + +.dm-attachment-preview__audio { + display: block; + width: 100%; + padding: 8px 6px; + accent-color: var(--accent, #aaa); +} + +/* Loading placeholder while decrypting */ +.dm-attachment-preview--loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 48px; + color: rgba(255, 255, 255, 0.3); + font-size: 1.2em; +} + +/* Floating download button in the preview corner */ +.dm-attachment-preview__dl { + position: absolute; + top: 6px; + right: 6px; + background: rgba(0, 0, 0, 0.55); + color: rgba(255, 255, 255, 0.75); + border-radius: 6px; + padding: 5px 7px; + font-size: 0.78em; + text-decoration: none; + line-height: 1; + transition: background 0.15s, color 0.15s; + z-index: 2; +} + +.dm-attachment-preview__dl:hover { + background: var(--accent, #aaa); + color: #000; +} + +/* Eye-slash on card when preview is open */ +.dm-attachment-card[data-previewed="true"] .dm-attachment-card__dl i { + color: var(--accent, #aaa); +} + a.gchat-post-card, span.gchat-post-card { display: inline-flex; diff --git a/public/s/js/messages.js b/public/s/js/messages.js index b1a8c49..c992c9c 100644 --- a/public/s/js/messages.js +++ b/public/s/js/messages.js @@ -510,6 +510,11 @@ if (window.__dmLoaded) { function renderDmContent(plaintext) { if (!plaintext) return ''; + // Strip attachment sentinels before rendering — resolveAttachments() replaces them with cards + const strippedPlaintext = plaintext.replace(/\[attachment:\d+:[A-Za-z0-9+/=]+:[^:]+:\d+\]/g, '').trim(); + if (!strippedPlaintext) return ''; + plaintext = strippedPlaintext; + // Anti-recursion / Performance safeguard for extremely long messages if (plaintext.length > 50000) { console.warn('[DM] Message too long, skipping markdown'); @@ -712,6 +717,9 @@ if (window.__dmLoaded) { // Async: extract post IDs from raw plaintext and inject preview cards into the bubble if (m.plaintext) resolvePostPreviews(div, m.plaintext); + // Async: resolve encrypted attachment sentinels + if (m.plaintext && m.plaintext.includes('[attachment:')) resolveAttachments(div, m.plaintext); + return div; } @@ -723,10 +731,11 @@ if (window.__dmLoaded) { const bubble = msgDiv.querySelector('.dm-bubble'); if (!bubble) return; - // Match bare /12345 and full same-site URLs like https://site.com/12345 + // Match bare /12345 and full same-site URLs like https://site.com/12345. + // Negative lookbehinds prevent /10 inside "10/10" or "//10" from matching. const siteOriginEsc = window.location.origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const itemRx = new RegExp( - `(?:${siteOriginEsc})?\\/(\\d+)(?=[\\s,!?\"'\\)\\]<]|$)`, + `(? DM_ATT_MAX_BYTES) { + showFlashMsg(`File too large (max 50 MB): ${file.name}`, 'error'); + return null; + } + + onProgress('encrypt'); + let iv, encryptedBuffer; + try { + const raw = await file.arrayBuffer(); + ({ iv, encryptedBuffer } = await encryptAttachment(sharedKey, raw)); + } catch (e) { + showFlashMsg('Encryption failed: ' + e.message, 'error'); + return null; + } + + onProgress('upload'); + try { + const fd = new FormData(); + fd.append('iv', iv); + fd.append('original_name', file.name); + fd.append('mime_hint', file.type || 'application/octet-stream'); + fd.append('size_bytes', String(file.size)); + fd.append('file', new Blob([encryptedBuffer], { type: 'application/octet-stream' }), 'enc'); + + const res = await fetch(`/api/dm/attachment/upload/${recipientId}`, { + method: 'POST', + headers: { 'X-CSRF-Token': csrfToken() }, + body: fd + }); + const data = await res.json(); + if (!data.success) { + showFlashMsg('Upload failed: ' + (data.msg || 'Unknown error'), 'error'); + return null; + } + + // Build sentinel: [attachment:ID:base64(filename):mime:size] + const b64name = btoa(unescape(encodeURIComponent(file.name))); + return `[attachment:${data.id}:${b64name}:${file.type || 'application/octet-stream'}:${file.size}]`; + } catch (e) { + showFlashMsg('Upload failed: ' + e.message, 'error'); + return null; + } + } + + // Parse [attachment:ID:b64name:mime:size] sentinels from plaintext + function parseAttachmentSentinels(text) { + const rx = /\[attachment:(\d+):([A-Za-z0-9+/=]+):([^:]+):(\d+)\]/g; + const results = []; + let m; + while ((m = rx.exec(text)) !== null) { + let filename = m[2]; + try { filename = decodeURIComponent(escape(atob(m[2]))); } catch { /* use raw */ } + results.push({ id: m[1], filename, mime: m[3], size: parseInt(m[4], 10), raw: m[0] }); + } + return results; + } + + // Inject attachment previews for any sentinels found in plaintext. + // Media (image/video/audio) decrypts automatically and renders inline. + // Other file types show the clickable download card. + async function resolveAttachments(msgDiv, plaintext) { + const bubble = msgDiv.querySelector('.dm-bubble'); + if (!bubble) return; + const sentinels = parseAttachmentSentinels(plaintext); + if (!sentinels.length) return; + + for (const att of sentinels) { + const isImage = att.mime.startsWith('image/'); + const isVideo = att.mime.startsWith('video/'); + const isAudio = att.mime.startsWith('audio/'); + const isMedia = isImage || isVideo || isAudio; + + // Build a placeholder element to swap into the bubble first + const placeholder = document.createElement('div'); + const rawHtmlEncoded = escHtml(att.raw); + + if (isMedia) { + // Loading state + placeholder.className = 'dm-attachment-preview dm-attachment-preview--loading'; + placeholder.innerHTML = ''; + } else { + // Non-media: just build the download card immediately, no async needed + const card = buildAttachmentCard(att); + if (bubble.innerHTML.includes(rawHtmlEncoded)) { + // Replace sentinel text with card HTML, then re-query the live node to attach events + bubble.innerHTML = bubble.innerHTML.replace(rawHtmlEncoded, ``); + const slot = bubble.querySelector(`[data-att-slot="${att.id}"]`); + if (slot) slot.replaceWith(card); + } else { + bubble.appendChild(card); + } + continue; + } + + // Insert placeholder where the sentinel text is + if (bubble.innerHTML.includes(rawHtmlEncoded)) { + bubble.innerHTML = bubble.innerHTML.replace(rawHtmlEncoded, ``); + const slot = bubble.querySelector(`[data-att-slot="${att.id}"]`); + if (slot) slot.replaceWith(placeholder); + } else { + bubble.appendChild(placeholder); + } + + // Auto-decrypt and render + (async () => { + const blob = await fetchAndDecryptAttachment(att.id, att.mime); + if (!blob || !placeholder.parentNode) return; + + const url = URL.createObjectURL(blob); + const wrap = document.createElement('div'); + wrap.className = 'dm-attachment-preview'; + + let el; + if (isImage) { + el = document.createElement('img'); + el.src = url; + el.className = 'dm-attachment-preview__img'; + el.alt = att.filename; + el.addEventListener('click', () => window.open(url, '_blank')); + } else if (isVideo) { + el = document.createElement('video'); + el.src = url; + el.controls = true; + el.className = 'dm-attachment-preview__video'; + el.preload = 'metadata'; + } else { + el = document.createElement('audio'); + el.src = url; + el.controls = true; + el.className = 'dm-attachment-preview__audio'; + el.preload = 'metadata'; + } + + // Download button overlaid on the preview + const dlBtn = document.createElement('a'); + dlBtn.className = 'dm-attachment-preview__dl'; + dlBtn.href = url; + dlBtn.download = att.filename; + dlBtn.title = `Download ${att.filename}`; + dlBtn.innerHTML = ''; + dlBtn.addEventListener('click', e => e.stopPropagation()); + + wrap.appendChild(el); + wrap.appendChild(dlBtn); + placeholder.replaceWith(wrap); + })(); + } + } + + function fmtBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / 1048576).toFixed(1)} MB`; + } + + function getAttachmentIcon(mime) { + if (mime.startsWith('image/')) return 'fa-image'; + if (mime.startsWith('video/')) return 'fa-film'; + if (mime.startsWith('audio/')) return 'fa-music'; + return 'fa-file'; + } + + function buildAttachmentCard(att) { + const isImage = att.mime.startsWith('image/'); + const isVideo = att.mime.startsWith('video/'); + const isAudio = att.mime.startsWith('audio/'); + const isMedia = isImage || isVideo || isAudio; + + const card = document.createElement('div'); + card.className = 'dm-attachment-card'; + card.dataset.attId = att.id; + card.dataset.attMime = att.mime; + card.dataset.attName = att.filename; + + const icon = getAttachmentIcon(att.mime); + // Action label: media shows "tap to preview", files show download arrow + const actionIcon = isMedia ? 'fa-eye' : 'fa-download'; + const actionTitle = isMedia ? 'Click to preview' : 'Download'; + + card.innerHTML = + ``+ + ``+ + `${escHtml(att.filename)}`+ + `${fmtBytes(att.size)}`+ + ``+ + ``; + + card.addEventListener('click', async (e) => { + e.preventDefault(); + if (card.dataset.busy === 'true') return; + + // If media is already previewed inline, clicking the card again toggles it + const existing = card.nextElementSibling; + if (existing && existing.classList.contains('dm-attachment-preview')) { + existing.remove(); + if (card._blobUrl) { URL.revokeObjectURL(card._blobUrl); card._blobUrl = null; } + card.dataset.previewed = ''; + return; + } + + card.dataset.busy = 'true'; + const dlIcon = card.querySelector('.dm-attachment-card__dl i'); + if (dlIcon) dlIcon.className = 'fa-solid fa-spinner fa-spin'; + + try { + const blob = await fetchAndDecryptAttachment(att.id, att.mime); + if (!blob) return; + + const url = URL.createObjectURL(blob); + card._blobUrl = url; + + if (isMedia) { + // Render inline preview + const wrap = document.createElement('div'); + wrap.className = 'dm-attachment-preview'; + + let el; + if (isImage) { + el = document.createElement('img'); + el.src = url; + el.className = 'dm-attachment-preview__img'; + el.alt = att.filename; + // Click image to open full-size in new tab + el.addEventListener('click', () => window.open(url, '_blank')); + } else if (isVideo) { + el = document.createElement('video'); + el.src = url; + el.controls = true; + el.className = 'dm-attachment-preview__video'; + el.preload = 'metadata'; + } else { // audio + el = document.createElement('audio'); + el.src = url; + el.controls = true; + el.className = 'dm-attachment-preview__audio'; + el.preload = 'metadata'; + } + + // Download button inside preview + const dlBtn = document.createElement('a'); + dlBtn.className = 'dm-attachment-preview__dl'; + dlBtn.href = url; + dlBtn.download = att.filename; + dlBtn.title = 'Download'; + dlBtn.innerHTML = ''; + dlBtn.addEventListener('click', e => e.stopPropagation()); + + wrap.appendChild(el); + wrap.appendChild(dlBtn); + card.insertAdjacentElement('afterend', wrap); + card.dataset.previewed = 'true'; + } else { + // Non-media: just trigger download + const a = document.createElement('a'); + a.href = url; + a.download = att.filename; + document.body.appendChild(a); + a.click(); + setTimeout(() => { URL.revokeObjectURL(url); a.remove(); card._blobUrl = null; }, 5000); + } + } catch (e) { + showFlashMsg('Preview failed: ' + e.message, 'error'); + } finally { + card.dataset.busy = ''; + if (dlIcon) { + const newIcon = isMedia ? 'fa-eye' : 'fa-download'; + dlIcon.className = `fa-solid ${card.dataset.previewed ? 'fa-eye-slash' : newIcon}`; + } + } + }); + + return card; + } + + // Shared fetch+decrypt helper — returns a Blob or null + async function fetchAndDecryptAttachment(id, mime) { + if (!currentOtherPubKey) { + showFlashMsg('Cannot decrypt: no encryption key', 'error'); + return null; + } + try { + const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey); + const res = await fetch(`/api/dm/attachment/${id}`, { + headers: { 'X-CSRF-Token': csrfToken() } + }); + if (!res.ok) { showFlashMsg('Download failed (' + res.status + ')', 'error'); return null; } + + const ivHeader = res.headers.get('X-DM-IV'); + if (!ivHeader) { showFlashMsg('Server did not return IV — please refresh', 'error'); return null; } + + const encBuf = await res.arrayBuffer(); + const plainBuf = await decryptAttachment(sharedKey, ivHeader, new Uint8Array(encBuf)); + return new Blob([plainBuf], { type: mime || 'application/octet-stream' }); + } catch (e) { + showFlashMsg('Decryption failed: ' + e.message, 'error'); + return null; + } + } + let sendInFlight = false; // debounce guard against double-submit function setupDmEmojiPicker() { @@ -851,6 +1186,75 @@ if (window.__dmLoaded) { actions.prepend(trigger); actions.prepend(spoilerBtn); + // ── Attachment button ───────────────────────────────────────────────── + if (window.f0ckSession?.dm_attachments !== false) { + const attachInput = document.createElement('input'); + attachInput.type = 'file'; + attachInput.id = 'dm-attach-input'; + attachInput.accept = 'audio/*,video/*,image/*'; + attachInput.style.display = 'none'; + form.appendChild(attachInput); + + const attachBtn = document.createElement('button'); + attachBtn.type = 'button'; + attachBtn.title = 'Send encrypted attachment'; + attachBtn.className = 'dm-attach-btn'; + attachBtn.innerHTML = ''; + actions.prepend(attachBtn); + + attachBtn.addEventListener('click', (e) => { + e.preventDefault(); + attachInput.value = ''; + attachInput.click(); + }); + + attachInput.addEventListener('change', async () => { + const file = attachInput.files[0]; + if (!file) return; + + if (!currentOtherPubKey) { + showFlashMsg('Cannot attach: recipient has no encryption key', 'error'); + return; + } + if (!hasKey()) { + showFlashMsg('Cannot attach: your encryption key is not loaded', 'error'); + return; + } + + // Show progress state on button + const origHtml = attachBtn.innerHTML; + attachBtn.disabled = true; + attachBtn.innerHTML = ''; + + const onProgress = (stage) => { + if (stage === 'encrypt') attachBtn.title = 'Encrypting…'; + if (stage === 'upload') attachBtn.title = 'Uploading…'; + }; + + try { + const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey); + const sentinel = await uploadDmAttachment(file, sharedKey, currentOtherId, onProgress); + if (sentinel) { + // Append sentinel to the textarea (with a newline separator) + const ta = form.querySelector('#dm-input'); + if (ta) { + ta.value = ta.value ? ta.value + '\n' + sentinel : sentinel; + ta.dispatchEvent(new Event('input')); + ta.focus(); + } + } + } catch (e) { + showFlashMsg('Attachment error: ' + e.message, 'error'); + } finally { + attachBtn.disabled = false; + attachBtn.innerHTML = origHtml; + attachBtn.title = 'Send encrypted attachment'; + attachInput.value = ''; + } + }); + + } // end if (dm_attachments) + // Picker lives inside the form — CSS positions it above via position:absolute + bottom:100% const picker = document.createElement('div'); picker.className = 'emoji-picker dm-emoji-picker'; diff --git a/src/dm_attachment_handler.mjs b/src/dm_attachment_handler.mjs new file mode 100644 index 0000000..8f09172 --- /dev/null +++ b/src/dm_attachment_handler.mjs @@ -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/ (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); + } +} diff --git a/src/inc/config.mjs b/src/inc/config.mjs index 20818af..e1b5393 100644 --- a/src/inc/config.mjs +++ b/src/inc/config.mjs @@ -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'), diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index b1b8c74..2a1e696 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -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'); diff --git a/src/index.mjs b/src/index.mjs index 5f09216..d837b83 100644 --- a/src/index.mjs +++ b/src/index.mjs @@ -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(); }, diff --git a/views/snippets/footer.html b/views/snippets/footer.html index 9355914..d6fc53c 100644 --- a/views/snippets/footer.html +++ b/views/snippets/footer.html @@ -411,7 +411,8 @@ fileupload_comments_multifile: @if(fileupload_comments_multifile) true @else false @endif, fileupload_comments_size: {{ fileupload_comments_size }}, fileupload_comments_max: {{ fileupload_comments_max }}, - fileupload_comments_mode: "{{ fileupload_comments_mode }}" + fileupload_comments_mode: "{{ fileupload_comments_mode }}", + dm_attachments: @if(dm_attachments) true @else false @endif }; window.f0ckDebug = window.f0ckSession.development ? console.log.bind(console) : () => {}; window.f0ckI18n = {