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 = `
${escHtml(time)}`;
+ const time = timeAgo(m.created_at);
+ const editedBit = m.edited_at ? ' (edited)' : '';
+ div.innerHTML =
+ `` +
+ `${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');