diff --git a/config_example.json b/config_example.json index 6405e3a..aee960a 100644 --- a/config_example.json +++ b/config_example.json @@ -62,6 +62,7 @@ "enable_danmaku": true, "private_messages": true, "dm_attachments": true, + "dm_attachment_expiry_days": 90, "halls_enabled": true, "userhalls_enabled": true, "enable_userhall_image_upload": true, diff --git a/migrations/f0ckm_schema.sql b/migrations/f0ckm_schema.sql index 311feec..b4f2894 100644 --- a/migrations/f0ckm_schema.sql +++ b/migrations/f0ckm_schema.sql @@ -2770,11 +2770,15 @@ CREATE TABLE IF NOT EXISTS public.dm_attachments ( 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') + expires_at timestamp with time zone DEFAULT (now() + interval '90 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); +-- DM message edit/delete support (Migration 006) +ALTER TABLE private_messages + ADD COLUMN IF NOT EXISTS edited_at timestamp with time zone DEFAULT NULL; + \unrestrict RMNKNzVQLV2ZcwmM3bmhglTot5nRoju9FmRyi3eUMfNy6iJUBfHRIgXnbrpJikG diff --git a/public/s/css/f0ckm.css b/public/s/css/f0ckm.css index eecbe6d..6dfbb05 100644 --- a/public/s/css/f0ckm.css +++ b/public/s/css/f0ckm.css @@ -11030,6 +11030,109 @@ body.layout-modern .tag-controls { font-size: 0.88em; } +/* ── Message edit / delete actions ─────────────────────── */ +.dm-msg { + position: relative; +} + +.dm-msg-actions { + display: flex; + gap: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + margin-top: 1px; +} + +.dm-msg:hover .dm-msg-actions, +.dm-msg:focus-within .dm-msg-actions { + opacity: 1; + pointer-events: auto; +} + +.dm-msg-action-btn { + background: none; + border: none; + color: #555; + cursor: pointer; + font-size: 0.78em; + padding: 2px 5px; + border-radius: 4px; + line-height: 1; + transition: color 0.12s, background 0.12s; +} + +.dm-msg-action-btn:hover { + color: var(--fg, #ddd); + background: rgba(255,255,255,0.07); +} + +.dm-msg-action-btn[data-action="delete"]:hover { + color: #e55; +} + +/* Inline edit textarea */ +.dm-edit-wrap { + width: 100%; +} + +.dm-edit-textarea { + width: 100%; + min-height: 60px; + background: rgba(255,255,255,0.05); + border: 1px solid rgba(255,255,255,0.15); + border-radius: 6px; + color: var(--fg, #ddd); + font: inherit; + font-size: 0.92em; + padding: 6px 8px; + resize: vertical; + box-sizing: border-box; +} + +.dm-edit-actions { + display: flex; + gap: 6px; + margin-top: 5px; +} + +.dm-edit-save, +.dm-edit-cancel { + font-size: 0.8em; + padding: 3px 10px; + border-radius: 5px; + border: none; + cursor: pointer; +} + +.dm-edit-save { + background: var(--badge-bg, #4a90d9); + color: #fff; +} + +.dm-edit-cancel { + background: rgba(255,255,255,0.08); + color: var(--fg, #ddd); +} + +/* (edited) badge */ +.dm-edited { + color: #555; + font-size: 0.85em; + margin-left: 3px; +} + +/* Delete fade-out */ +@keyframes dm-msg-fade-out { + to { opacity: 0; transform: scaleY(0); max-height: 0; margin: 0; padding: 0; overflow: hidden; } +} + +.dm-msg-deleting { + animation: dm-msg-fade-out 0.25s ease forwards; + transform-origin: top; + overflow: hidden; +} + /* ── Send form ───────────────────────────────────────────── */ .dm-send-form { gap: 8px; diff --git a/public/s/js/messages.js b/public/s/js/messages.js index 08a32f5..98b5916 100644 --- a/public/s/js/messages.js +++ b/public/s/js/messages.js @@ -710,9 +710,29 @@ if (window.__dmLoaded) { const hasEmbed = content.includes('yt-embed-wrap'); div.className = `dm-msg ${isMine ? 'dm-msg-mine' : 'dm-msg-theirs'}${hasEmbed ? ' dm-has-embed' : ''}`; div.dataset.msgId = m.id; + if (m.plaintext) div.dataset.plaintext = m.plaintext; - const time = timeAgo(m.created_at); - div.innerHTML = `
${content}
${escHtml(time)}`; + const time = timeAgo(m.created_at); + const editedBit = m.edited_at ? ' (edited)' : ''; + div.innerHTML = + `
${content}
` + + `${escHtml(time)}${editedBit}`; + + if (isMine) { + const actions = document.createElement('div'); + actions.className = 'dm-msg-actions'; + actions.innerHTML = + `` + + ``; + actions.addEventListener('click', async (e) => { + const btn = e.target.closest('[data-action]'); + if (!btn) return; + const action = btn.dataset.action; + if (action === 'edit') editDmMessage(m, div); + if (action === 'delete') deleteDmMessage(m, div); + }); + div.appendChild(actions); + } // Async: extract post IDs from raw plaintext and inject preview cards into the bubble if (m.plaintext) resolvePostPreviews(div, m.plaintext); @@ -723,6 +743,127 @@ if (window.__dmLoaded) { return div; } + async function editDmMessage(m, div) { + if (div.dataset.editing === 'true') return; + if (!m.plaintext) { showFlashMsg('Cannot edit — message could not be decrypted', 'error'); return; } + div.dataset.editing = 'true'; + + const bubble = div.querySelector('.dm-bubble'); + const origHtml = bubble.innerHTML; + + const wrap = document.createElement('div'); + wrap.className = 'dm-edit-wrap'; + + const ta = document.createElement('textarea'); + ta.className = 'dm-edit-textarea'; + ta.value = m.plaintext; + + const actions = document.createElement('div'); + actions.className = 'dm-edit-actions'; + + const saveBtn = document.createElement('button'); + saveBtn.className = 'dm-edit-save'; + saveBtn.textContent = 'Save'; + + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'dm-edit-cancel'; + cancelBtn.textContent = 'Cancel'; + + actions.append(saveBtn, cancelBtn); + wrap.append(ta, actions); + bubble.innerHTML = ''; + bubble.appendChild(wrap); + ta.focus(); + + cancelBtn.addEventListener('click', () => { + bubble.innerHTML = origHtml; + div.dataset.editing = ''; + }); + + saveBtn.addEventListener('click', async () => { + const newText = ta.value.trim(); + if (!newText || newText === m.plaintext) { + bubble.innerHTML = origHtml; + div.dataset.editing = ''; + return; + } + if (!currentOtherPubKey) { showFlashMsg('Cannot encrypt: no key', 'error'); return; } + + saveBtn.disabled = true; + saveBtn.textContent = '…'; + try { + const sharedKey = await deriveSharedKey(_privateKey, currentOtherPubKey); + const { iv: newIv, ciphertext: newCt } = await encryptMessage(sharedKey, newText); + + const res = await fetch(`/api/dm/message/${m.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken() }, + body: new URLSearchParams({ ciphertext: newCt, iv: newIv }) + }); + const data = await res.json(); + if (!data.success) { showFlashMsg('Edit failed: ' + (data.msg || res.status), 'error'); return; } + + // Update in-memory plaintext and re-render bubble + m.plaintext = newText; + m.edited_at = data.edited_at || new Date().toISOString(); + div.dataset.plaintext = newText; + + const newContent = renderDmContent(newText).trimEnd(); + const editedBit = '(edited)'; + bubble.innerHTML = newContent; + + // Update the time span + const timeEl = div.querySelector('.dm-msg-time'); + if (timeEl) timeEl.innerHTML = escHtml(timeAgo(m.created_at)) + ' ' + editedBit; + + if (m.plaintext) resolvePostPreviews(div, m.plaintext); + if (m.plaintext && m.plaintext.includes('[attachment:')) resolveAttachments(div, m.plaintext); + } catch (e) { + showFlashMsg('Edit error: ' + e.message, 'error'); + } finally { + div.dataset.editing = ''; + saveBtn.disabled = false; + } + }); + } + + async function deleteDmMessage(m, div) { + if (div.dataset.deleting === 'true') return; + div.dataset.deleting = 'true'; + + // Extract attachment IDs from plaintext sentinels + const attachmentIds = []; + if (m.plaintext) { + for (const att of parseAttachmentSentinels(m.plaintext)) { + const id = parseInt(att.id, 10); + if (id) attachmentIds.push(id); + } + } + + try { + const body = new URLSearchParams(); + attachmentIds.forEach(id => body.append('attachment_ids[]', id)); + + const res = await fetch(`/api/dm/message/${m.id}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrfToken() }, + body + }); + const data = await res.json(); + if (!data.success) { + showFlashMsg('Delete failed: ' + (data.msg || res.status), 'error'); + div.dataset.deleting = ''; + return; + } + + div.classList.add('dm-msg-deleting'); + div.addEventListener('animationend', () => div.remove(), { once: true }); + } catch (e) { + showFlashMsg('Delete error: ' + e.message, 'error'); + div.dataset.deleting = ''; + } + } + // ── Post link preview cards ─────────────────────────────────────────────── // Extracts item IDs from the raw plaintext (immune to rendering pipeline // variations: marked / commentSystem / plain-text fallback), then appends diff --git a/src/dm_attachment_handler.mjs b/src/dm_attachment_handler.mjs index 80e37dd..33b951f 100644 --- a/src/dm_attachment_handler.mjs +++ b/src/dm_attachment_handler.mjs @@ -16,7 +16,7 @@ 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'; +import { getDmAttachments, getDmAttachmentExpiryDays } from './inc/settings.mjs'; // ─── Config ────────────────────────────────────────────────────────────────── @@ -165,6 +165,7 @@ export async function handleDmAttachmentUpload(req, res, recipientId) { 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, @@ -173,7 +174,8 @@ export async function handleDmAttachmentUpload(req, res, recipientId) { file_path: '', original_name: originalName, mime_hint: mimeHint, - size_bytes: sizeBytes + size_bytes: sizeBytes, + expires_at: expiresAt })} RETURNING id `; diff --git a/src/inc/routes/messages.mjs b/src/inc/routes/messages.mjs index 2d9c238..f9bc199 100644 --- a/src/inc/routes/messages.mjs +++ b/src/inc/routes/messages.mjs @@ -209,7 +209,8 @@ export default (router, tpl) => { pm.ciphertext, pm.iv, pm.is_read, - pm.created_at + pm.created_at, + pm.edited_at FROM private_messages pm WHERE ( (pm.sender_id = ${req.session.id} AND pm.recipient_id = ${otherId}) @@ -312,6 +313,81 @@ export default (router, tpl) => { } }); + // Edit own message (re-encrypt in browser, send new ciphertext + iv) + router.patch(/\/api\/dm\/message\/(?\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\/(?\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\/(?\d+)\/delete/, async (req, res) => { if (!getPrivateMessages()) return json(res, { success: false, msg: 'Not found' }, 404); diff --git a/src/inc/settings.mjs b/src/inc/settings.mjs index 2a1e696..93c219c 100644 --- a/src/inc/settings.mjs +++ b/src/inc/settings.mjs @@ -63,6 +63,11 @@ export const setPrivateMessages = (val) => private_messages = !!val; export const getDmAttachments = () => dm_attachments; export const setDmAttachments = (val) => dm_attachments = !!val; +export const getDmAttachmentExpiryDays = () => { + const v = parseInt(cfg.websrv.dm_attachment_expiry_days); + return (Number.isFinite(v) && v > 0) ? v : 90; +}; + export const getDefaultLayout = () => default_layout; export const setDefaultLayout = (val) => default_layout = (val === 'legacy' ? 'legacy' : 'modern');