openvibers/openvibe.community Commits
Pastes: Delete can be undone for ten minutes, right on the paste (#416)
Changes
nnew file mode 100644@@@ -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;@@@ -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; }@@@ -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); });}});@@@ -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({@@@ -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')],@@@ -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)));@@@ -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 = {}, limreturn { 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 });@@@ -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,nnew file mode 100644@@@ -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 };@@@ -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 } });}@@@ -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);@@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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;
@@ -610,3 +610,11 @@ button.rx:hover { border-color: var(--accent); }
@@ -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). */
/* 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; }
.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; } }
@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; }
@@ -42,6 +42,38 @@
@@ -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; }); });
.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 ──────────────────────────────────────────────
// ── Paste page ──────────────────────────────────────────────
document.addEventListener('click', function (e) {
document.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('[data-copy-content],[data-copy-link],[data-delete]') : null;
var t = e.target.closest ? e.target.closest('[data-copy-content],[data-copy-link],[data-delete]') : null;
@@ -52,10 +84,12 @@
@@ -52,10 +84,12 @@
} else if (t.hasAttribute('data-copy-link')) {
} else if (t.hasAttribute('data-copy-link')) {
copyText(t.getAttribute('data-copy-link')).then(function () { flash(t, 'Link copied'); }).catch(function () {});
copyText(t.getAttribute('data-copy-link')).then(function () { flash(t, 'Link copied'); }).catch(function () {});
} else if (t.hasAttribute('data-delete')) {
} 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;
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); });
.catch(function (err) { t.disabled = false; alert(err.message); });
}
}
});
});
@@ -298,6 +298,8 @@ async function createApp(opts = {}) {
@@ -298,6 +298,8 @@ async function createApp(opts = {}) {
app.locals.pasteMedia = pasteMedia;
app.locals.pasteMedia = pasteMedia;
app.locals.pasteReview = review;
app.locals.pasteReview = review;
if (opts.startPasteHashWorker !== false) app.locals.pasteHashTimer = require('./pastes/content-hash').start({ db, media: mediaObjects, screenshots: pasteMedia });
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).
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
const anonWriteLimiter = rateLimit({
const anonWriteLimiter = rateLimit({
@@ -170,8 +170,13 @@ route('put', '/api/pastes/{slug}', {
@@ -170,8 +170,13 @@ route('put', '/api/pastes/{slug}', {
});
});
route('delete', '/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')],
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', {
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')],
operationId: 'censorPaste', summary: 'Replace a paste\'s content with a censorship notice (staff)', tags: ['pastes-admin'], security: STAFF, parameters: [SLUG('slug')],
@@ -140,6 +140,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
@@ -140,6 +140,7 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
// ── one paste ────────────────────────────────────────────
// ── 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.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.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.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)));
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)));
@@ -87,6 +87,8 @@ function sinceParam(value) {
@@ -87,6 +87,8 @@ function sinceParam(value) {
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
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 } = {}) {
function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null, shares = null, review = null } = {}) {
const L = { ...DEFAULT_LIMITS, ...limits };
const L = { ...DEFAULT_LIMITS, ...limits };
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
// 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
@@ -784,11 +786,34 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { paste: await shapeOne(row, v) };
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);
needIdentity(v);
const p = await visible(v, slug);
const p = await visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
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 db.tx(async () => {
await store.softDelete(db, p.id);
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 });
if (!isOwner(v, p)) await events.moderationAction(v, 'paste.deleted', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null });
@@ -234,6 +234,35 @@ async function listVersions(db, pasteId) {
@@ -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);
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. */
/** Soft delete: the row stays (its slug stays reserved), its content and image link do not. */
async function softDelete(db, id) {
async function softDelete(db, id) {
return await db.tx(async () => {
return await db.tx(async () => {
@@ -485,7 +514,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {
@@ -485,7 +514,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {
module.exports = { ensurePublicId, getByPublicId, canonicalForRef, canonicalSlug, publicIdOfRef,
module.exports = { ensurePublicId, getByPublicId, canonicalForRef, canonicalSlug, publicIdOfRef,
generateSlug, slugTaken, retireSlug, sanitizeTitle, detectLanguage,
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,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,
setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,
getProjections, upsertProjection, subjectsByUsername,
getProjections, upsertProjection, subjectsByUsername,
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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 };
@@ -46,7 +46,7 @@ function router({ db, viewers, pastes, now = () => Date.now() }) {
@@ -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' } });
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).
// 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 } });
return result(request, 'done', { result: { deleted: true } });
}
}
@@ -326,7 +326,26 @@ const { boot, check, done } = require('./helpers/app');
@@ -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 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 });
const gone = await call(`/api/pastes/${first.json().slug}`, { method: 'DELETE', cookie: alexJwt });
assert.strictEqual(gone.status, 200, gone.text);
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');
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' });
const again = await post({ title: 'Nvidia thing' });
assert.strictEqual(again.status, 201, `posted again: ${again.status} ${again.text}`);
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);
const two = await t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(again.json().slug);
@@ -245,7 +245,10 @@ async function rejects(p, status, message) {
@@ -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 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);
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);
await rejects(svc.get(alice, unl), 404);
assert.deepStrictEqual(await svc.remove(mod, priv), { success: true }, 'staff');
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);
const row = await db.prepare('SELECT slug, content, deleted_at FROM pastes WHERE slug = ?').get(unl);