openvibers/openvibe.community Commits
An erased paste's slug is never given to another paste (AGT-4) (#249)
Changes
nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- Paste slugs an account erasure took out of the table (ADR-033: a person's paste that no one else commented on is++-- deleted, not tombstoned). Its slug stays retired: no later paste is ever given it, and no re-import can bring the++-- paste back, so a link someone kept or a search engine indexed never opens another person's paste (AGT-4). Only a++-- SHA-256 of the slug is kept, so nothing readable of the erased paste stays.++CREATE TABLE retired_paste_slugs (++ slug_hash text COLLATE "C" PRIMARY KEY CHECK (slug_hash ~ '^[0-9a-f]{64}$'),++ retired_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);@@@ -97,6 +97,8 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {ai_summary = NULL, ai_tags = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);add(retained, 'tombstones', 1);} else {++ // Its slug is retired first, so no later paste is given it and no re-import brings it back.++ await require('../pastes/store').retireSlug(db, p.slug);await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);add(erased, 'pastes', 1);}@@@ -315,7 +315,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limif (!isService(v) || body.slug == null || body.slug === '' || secret) return await store.generateSlug(db, { secret });const slug = String(body.slug);if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');-- if (await db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');++ if (await store.slugTaken(db, slug)) fail(409, 'Slug already taken'); // an erased paste's slug too: it never comes backreturn slug;}@@@ -61,8 +61,21 @@ const SECRET_CHARS = 16; // 62^16 ≈ 2^95, on top of the words* characters (adj-noun-XXXXXXXXXXXXXXXX): the ~1.5M word/number slugs could be walked, these* cannot. Existing slugs never change.*/++// A slug is never given to a second paste, while its paste exists (soft-deleted ones included) or after an account++// erasure deleted it (retired_paste_slugs keeps only a SHA-256 of it, migration 0014): a link someone kept never opens++// another person's paste, and a re-import never brings an erased one back (AGT-4).++const slugHash = (slug) => crypto.createHash('sha256').update(String(slug)).digest('hex');++async function slugTaken(db, slug) {++ return !!(await db.prepare('SELECT 1 FROM pastes WHERE slug = ? UNION ALL SELECT 1 FROM retired_paste_slugs WHERE slug_hash = ? LIMIT 1')++ .get(String(slug), slugHash(slug)));++}++/** Retire an erased paste's slug before its row is deleted. */++async function retireSlug(db, slug) {++ await db.prepare('INSERT INTO retired_paste_slugs (slug_hash) VALUES (?) ON CONFLICT (slug_hash) DO NOTHING').run(slugHash(slug));++}++async function generateSlug(db, { secret = false } = {}) {-- const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');++ const taken = { get: (slug) => slugTaken(db, slug) };const words = () => `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}`;if (secret) {for (let i = 0; i < 10; i++) {@@@ -424,7 +437,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {}module.exports = {-- generateSlug, sanitizeTitle, detectLanguage,++ generateSlug, slugTaken, retireSlug, sanitizeTitle, detectLanguage,getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,setAi, setScreenshot, setVisibility, stats, statSeries, listForks, deleteAllForks,@@@ -81,6 +81,12 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;const all = async (sql) => await db.prepare(sql).all();assert.deepStrictEqual(await all('SELECT slug, owner_subject AS o, title, content FROM pastes ORDER BY id'), [{ slug: 'p1', o: null, title: '[deleted]', content: '' }, { slug: 'p3', o: OTTO, title: "otto's", content: 'x' }], "answered paste a tombstone, the alone one gone, the alias's gone");++ const store = require('../server/pastes/store');++ const sha = (s) => require('crypto').createHash('sha256').update(s).digest('hex');++ assert.deepStrictEqual((await all('SELECT slug_hash FROM retired_paste_slugs ORDER BY slug_hash')).map((r) => r.slug_hash), [sha('p2'), sha('p4')].sort(),++ 'the erased pastes\' slugs are retired, as hashes only');++ assert.deepStrictEqual([await store.slugTaken(db, 'p2'), await store.slugTaken(db, 'p4'), await store.slugTaken(db, 'p1'), await store.slugTaken(db, 'p9')], [true, true, true, false],++ 'an erased slug is never given out again (AGT-4)');assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message FROM paste_comments ORDER BY id'), [{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [@@@ -111,6 +111,10 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(p.ai_summary, 'chat exploded');const dup = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'wild-moment-4242' } });assert.strictEqual(dup.status, 409);++ // An erased paste's slug (retired_paste_slugs) is never brought back by a service naming it (AGT-4).++ await require('../server/pastes/store').retireSlug(t.db, 'erased-moment-77');++ const back = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'erased-moment-77' } });++ assert.strictEqual(back.status, 409, 'an erased slug is not given out again');const bad = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'config' } });assert.strictEqual(bad.status, 400);const onlyAi = (await call('/api/pastes?origin=ai')).json();@@@ -174,8 +174,11 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((await call('/p/calm-otter-42/raw')).status, 200);// The secret part is really random.const { generateSlug } = require('../server/pastes/store');-- const many = new Set(Array.from({ length: 2000 }, async () => (await generateSlug(t.db, { secret: true })).split('-')[2]));-- assert.strictEqual(many.size, 2000);++ // 2,000 secret tails, all different. Awaited in batches: a Set of 2,000 pending promises (as this was) proved nothing++ // and left queries running after the pool closed.++ const tails = [];++ for (let b = 0; b < 20; b++) tails.push(...await Promise.all(Array.from({ length: 100 }, async () => (await generateSlug(t.db, { secret: true })).split('-')[2])));++ assert.strictEqual(new Set(tails).size, 2000);});await t.close();
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- Paste slugs an account erasure took out of the table (ADR-033: a person's paste that no one else commented on is
+-- deleted, not tombstoned). Its slug stays retired: no later paste is ever given it, and no re-import can bring the
+-- paste back, so a link someone kept or a search engine indexed never opens another person's paste (AGT-4). Only a
+-- SHA-256 of the slug is kept, so nothing readable of the erased paste stays.
+CREATE TABLE retired_paste_slugs (
+ slug_hash text COLLATE "C" PRIMARY KEY CHECK (slug_hash ~ '^[0-9a-f]{64}$'),
+ retired_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
@@ -97,6 +97,8 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -97,6 +97,8 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
ai_summary = NULL, ai_tags = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
ai_summary = NULL, ai_tags = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
add(retained, 'tombstones', 1);
add(retained, 'tombstones', 1);
} else {
} else {
+ // Its slug is retired first, so no later paste is given it and no re-import brings it back.
+ await require('../pastes/store').retireSlug(db, p.slug);
await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
await db.prepare('DELETE FROM pastes WHERE id = ?').run(p.id);
add(erased, 'pastes', 1);
add(erased, 'pastes', 1);
}
}
@@ -315,7 +315,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -315,7 +315,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!isService(v) || body.slug == null || body.slug === '' || secret) return await store.generateSlug(db, { secret });
if (!isService(v) || body.slug == null || body.slug === '' || secret) return await store.generateSlug(db, { secret });
const slug = String(body.slug);
const slug = String(body.slug);
if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');
if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');
- if (await db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');
+ if (await store.slugTaken(db, slug)) fail(409, 'Slug already taken'); // an erased paste's slug too: it never comes back
return slug;
return slug;
}
}
@@ -61,8 +61,21 @@ const SECRET_CHARS = 16; // 62^16 ≈ 2^95, on top of the words
@@ -61,8 +61,21 @@ const SECRET_CHARS = 16; // 62^16 ≈ 2^95, on top of the words
* characters (adj-noun-XXXXXXXXXXXXXXXX): the ~1.5M word/number slugs could be walked, these
* characters (adj-noun-XXXXXXXXXXXXXXXX): the ~1.5M word/number slugs could be walked, these
* cannot. Existing slugs never change.
* cannot. Existing slugs never change.
*/
*/
+// A slug is never given to a second paste, while its paste exists (soft-deleted ones included) or after an account
+// erasure deleted it (retired_paste_slugs keeps only a SHA-256 of it, migration 0014): a link someone kept never opens
+// another person's paste, and a re-import never brings an erased one back (AGT-4).
+const slugHash = (slug) => crypto.createHash('sha256').update(String(slug)).digest('hex');
+async function slugTaken(db, slug) {
+ return !!(await db.prepare('SELECT 1 FROM pastes WHERE slug = ? UNION ALL SELECT 1 FROM retired_paste_slugs WHERE slug_hash = ? LIMIT 1')
+ .get(String(slug), slugHash(slug)));
+}
+/** Retire an erased paste's slug before its row is deleted. */
+async function retireSlug(db, slug) {
+ await db.prepare('INSERT INTO retired_paste_slugs (slug_hash) VALUES (?) ON CONFLICT (slug_hash) DO NOTHING').run(slugHash(slug));
+}
+
async function generateSlug(db, { secret = false } = {}) {
async function generateSlug(db, { secret = false } = {}) {
- const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');
+ const taken = { get: (slug) => slugTaken(db, slug) };
const words = () => `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}`;
const words = () => `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}`;
if (secret) {
if (secret) {
for (let i = 0; i < 10; i++) {
for (let i = 0; i < 10; i++) {
@@ -424,7 +437,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {
@@ -424,7 +437,7 @@ async function mapSet(db, system, type, id, targetType, targetId) {
}
}
module.exports = {
module.exports = {
- generateSlug, sanitizeTitle, detectLanguage,
+ generateSlug, slugTaken, retireSlug, sanitizeTitle, detectLanguage,
getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, 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,
@@ -81,6 +81,12 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
@@ -81,6 +81,12 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
const all = async (sql) => await db.prepare(sql).all();
const all = async (sql) => await db.prepare(sql).all();
assert.deepStrictEqual(await all('SELECT slug, owner_subject AS o, title, content FROM pastes ORDER BY id'), [
assert.deepStrictEqual(await all('SELECT slug, owner_subject AS o, title, content FROM pastes ORDER BY id'), [
{ slug: 'p1', o: null, title: '[deleted]', content: '' }, { slug: 'p3', o: OTTO, title: "otto's", content: 'x' }], "answered paste a tombstone, the alone one gone, the alias's gone");
{ slug: 'p1', o: null, title: '[deleted]', content: '' }, { slug: 'p3', o: OTTO, title: "otto's", content: 'x' }], "answered paste a tombstone, the alone one gone, the alias's gone");
+ const store = require('../server/pastes/store');
+ const sha = (s) => require('crypto').createHash('sha256').update(s).digest('hex');
+ assert.deepStrictEqual((await all('SELECT slug_hash FROM retired_paste_slugs ORDER BY slug_hash')).map((r) => r.slug_hash), [sha('p2'), sha('p4')].sort(),
+ 'the erased pastes\' slugs are retired, as hashes only');
+ assert.deepStrictEqual([await store.slugTaken(db, 'p2'), await store.slugTaken(db, 'p4'), await store.slugTaken(db, 'p1'), await store.slugTaken(db, 'p9')], [true, true, true, false],
+ 'an erased slug is never given out again (AGT-4)');
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message FROM paste_comments ORDER BY id'), [
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message FROM paste_comments ORDER BY id'), [
{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');
{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [
@@ -111,6 +111,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -111,6 +111,10 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(p.ai_summary, 'chat exploded');
assert.strictEqual(p.ai_summary, 'chat exploded');
const dup = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'wild-moment-4242' } });
const dup = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'wild-moment-4242' } });
assert.strictEqual(dup.status, 409);
assert.strictEqual(dup.status, 409);
+ // An erased paste's slug (retired_paste_slugs) is never brought back by a service naming it (AGT-4).
+ await require('../server/pastes/store').retireSlug(t.db, 'erased-moment-77');
+ const back = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'erased-moment-77' } });
+ assert.strictEqual(back.status, 409, 'an erased slug is not given out again');
const bad = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'config' } });
const bad = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'config' } });
assert.strictEqual(bad.status, 400);
assert.strictEqual(bad.status, 400);
const onlyAi = (await call('/api/pastes?origin=ai')).json();
const onlyAi = (await call('/api/pastes?origin=ai')).json();
@@ -174,8 +174,11 @@ const { boot, check, done } = require('./helpers/app');
@@ -174,8 +174,11 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await call('/p/calm-otter-42/raw')).status, 200);
assert.strictEqual((await call('/p/calm-otter-42/raw')).status, 200);
// The secret part is really random.
// The secret part is really random.
const { generateSlug } = require('../server/pastes/store');
const { generateSlug } = require('../server/pastes/store');
- const many = new Set(Array.from({ length: 2000 }, async () => (await generateSlug(t.db, { secret: true })).split('-')[2]));
- assert.strictEqual(many.size, 2000);
+ // 2,000 secret tails, all different. Awaited in batches: a Set of 2,000 pending promises (as this was) proved nothing
+ // and left queries running after the pool closed.
+ const tails = [];
+ for (let b = 0; b < 20; b++) tails.push(...await Promise.all(Array.from({ length: 100 }, async () => (await generateSlug(t.db, { secret: true })).split('-')[2])));
+ assert.strictEqual(new Set(tails).size, 2000);
});
});
await t.close();
await t.close();