openvibers/openvibe.community Commits

Pastes: Delete can be undone for ten minutes, right on the paste (#416)

OP OpenVibers committed 16 h ago · bea9070b6f · 12 files, +190 −10

* Pastes: Delete can be undone for ten minutes, right on the paste

The owner, 11-12: "a short undo time period on deletion, and undo right on it" (after someone deleted the wrong picture paste and could not get it back). Delete no longer asks "are you sure, there is no undo". The paste greys out where it is, with Deleted · Undo · Your pastes on top and the time left. Undo (POST /api/pastes/:slug/restore, the owner only) brings it back with its comments' thread, its feed card and its address.

The owner's delete hides the paste everywhere at once (deleted_at) and keeps its words and picture until purge_after: migration 0038, expand, a nullable text column. A sweep once a minute (server/pastes/undo.js) clears what passed its time the way every delete used to: words, picture address, metadata and AI notes. It then deletes the picture's Media object unless another live paste shows it. Burn-after-read, staff deletes, fork cleanup and account erasure still clear at once.

Tests: delete, then hidden at once and the picture kept; someone else's undo is refused; undo brings it back; delete again, then the sweep clears it and deletes the picture in Media; a late undo answers 410. The store test follows the owner's delete through the sweep.

* Undo is for a person deleting on the site; a service acting for the owner (resource control) clears at once

Changes

ddiff --git a/migrations/0038_paste_undo.sql b/migrations/0038_paste_undo.sql
nnew file mode 100644
iindex 0000000..0fa1739
---- /dev/null
++++ b/migrations/0038_paste_undo.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- Undo a delete (the owner, 2026-11-12: "a short undo time period on deletion, and undo right on it"). A paste its
++-- owner deletes is hidden at once and keeps its words and picture until purge_after ('YYYY-MM-DD HH:MM:SS' UTC, the
++-- same text form as ov_now()); until then the owner can restore it. A sweep (server/pastes/undo.js) clears what
++-- passed its time and sets purge_after back to NULL. Every other delete (burn after read, staff, an account's
++-- erasure) clears at once and never sets it.
++ALTER TABLE pastes ADD COLUMN IF NOT EXISTS purge_after text COLLATE "C";
++CREATE INDEX IF NOT EXISTS pastes_purge_after ON pastes (purge_after) WHERE purge_after IS NOT NULL;
ddiff --git a/public/css/community.css b/public/css/community.css
iindex 82412da..9d58043 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -610,3 +610,11 @@ button.rx:hover { border-color: var(--accent); }
/* One column a reader can follow down, at a reading width (two columns left holes beside the short cards). */
.feed-list, .feed-find, .feed-empty { max-width: 760px; }
@media (prefers-reduced-motion: reduce) { .feed-media img { transition: none; } .feed-media:hover img { transform: none; } }
++
++/* Undo a delete, right on the paste (2026-11-12): the bar on top, the paste greyed out under it. */
++.paste-undo { position: sticky; top: 8px; z-index: 5; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; margin: 0 0 14px; padding: 10px 14px; border-radius: 14px;
++ background: var(--bg-elevated, var(--bg-secondary, #151c2b)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 10px 28px rgba(0,0,0,.28); font-weight: 600; }
++.paste-undo small { flex-basis: 100%; color: var(--text-muted, #93a0b4); font-weight: 500; }
++@media (min-width: 560px) { .paste-undo small { flex-basis: auto; margin-left: auto; } }
++.paste-is-deleted main > :not(.paste-undo) { opacity: .35; filter: grayscale(.7); pointer-events: none; transition: opacity .25s ease; }
++html[data-ov-motion=off] .paste-is-deleted main > :not(.paste-undo) { transition: none; }
ddiff --git a/public/js/community.js b/public/js/community.js
iindex 1124568..ae726fa 100644
---- a/public/js/community.js
++++ b/public/js/community.js
@@@ -42,6 +42,38 @@
.then(function (r) { return r.text().then(function (t) { var d = null; try { d = t ? JSON.parse(t) : null; } catch (e) { d = { error: t.slice(0, 200) }; } if (!r.ok) { var err = new Error((d && d.error) || ('Request failed (' + r.status + ')')); err.status = r.status; err.data = d; throw err; } return d; }); });
}
++ // ── Undo a delete, right on the paste ───────────────────────
++ function showUndo(slug, undo) {
++ var main = document.querySelector('main') || document.body;
++ document.body.classList.add('paste-is-deleted');
++ var bar = document.createElement('div');
++ bar.className = 'paste-undo';
++ bar.setAttribute('role', 'status');
++ var words = document.createElement('span');
++ var left = document.createElement('small');
++ var btn = document.createElement('button');
++ btn.type = 'button'; btn.className = 'btn btn-sm btn-primary'; btn.textContent = 'Undo';
++ var away = document.createElement('a');
++ away.href = '/my'; away.className = 'btn btn-sm'; away.textContent = 'Your pastes';
++ words.textContent = 'Deleted. ';
++ bar.append(words, btn, away, left);
++ main.insertBefore(bar, main.firstChild);
++ btn.focus({ preventScroll: true });
++ var until = Date.parse(undo.until) || (Date.now() + 600000);
++ var tick = function () {
++ var s = Math.max(0, Math.round((until - Date.now()) / 1000));
++ left.textContent = s > 0 ? 'Undo for ' + (s >= 60 ? Math.ceil(s / 60) + ' min' : s + ' s') : 'It is gone for good.';
++ if (s <= 0) { clearInterval(timer); btn.disabled = true; }
++ };
++ var timer = setInterval(tick, 1000); tick();
++ btn.addEventListener('click', function () {
++ btn.disabled = true; btn.textContent = 'Bringing it back…';
++ api(undo.restore || ('/api/pastes/' + encodeURIComponent(slug) + '/restore'), { method: 'POST', json: {} })
++ .then(function (out) { clearInterval(timer); location.replace((out && out.url) || location.href); })
++ .catch(function (err) { btn.textContent = 'Undo'; btn.disabled = false; left.textContent = err.message; });
++ });
++ }
++
// ── Paste page ──────────────────────────────────────────────
document.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('[data-copy-content],[data-copy-link],[data-delete]') : null;
@@@ -52,10 +84,12 @@
} else if (t.hasAttribute('data-copy-link')) {
copyText(t.getAttribute('data-copy-link')).then(function () { flash(t, 'Link copied'); }).catch(function () {});
} else if (t.hasAttribute('data-delete')) {
-- if (!confirm('Delete this paste? There is no undo.')) return;
++ // 2026-11-12 (the owner: "a short undo time period on deletion, and undo right on it"): no "are you sure";
++ // the paste greys out where it is, with Undo on it for the ten minutes the server keeps it.
++ var slug = t.getAttribute('data-delete');
t.disabled = true;
-- api('/api/pastes/' + encodeURIComponent(t.getAttribute('data-delete')), { method: 'DELETE' })
-- .then(function () { location.href = '/my'; })
++ api('/api/pastes/' + encodeURIComponent(slug), { method: 'DELETE' })
++ .then(function (out) { if (out && out.undo) showUndo(slug, out.undo); else location.href = '/my'; })
.catch(function (err) { t.disabled = false; alert(err.message); });
}
});
ddiff --git a/server/app.js b/server/app.js
iindex 8470825..bb45219 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -298,6 +298,8 @@ async function createApp(opts = {}) {
app.locals.pasteMedia = pasteMedia;
app.locals.pasteReview = review;
if (opts.startPasteHashWorker !== false) app.locals.pasteHashTimer = require('./pastes/content-hash').start({ db, media: mediaObjects, screenshots: pasteMedia });
++ // What an owner deleted is cleared once its ten minutes of Undo pass (pastes/undo.js).
++ if (opts.startPasteUndoSweep !== false) app.locals.pasteUndoTimer = require('./pastes/undo').start({ db, media: pasteMedia });
}
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
const anonWriteLimiter = rateLimit({
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 46fbacc..8112ed9 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -170,8 +170,13 @@ route('put', '/api/pastes/{slug}', {
});
route('delete', '/api/pastes/{slug}', {
operationId: 'deletePaste', summary: 'Delete a paste (soft: its address answers 410)', tags: ['pastes'], security: PERSON, parameters: [SLUG('slug')],
-- description: 'community.paste.write.',
-- responses: { 200: json('What was deleted'), ...errors() },
++ description: 'community.paste.write. The owner\'s delete answers undo: { until, restore } and can be undone for ten minutes; the paste is hidden at once and cleared after.',
++ responses: { 200: json('What was deleted, and until when it can be undone'), ...errors() },
++});
++route('post', '/api/pastes/{slug}/restore', {
++ operationId: 'restorePaste', summary: 'Undo the owner\'s delete, within ten minutes', tags: ['pastes'], security: PERSON, parameters: [SLUG('slug')],
++ description: 'community.paste.write. 410 once the ten minutes have passed (the paste is cleared).',
++ responses: { 200: json('The paste, back'), ...errors() },
});
route('post', '/api/pastes/{slug}/censor', {
operationId: 'censorPaste', summary: 'Replace a paste\'s content with a censorship notice (staff)', tags: ['pastes-admin'], security: STAFF, parameters: [SLUG('slug')],
ddiff --git a/server/pastes/api.js b/server/pastes/api.js
iindex a6458a3..169c9b7 100644
---- a/server/pastes/api.js
++++ b/server/pastes/api.js
@@@ -140,6 +140,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
// ── one paste ────────────────────────────────────────────
router.get('/:slug', anyCap, run(async (req) => await service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));
router.put('/:slug', guard('community.paste.write'), limits('community.paste.edit', { minute: 30, hour: 300 }), json, jsonErrors, run(async (req) => await service.update(req.viewer, slug(req), req.body || {})));
++ router.post('/:slug/restore', guard('community.paste.write'), limits('community.paste.delete', { minute: 60, hour: 600 }), run(async (req) => await service.restore(req.viewer, slug(req))));
router.delete('/:slug', guard('community.paste.write'), limits('community.paste.delete', { minute: 60, hour: 600 }), run(async (req) => await service.remove(req.viewer, slug(req))));
router.post('/:slug/censor', staffOnly, limits('community.paste.censor', { minute: 20, hour: 200 }), withFile, run(async (req) => await service.censor(req.viewer, slug(req), req.file)));
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 6c3ba1c..d870ca1 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -87,6 +87,8 @@ function sinceParam(value) {
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
}
++const undo = require('./undo');
++
function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null, shares = null, review = null } = {}) {
const L = { ...DEFAULT_LIMITS, ...limits };
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
@@@ -784,11 +786,34 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { paste: await shapeOne(row, v) };
},
-- /** DELETE /api/pastes/:slug — owner (or staff) only. */
-- async remove(v, slug) {
++ /** POST /api/pastes/:slug/restore — the owner's Undo, inside the ten minutes after their delete. */
++ async restore(v, slug) {
++ needIdentity(v);
++ const row = await store.getUndoable(db, slug, undo.stamp(Date.now()));
++ if (!row) fail(410, 'This paste can no longer be brought back');
++ if (!isOwner(v, row)) fail(403, 'Not authorized for this paste');
++ const back = await store.unhide(db, row.id);
++ if (!back) fail(410, 'This paste can no longer be brought back');
++ try { await require('../events').pasteCreated(back); } catch (err) { console.warn('[Pastes] restore event:', err.message); }
++ await tell((pl) => pl.pasteCreated(back));
++ pingIndexNow(back);
++ return { success: true, paste: await shapeOne(back, v), url: `/p/${canonicalSlug(back)}` };
++ },
++
++ /** DELETE /api/pastes/:slug — owner (or staff) only. The owner's can be undone for ten minutes (./undo.js). */
++ async remove(v, slug, { undo: undoable = true } = {}) {
needIdentity(v);
const p = await visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
++ // Undo is for a person deleting on the site; a service acting for the owner (Network's resource control)
++ // has no page to undo from, so its delete clears at once as before.
++ if (undoable && v.kind !== 'service' && isOwner(v, p) && !truthy(p.burn_after_read)) {
++ const until = Date.now() + undo.UNDO_MS;
++ await store.hideForUndo(db, p.id, undo.stamp(until));
++ await tell((pl) => pl.pasteGone(p.slug));
++ pingIndexNow(p);
++ return { success: true, undo: { until: new Date(until).toISOString(), restore: `/api/pastes/${encodeURIComponent(p.slug)}/restore` } };
++ }
await db.tx(async () => {
await store.softDelete(db, p.id);
if (!isOwner(v, p)) await events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 8572e2e..1f8c071 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -234,6 +234,35 @@ async function listVersions(db, pasteId) {
return await db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);
}
++/**
++ * The owner's delete (2026-11-12): hidden everywhere at once, its words and picture kept until purgeAfter so the owner
++ * can undo it (./undo.js clears it after). The thread under it is hidden and community.paste.deleted goes out now.
++ */
++async function hideForUndo(db, id, purgeAfter) {
++ return await db.tx(async () => {
++ const cur = await getById(db, id);
++ const n = (await db.prepare(`UPDATE pastes SET deleted_at = ov_now(), purge_after = ?, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL`).run(purgeAfter, id)).changes;
++ if (n && cur) await require('../comments/forum-links').hidePasteThread(db, cur.slug, false);
++ if (n && cur) await require('../events').pasteDeleted(cur.slug); // community.paste.deleted
++ return n;
++ });
++}
++
++/** A paste deleted by its owner, still inside its undo window: the row (deleted_at set), or null. */
++async function getUndoable(db, slug, now) {
++ return await db.prepare(`SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NOT NULL AND purge_after IS NOT NULL AND purge_after > ?`).get(String(slug), now) || null;
++}
++
++/** Undo: the paste is back as it was, with its thread. */
++async function unhide(db, id) {
++ return await db.tx(async () => {
++ const n = (await db.prepare(`UPDATE pastes SET deleted_at = NULL, purge_after = NULL, updated_at = ov_now() WHERE id = ? AND deleted_at IS NOT NULL AND purge_after IS NOT NULL`).run(id)).changes;
++ const back = n ? await getById(db, id) : null;
++ if (back) await require('../comments/forum-links').hidePasteThread(db, back.slug, true);
++ return back;
++ });
++}
++
/** Soft delete: the row stays (its slug stays reserved), its content and image link do not. */
async function softDelete(db, id) {
return await db.tx(async () => {
@@@ -485,7 +514,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {
module.exports = { ensurePublicId, getByPublicId, canonicalForRef, canonicalSlug, publicIdOfRef,
generateSlug, slugTaken, retireSlug, sanitizeTitle, detectLanguage,
-- getBySlug, getById, insertPaste, findOwnedHash, updatePaste, listVersions, softDelete, listPastes,
++ getBySlug, getById, insertPaste, findOwnedHash, updatePaste, listVersions, softDelete, hideForUndo, getUndoable, unhide, listPastes,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,
getProjections, upsertProjection, subjectsByUsername,
ddiff --git a/server/pastes/undo.js b/server/pastes/undo.js
nnew file mode 100644
iindex 0000000..3e92584
---- /dev/null
++++ b/server/pastes/undo.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Undo a delete (the owner, 2026-11-12: "a short undo time period on deletion, and undo right on it").
++ *
++ * The owner's delete hides a paste everywhere at once (deleted_at) and keeps its words and picture for UNDO_MS
++ * (purge_after); POST /api/pastes/:slug/restore brings it back until then. This sweep, once a minute, clears what
++ * passed its time the way every other delete clears at once: the words, the picture's address, the metadata and the
++ * AI notes go, and the picture's Media object is deleted unless another live paste still shows it.
++ */
++const UNDO_MS = 10 * 60 * 1000;
++
++/** The text form of a time that ov_now() writes ('YYYY-MM-DD HH:MM:SS', UTC), so the two compare as text. */
++const stamp = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
++
++async function purgeDue(db, { media = null, now = Date.now(), limit = 50, log = console } = {}) {
++ const rows = await db.prepare(`SELECT id, slug, media_ref FROM pastes WHERE purge_after IS NOT NULL AND purge_after <= ?
++ AND deleted_at IS NOT NULL ORDER BY purge_after LIMIT ?`).all(stamp(now), limit);
++ let purged = 0;
++ for (const r of rows) {
++ const n = (await db.prepare(`UPDATE pastes SET content = NULL, screenshot_url = NULL, metadata = NULL, ai_summary = NULL,
++ ai_tags = NULL, purge_after = NULL, updated_at = ov_now() WHERE id = ? AND deleted_at IS NOT NULL AND purge_after IS NOT NULL`).run(r.id)).changes;
++ if (!n) continue;
++ purged++;
++ if (r.media_ref && media && typeof media.remove === 'function') {
++ try {
++ const other = await db.prepare('SELECT 1 FROM pastes WHERE media_ref = ? AND id <> ? AND deleted_at IS NULL LIMIT 1').get(r.media_ref, r.id);
++ if (!other) await media.remove(r.media_ref);
++ } catch (err) { log.warn('[Pastes] picture cleanup after undo window failed:', err.message); }
++ }
++ }
++ return { purged };
++}
++
++function start({ db, media, intervalMs = 60_000, log = console } = {}) {
++ let busy = false;
++ const run = async () => {
++ if (busy) return;
++ busy = true;
++ try { await purgeDue(db, { media, log }); } catch (err) { log.warn('[Pastes] undo sweep failed:', err.message); } finally { busy = false; }
++ };
++ const timer = setInterval(run, Math.max(15_000, Number(intervalMs) || 60_000));
++ if (timer.unref) timer.unref();
++ return timer;
++}
++
++module.exports = { UNDO_MS, stamp, purgeDue, start };
ddiff --git a/server/registry/resource-control.js b/server/registry/resource-control.js
iindex b2e2f09..4bb0d7e 100644
---- a/server/registry/resource-control.js
++++ b/server/registry/resource-control.js
@@@ -46,7 +46,7 @@ function router({ db, viewers, pastes, now = () => Date.now() }) {
}
if (request.dry_run) return result(request, 'done', { result: { dry_run: true, would: 'delete the paste' } });
// The owner's own deletion path, as the owner (the service acts for them; their subject is checked above).
-- await pastes.remove({ kind: 'service', service: caller, subject: request.owner.id, claims: {}, staff: false }, p.slug);
++ await pastes.remove({ kind: 'service', service: caller, subject: request.owner.id, claims: {}, staff: false }, p.slug, { undo: false });
return result(request, 'done', { result: { deleted: true } });
}
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex 2aa65b2..3459780 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -326,7 +326,26 @@ const { boot, check, done } = require('./helpers/app');
const one = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(first.json().slug);
const gone = await call(`/api/pastes/${first.json().slug}`, { method: 'DELETE', cookie: alexJwt });
assert.strictEqual(gone.status, 200, gone.text);
++ // 2026-11-12: the owner's delete can be undone for ten minutes; the picture leaves Media once they pass.
++ const undo = gone.json().undo;
++ assert.ok(undo && Date.parse(undo.until) - Date.now() > 9 * 60 * 1000, `an undo window of ten minutes: ${gone.text}`);
++ assert.ok([404, 410].includes((await call(`/p/${first.json().slug}`, { cookie: alexJwt })).status), 'hidden at once');
++ assert.ok(!t.media.deletes.includes(one.media_ref), 'the picture is kept while it can be undone');
++ const sam410 = await call(undo.restore, { method: 'POST', cookie: samJwt, json: {} });
++ assert.strictEqual(sam410.status, 403, 'only its owner can undo it');
++ const back = await call(undo.restore, { method: 'POST', cookie: alexJwt, json: {} });
++ assert.strictEqual(back.status, 200, back.text);
++ assert.strictEqual((await call(back.json().url, { cookie: alexJwt })).status, 200, 'Undo brings it back, at its own address');
++ const again1 = await call(`/api/pastes/${first.json().slug}`, { method: 'DELETE', cookie: alexJwt });
++ assert.strictEqual(again1.status, 200, again1.text);
++ const undo2 = require('../server/pastes/undo');
++ const swept = await undo2.purgeDue(t.db, { media: t.app.locals.pasteMedia, now: Date.now() + 11 * 60 * 1000 });
++ assert.ok(swept.purged >= 1, 'the sweep clears it once the ten minutes pass');
assert.ok(t.media.deletes.includes(one.media_ref), 'the deleted paste\'s picture is deleted in Media');
++ const row = await t.db.prepare('SELECT content, screenshot_url, metadata, purge_after FROM pastes WHERE slug = ?').get(first.json().slug);
++ assert.deepStrictEqual([row.screenshot_url, row.metadata, row.purge_after], [null, null, null], 'its picture address and metadata are gone');
++ const late = await call(undo.restore, { method: 'POST', cookie: alexJwt, json: {} });
++ assert.strictEqual(late.status, 410, 'too late to undo');
const again = await post({ title: 'Nvidia thing' });
assert.strictEqual(again.status, 201, `posted again: ${again.status} ${again.text}`);
const two = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(again.json().slug);
ddiff --git a/test/store.test.js b/test/store.test.js
iindex 8e175b8..2d6a69e 100644
---- a/test/store.test.js
++++ b/test/store.test.js
@@@ -245,7 +245,10 @@ async function rejects(p, status, message) {
await check('delete: owner or staff; soft delete keeps the slug reserved; gone from reads and lists', async () => {
await rejects(Promise.resolve().then(async () => await svc.remove(bob, unl)), 403);
-- assert.deepStrictEqual(await svc.remove(alice, unl), { success: true });
++ // The owner's delete can be undone for ten minutes (2026-11-12): hidden at once, cleared by the sweep after.
++ const own = await svc.remove(alice, unl);
++ assert.ok(own.success && own.undo && /\/restore$/.test(own.undo.restore), JSON.stringify(own));
++ await require('../server/pastes/undo').purgeDue(db, { now: Date.now() + 11 * 60 * 1000 });
await rejects(svc.get(alice, unl), 404);
assert.deepStrictEqual(await svc.remove(mod, priv), { success: true }, 'staff');
const row = await db.prepare('SELECT slug, content, deleted_at FROM pastes WHERE slug = ?').get(unl);