openvibers/openvibe.community Commits
Comment threads cannot be enumerated: every thread gets an unguessable access id (cth_ + 128 random bits, filled in for existing threads on boot, unique index); browsers address threads only by it and get it as the thread id from resolve, while the sequential id keeps working for services (Blog, Wiki, Deals, Reviews resolve by EntityRef with a service token and store it), so walking ids no longer reveals the refs and comments of unlisted or private entities
Changes
@@@ -168,6 +168,11 @@ Who may do what:`X-OV-Origin: ai` (stored with origin `ai` and no author, shown as "OpenVibe AI").`community.comment.moderate` moderates — as itself (no `X-OV-Subject`), or for a person itvouches is staff with `X-OV-Staff: 1`.++- **Thread ids**: every thread has an unguessable access id (`cth_` + 22 characters). Browsers++ get it as the thread's `id` (from resolve) and can address a thread only by it; the sequential++ id works for services only (their resolve answer carries it as `id`, plus `access_id` to hand to++ a browser). Nobody can walk thread ids to find the refs and comments of entities they were not++ given.- **Locked** threads can be read but take no comments or votes (moderators still may);**hidden** threads are 404 for everyone but moderators.- Paste comments stay in `paste_comments` behind `/api/pastes/:slug/comments` for now.@@@ -5,6 +5,7 @@** POST /threads/resolve { ref: EntityRef } → get-or-create (201 created, 200 existing)* GET /threads/:id thread + first page (?after=<id>&sort=old|new&limit=, ?parent=<id>)++ * :id is the access id (cth_…); the sequential id is for services only* POST /threads/:id/comments { message, parent_id?, anon_name? }* PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators* DELETE /:commentId author or moderator@@@ -9,7 +9,10 @@* service's capability for the route):* - resolve: services with community.comment.write may resolve any ref; browsers (signed in or* not) only refs of the types in BROWSER_REF_TYPES. Community's own refs must exist.-- * - read: anyone, except hidden threads (moderators only; everyone else gets 404).++ * - read: anyone, except hidden threads (moderators only; everyone else gets 404). Browsers++ * address a thread only by its unguessable access_id (cth_…, what resolve hands them as++ * `id`); the sequential id works for services only, so nobody can walk thread ids and learn++ * the refs (unlisted paste slugs, private pages) and comments of entities they were not given.* - comment: people (browser or service X-OV-Subject), anonymous with an anon_name, or AI output* from a service (origin ai, never attributed). Not on locked threads (moderators still may).* - delete: the comment's author, or a moderator.@@@ -42,6 +45,7 @@ const BROWSER_REF_TYPES = {};const VISIBILITIES = ['public', 'hidden', 'locked'];const MAX_MESSAGE = 5000;++const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;const PAGE = 30;const REPLY_PAGE = 20;@@@ -56,17 +60,21 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsreturn ref;}++ /** The id a viewer addresses the thread by: services keep the sequential id they store; browsers get the access id. */++ const threadIdFor = (t, v) => (v && v.kind === 'service' ? t.id : t.access_id);++function shapeThread(t, v) {-- if (t.visibility === 'hidden' && !moderator(v)) return { id: t.id, ref: refOf(t), visibility: 'hidden', comment_count: null };-- return { id: t.id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };++ const id = threadIdFor(t, v);++ if (t.visibility === 'hidden' && !moderator(v)) return { id, access_id: t.access_id, ref: refOf(t), visibility: 'hidden', comment_count: null };++ return { id, access_id: t.access_id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };}-- function shapeComment(c, v, projections, votes) {++ function shapeComment(c, v, projections, votes, threadId) {const deleted = !!c.deleted_at;const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);const out = {id: c.id,-- thread_id: c.thread_id,++ thread_id: threadId,parent_id: c.parent_id || null,origin: c.origin,author: a,@@@ -81,29 +89,37 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsupdated_at: isoTime(c.updated_at),can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),};-- if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes));++ if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));return out;}-- async function shapeComments(rows, v) {++ async function shapeComments(rows, v, t) {const all = [];for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }const projections = await authors.projectionsFor(all.map((c) => c.author_subject));const votes = myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);-- return rows.map((r) => shapeComment(r, v, projections, votes));++ return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v)));++ }++++ /** A thread by the id this viewer may use: the access id for anyone, the sequential id for services only. */++ function threadById(v, id) {++ const s = String(id == null ? '' : id);++ if (ACCESS_ID_RE.test(s)) return store.getThreadByAccessId(db, s);++ if (v && v.kind === 'service' && /^\d{1,15}$/.test(s)) return store.getThread(db, Number(s));++ return null;}/** The thread, or 404 — hidden threads look missing to everyone but moderators. */-- function visibleThread(v, id) {-- const t = /^\d{1,15}$/.test(String(id)) ? store.getThread(db, Number(id)) : null;++ function visible(v, t) {if (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');return t;}++ const visibleThread = (v, id) => visible(v, threadById(v, id));function visibleComment(v, id) {const c = /^\d{1,15}$/.test(String(id)) ? store.getComment(db, Number(id)) : null;if (!c || c.deleted_at) fail(404, 'comment.not_found', 'Comment not found');-- return { comment: c, thread: visibleThread(v, c.thread_id) };++ return { comment: c, thread: visible(v, store.getThread(db, c.thread_id)) };}/** Community's own entities must exist (and be visible) before anyone opens a thread on them. */@@@ -163,7 +179,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits} else {({ rows, hasMore } = store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));}-- const comments = await shapeComments(rows, v);++ const comments = await shapeComments(rows, v, t);return {thread: shapeThread(t, v),comments,@@@ -203,7 +219,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limitscommentLimiter.check(key, message);const c = store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });commentLimiter.record(key, message);-- return { comment: (await shapeComments([c], v))[0] };++ return { comment: (await shapeComments([c], v, t))[0] };},/** DELETE /comments/:id — the author or a moderator. */@@@ -234,7 +250,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limitsif (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');const visibility = body.visibility;if (!VISIBILITIES.includes(visibility)) fail(400, 'thread.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);-- const t = /^\d{1,15}$/.test(String(id)) ? store.getThread(db, Number(id)) : null;++ const t = threadById(v, id);if (!t) fail(404, 'thread.not_found', 'Comment thread not found');return { thread: shapeThread(store.setThreadVisibility(db, t.id, visibility), v) };},@@@ -9,11 +9,17 @@* soft (message scrubbed); a deleted top-level comment that still has replies stays as a* tombstone so the replies keep their context.*/++const { newThreadAccessId } = require('../db');function getThread(db, id) {return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;}++/** A thread by the unguessable handle browsers use (server/db.js: access_id). */++function getThreadByAccessId(db, accessId) {++ return db.prepare('SELECT * FROM comment_threads WHERE access_id = ?').get(String(accessId)) || null;++}++function getThreadByRef(db, ref) {return db.prepare('SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?').get(ref.service, ref.type, String(ref.id)) || null;@@@ -26,9 +32,9 @@ function getThreadByRef(db, ref) {*/function resolveThread(db, ref, { label = null, createdBy = null } = {}) {return db.transaction(() => {-- const info = db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by) VALUES (?, ?, ?, ?, ?)++ const info = db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by, access_id) VALUES (?, ?, ?, ?, ?, ?)ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING`)-- .run(ref.service, ref.type, String(ref.id), label, createdBy);++ .run(ref.service, ref.type, String(ref.id), label, createdBy, newThreadAccessId());if (!info.changes && label) {db.prepare('UPDATE comment_threads SET ref_label = ?, updated_at = CURRENT_TIMESTAMP WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, \'\') <> ?').run(label, ref.service, ref.type, String(ref.id), label);@@@ -99,4 +105,4 @@ function listReplies(db, parentId, { after = null, limit = 50 } = {}) {return { rows, hasMore };}--module.exports = { getThread, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, softDeleteComment, listTopLevel, listReplies };++module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, softDeleteComment, listTopLevel, listReplies };@@@ -11,6 +11,7 @@** Schema creation is idempotent (CREATE … IF NOT EXISTS) and runs on open.*/++const crypto = require('crypto');const fs = require('fs');const path = require('path');const Database = require('better-sqlite3');@@@ -137,6 +138,7 @@ CREATE TABLE IF NOT EXISTS comment_threads (created_by TEXT, -- subject or service principal that first resolved itcreated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,++ access_id TEXT, -- unguessable handle for browsers (migrate() fills old rows, unique index)UNIQUE (ref_service, ref_type, ref_id));@@@ -323,10 +325,33 @@ function openDb(file) {db.pragma('foreign_keys = ON');db.pragma('busy_timeout = 5000');db.exec(SCHEMA);++ migrate(db);registerFunctions(db);return db;}++/**++ * Additive changes to tables that already exist (CREATE TABLE IF NOT EXISTS never alters one).++ * Idempotent: every boot checks and fills in only what is missing.++ *++ * comment_threads.access_id — the unguessable handle browsers address a thread by (128 random++ * bits); the sequential id is for services only, so threads cannot be enumerated. Threads made++ * before the column existed get one here.++ */++function migrate(db) {++ const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));++ if (!cols.has('access_id')) db.exec('ALTER TABLE comment_threads ADD COLUMN access_id TEXT');++ const missing = db.prepare('SELECT id FROM comment_threads WHERE access_id IS NULL').all();++ if (missing.length) {++ const set = db.prepare('UPDATE comment_threads SET access_id = ? WHERE id = ?');++ db.transaction(() => { for (const r of missing) set.run(newThreadAccessId(), r.id); })();++ }++ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');++}++++/** cth_ + 22 base64url characters (16 random bytes). */++function newThreadAccessId() { return `cth_${crypto.randomBytes(16).toString('base64url')}`; }++let _shared = null;/** The process-wide database at COMMUNITY_DB_PATH (opened on first use). */function getDb() {@@@ -334,4 +359,4 @@ function getDb() {return _shared;}--module.exports = { openDb, getDb, registerFunctions, SCHEMA };++module.exports = { openDb, getDb, registerFunctions, SCHEMA, newThreadAccessId };@@@ -21,7 +21,7 @@ const { checkCapability } = require('../server/identity/capabilities');const svc = (cap, extra = {}) => net.signService({ cap, ...extra });const WRITE = 'community.comment.write', MOD = 'community.comment.moderate';-- const call = (path, { method = 'GET', token, cookie, headers = {}, json, ip } = {}) => {++ const call0 = (path, { method = 'GET', token, cookie, headers = {}, json, ip } = {}) => {const h = { ...headers };if (token) h.authorization = `Bearer ${token}`;if (ip) h['x-forwarded-for'] = ip;@@@ -29,6 +29,7 @@ const { checkCapability } = require('../server/identity/capabilities');if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });};++ const call = call0;const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });@@@ -108,7 +109,7 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual(c.anon_name, null);assert.strictEqual(c.origin, 'user');assert.strictEqual(c.can_delete, true);-- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count, 1);++ assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, 1);assert.strictEqual((await post(vod.id, { message: ' ' }, { cookie: alexJwt })).status, 400);assert.strictEqual((await post(vod.id, { message: 'x'.repeat(5001) }, { cookie: alexJwt })).status, 400);assert.strictEqual((await post(99999, { message: 'hi' }, { cookie: alexJwt })).status, 404);@@@ -199,13 +200,13 @@ const { checkCapability } = require('../server/identity/capabilities');await check('delete: author or staff; others 403; tombstone keeps replies; counts follow', async () => {const top = (await post(vod.id, { message: 'to be deleted' }, { cookie: alexJwt })).json().comment;await post(vod.id, { message: 'a reply that stays', parent_id: top.id }, { cookie: samJwt });-- const before = t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count;++ const before = t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count;assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE' })).status, 401);assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).status, 403);const ok = await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: alexJwt });assert.strictEqual(ok.status, 200, ok.text);-- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count, before - 1);++ assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, before - 1);assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(top.id).message, '', 'scrubbed');const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();const tomb = page.comments.find((c) => c.id === top.id);@@@ -246,7 +247,7 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual((await post(th.id, { message: 'hidden?' }, { cookie: samJwt })).status, 404);assert.strictEqual((await call(`/api/v1/comments/${c.id}`, { method: 'DELETE', cookie: samJwt })).status, 404);const again = (await resolve({ service: 'live', type: 'clip', id: 'vis-1' })).json().thread;-- assert.deepStrictEqual(again, { id: th.id, ref: { service: 'live', type: 'clip', id: 'vis-1' }, visibility: 'hidden', comment_count: null });++ assert.deepStrictEqual(again, { id: th.id, access_id: th.id, ref: { service: 'live', type: 'clip', id: 'vis-1' }, visibility: 'hidden', comment_count: null });const mod = await call(`/api/v1/comments/threads/${th.id}`, { token: svc([MOD]) });assert.strictEqual(mod.status, 200);assert.strictEqual(mod.json().comments.length, 2);@@@ -267,6 +268,80 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual((await call(`/api/v1/comments/threads/${vod.id}`, { token: svc(['community.pulse.write']) })).status, 403, 'reads need a comment capability');});++ await check('thread ids cannot be enumerated: browsers need the access id, sequential ids are for services', async () => {++ // Every request from its own address: this test makes more calls than the /api/ read limiter allows one.++ let n = 0;++ const ipOf = () => `198.18.${(n >> 8) & 255}.${(n++ & 255)}`;++ const call = (p, o = {}) => call0(p, { ip: ipOf(), ...o });++ const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });++ const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });++ // A service resolves (Blog, Wiki, Deals, Live): it gets the sequential id it stores, plus the access id.++ const svcW = svc([WRITE]);++ const made = await resolve({ service: 'wiki', type: 'page', id: 'pg_private_space_1' }, { token: svcW });++ assert.strictEqual(made.status, 201, made.text);++ const th = made.json().thread;++ assert.ok(Number.isInteger(th.id), 'services keep the sequential id');++ assert.match(th.access_id, /^cth_[A-Za-z0-9_-]{22}$/);++ assert.strictEqual((await post(th.id, { message: 'said by a service' }, { token: svcW, headers: { 'x-ov-subject': alex.subject_id } })).status, 201);++ const bySvc = await call(`/api/v1/comments/threads/${th.id}`, { token: svcW });++ assert.strictEqual(bySvc.status, 200, 'services still read by sequential id');++ assert.strictEqual(bySvc.json().comments[0].thread_id, th.id);++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.access_id}`, { token: svcW })).status, 200, 'and by access id');++++ // Browsers walking ids 1..N find nothing, signed in or not, staff included.++ const max = t.db.prepare('SELECT MAX(id) AS m FROM comment_threads').get().m;++ for (let id = 1; id <= max; id++) {++ for (const who of [{}, { cookie: samJwt }, { token: alexJwt }, { cookie: adminJwt }]) {++ const r = await call(`/api/v1/comments/threads/${id}`, who);++ assert.strictEqual(r.status, 404, `GET /threads/${id} as ${JSON.stringify(Object.keys(who))}`);++ assert.strictEqual(r.json().code, 'thread.not_found');++ }++ }++ assert.strictEqual((await post(th.id, { message: 'guessing' }, { cookie: samJwt })).status, 404);++ assert.strictEqual((await post(th.id, { message: 'guessing' })).status, 404);++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}/visibility`, { method: 'PUT', json: { visibility: 'locked' }, cookie: adminJwt })).status, 404);++ for (const bad of ['cth_short', `${th.access_id}x`, `cth_${'A'.repeat(22)}`, '0x1', ' 1']) {++ assert.strictEqual((await call(`/api/v1/comments/threads/${encodeURIComponent(bad)}`)).status, 404, bad);++ }++++ // ...but a browser handed the access id (by resolve, or by the product's page) reads and writes as before.++ const byBrowser = await call(`/api/v1/comments/threads/${th.access_id}`, { cookie: samJwt });++ assert.strictEqual(byBrowser.status, 200, byBrowser.text);++ assert.strictEqual(byBrowser.json().thread.id, th.access_id);++ assert.strictEqual(byBrowser.json().comments[0].thread_id, th.access_id, 'no sequential id reaches a browser');++ assert.ok(!byBrowser.text.includes(`"thread_id":${th.id}`));++ assert.strictEqual((await post(th.access_id, { message: 'with the handle' }, { cookie: samJwt })).status, 201);++ const again = await resolve({ service: 'live', type: 'vod', id: '123' }, { cookie: samJwt });++ assert.strictEqual(again.json().thread.id, vod.id, 'browser resolve hands out the access id');++ assert.match(vod.id, /^cth_/);++ const all = t.db.prepare('SELECT access_id FROM comment_threads').all().map((r) => r.access_id);++ assert.ok(all.every(Boolean) && new Set(all).size === all.length, 'every thread has its own');++ });++++ await check('threads made before access ids get one on boot (migration is idempotent)', async () => {++ const fs = require('fs'), os = require('os'), path = require('path');++ const Database = require('better-sqlite3');++ const { openDb } = require('../server/db');++ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-community-mig-')), 'old.db');++ const old = new Database(file);++ old.exec(`CREATE TABLE comment_threads (id INTEGER PRIMARY KEY AUTOINCREMENT, ref_service TEXT NOT NULL, ref_type TEXT NOT NULL, ref_id TEXT NOT NULL, ref_label TEXT,++ visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')), comment_count INTEGER NOT NULL DEFAULT 0, created_by TEXT,++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (ref_service, ref_type, ref_id))`);++ for (let i = 0; i < 5; i++) old.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', ?)").run(String(i));++ old.close();++ const db = openDb(file);++ const rows = db.prepare('SELECT id, access_id FROM comment_threads').all();++ assert.strictEqual(rows.length, 5);++ for (const r of rows) assert.match(r.access_id, /^cth_[A-Za-z0-9_-]{22}$/);++ assert.strictEqual(new Set(rows.map((r) => r.access_id)).size, 5);++ db.close();++ const db2 = openDb(file);++ assert.deepStrictEqual(db2.prepare('SELECT id, access_id FROM comment_threads').all(), rows, 'a second boot changes nothing');++ assert.throws(() => db2.prepare('UPDATE comment_threads SET access_id = ? WHERE id = ?').run(rows[0].access_id, rows[1].id), /UNIQUE/);++ db2.close();++ fs.rmSync(path.dirname(file), { recursive: true, force: true });++ });++await check('CORS: OpenVibe origins get Bearer-only CORS headers; others none; preflight answers 204', async () => {const pre = await call(`/api/v1/comments/threads/${vod.id}/comments`, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST' } });assert.strictEqual(pre.status, 204);
@@ -168,6 +168,11 @@ Who may do what:
@@ -168,6 +168,11 @@ Who may do what:
`X-OV-Origin: ai` (stored with origin `ai` and no author, shown as "OpenVibe AI").
`X-OV-Origin: ai` (stored with origin `ai` and no author, shown as "OpenVibe AI").
`community.comment.moderate` moderates — as itself (no `X-OV-Subject`), or for a person it
`community.comment.moderate` moderates — as itself (no `X-OV-Subject`), or for a person it
vouches is staff with `X-OV-Staff: 1`.
vouches is staff with `X-OV-Staff: 1`.
+- **Thread ids**: every thread has an unguessable access id (`cth_` + 22 characters). Browsers
+ get it as the thread's `id` (from resolve) and can address a thread only by it; the sequential
+ id works for services only (their resolve answer carries it as `id`, plus `access_id` to hand to
+ a browser). Nobody can walk thread ids to find the refs and comments of entities they were not
+ given.
- **Locked** threads can be read but take no comments or votes (moderators still may);
- **Locked** threads can be read but take no comments or votes (moderators still may);
**hidden** threads are 404 for everyone but moderators.
**hidden** threads are 404 for everyone but moderators.
- Paste comments stay in `paste_comments` behind `/api/pastes/:slug/comments` for now.
- Paste comments stay in `paste_comments` behind `/api/pastes/:slug/comments` for now.
@@ -5,6 +5,7 @@
@@ -5,6 +5,7 @@
*
*
* POST /threads/resolve { ref: EntityRef } → get-or-create (201 created, 200 existing)
* POST /threads/resolve { ref: EntityRef } → get-or-create (201 created, 200 existing)
* GET /threads/:id thread + first page (?after=<id>&sort=old|new&limit=, ?parent=<id>)
* GET /threads/:id thread + first page (?after=<id>&sort=old|new&limit=, ?parent=<id>)
+ * :id is the access id (cth_…); the sequential id is for services only
* POST /threads/:id/comments { message, parent_id?, anon_name? }
* POST /threads/:id/comments { message, parent_id?, anon_name? }
* PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators
* PUT /threads/:id/visibility { visibility: public|hidden|locked } moderators
* DELETE /:commentId author or moderator
* DELETE /:commentId author or moderator
@@ -9,7 +9,10 @@
@@ -9,7 +9,10 @@
* service's capability for the route):
* service's capability for the route):
* - resolve: services with community.comment.write may resolve any ref; browsers (signed in or
* - resolve: services with community.comment.write may resolve any ref; browsers (signed in or
* not) only refs of the types in BROWSER_REF_TYPES. Community's own refs must exist.
* not) only refs of the types in BROWSER_REF_TYPES. Community's own refs must exist.
- * - read: anyone, except hidden threads (moderators only; everyone else gets 404).
+ * - read: anyone, except hidden threads (moderators only; everyone else gets 404). Browsers
+ * address a thread only by its unguessable access_id (cth_…, what resolve hands them as
+ * `id`); the sequential id works for services only, so nobody can walk thread ids and learn
+ * the refs (unlisted paste slugs, private pages) and comments of entities they were not given.
* - comment: people (browser or service X-OV-Subject), anonymous with an anon_name, or AI output
* - comment: people (browser or service X-OV-Subject), anonymous with an anon_name, or AI output
* from a service (origin ai, never attributed). Not on locked threads (moderators still may).
* from a service (origin ai, never attributed). Not on locked threads (moderators still may).
* - delete: the comment's author, or a moderator.
* - delete: the comment's author, or a moderator.
@@ -42,6 +45,7 @@ const BROWSER_REF_TYPES = {
@@ -42,6 +45,7 @@ const BROWSER_REF_TYPES = {
};
};
const VISIBILITIES = ['public', 'hidden', 'locked'];
const VISIBILITIES = ['public', 'hidden', 'locked'];
const MAX_MESSAGE = 5000;
const MAX_MESSAGE = 5000;
+const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
const PAGE = 30;
const REPLY_PAGE = 20;
const REPLY_PAGE = 20;
@@ -56,17 +60,21 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -56,17 +60,21 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
return ref;
return ref;
}
}
+ /** The id a viewer addresses the thread by: services keep the sequential id they store; browsers get the access id. */
+ const threadIdFor = (t, v) => (v && v.kind === 'service' ? t.id : t.access_id);
+
function shapeThread(t, v) {
function shapeThread(t, v) {
- if (t.visibility === 'hidden' && !moderator(v)) return { id: t.id, ref: refOf(t), visibility: 'hidden', comment_count: null };
- return { id: t.id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
+ const id = threadIdFor(t, v);
+ if (t.visibility === 'hidden' && !moderator(v)) return { id, access_id: t.access_id, ref: refOf(t), visibility: 'hidden', comment_count: null };
+ return { id, access_id: t.access_id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
}
}
- function shapeComment(c, v, projections, votes) {
+ function shapeComment(c, v, projections, votes, threadId) {
const deleted = !!c.deleted_at;
const deleted = !!c.deleted_at;
const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);
const a = deleted ? null : authors.author(c.author_subject, c.origin, projections);
const out = {
const out = {
id: c.id,
id: c.id,
- thread_id: c.thread_id,
+ thread_id: threadId,
parent_id: c.parent_id || null,
parent_id: c.parent_id || null,
origin: c.origin,
origin: c.origin,
author: a,
author: a,
@@ -81,29 +89,37 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -81,29 +89,37 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
updated_at: isoTime(c.updated_at),
updated_at: isoTime(c.updated_at),
can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
};
};
- if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes));
+ if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));
return out;
return out;
}
}
- async function shapeComments(rows, v) {
+ async function shapeComments(rows, v, t) {
const all = [];
const all = [];
for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }
for (const r of rows) { all.push(r); if (r.replies) all.push(...r.replies); }
const projections = await authors.projectionsFor(all.map((c) => c.author_subject));
const projections = await authors.projectionsFor(all.map((c) => c.author_subject));
const votes = myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);
const votes = myVotes(db, 'comment', all.map((c) => c.id), v && v.subject);
- return rows.map((r) => shapeComment(r, v, projections, votes));
+ return rows.map((r) => shapeComment(r, v, projections, votes, threadIdFor(t, v)));
+ }
+
+ /** A thread by the id this viewer may use: the access id for anyone, the sequential id for services only. */
+ function threadById(v, id) {
+ const s = String(id == null ? '' : id);
+ if (ACCESS_ID_RE.test(s)) return store.getThreadByAccessId(db, s);
+ if (v && v.kind === 'service' && /^\d{1,15}$/.test(s)) return store.getThread(db, Number(s));
+ return null;
}
}
/** The thread, or 404 — hidden threads look missing to everyone but moderators. */
/** The thread, or 404 — hidden threads look missing to everyone but moderators. */
- function visibleThread(v, id) {
- const t = /^\d{1,15}$/.test(String(id)) ? store.getThread(db, Number(id)) : null;
+ function visible(v, t) {
if (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');
if (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');
return t;
return t;
}
}
+ const visibleThread = (v, id) => visible(v, threadById(v, id));
function visibleComment(v, id) {
function visibleComment(v, id) {
const c = /^\d{1,15}$/.test(String(id)) ? store.getComment(db, Number(id)) : null;
const c = /^\d{1,15}$/.test(String(id)) ? store.getComment(db, Number(id)) : null;
if (!c || c.deleted_at) fail(404, 'comment.not_found', 'Comment not found');
if (!c || c.deleted_at) fail(404, 'comment.not_found', 'Comment not found');
- return { comment: c, thread: visibleThread(v, c.thread_id) };
+ return { comment: c, thread: visible(v, store.getThread(db, c.thread_id)) };
}
}
/** Community's own entities must exist (and be visible) before anyone opens a thread on them. */
/** Community's own entities must exist (and be visible) before anyone opens a thread on them. */
@@ -163,7 +179,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -163,7 +179,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
} else {
} else {
({ rows, hasMore } = store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));
({ rows, hasMore } = store.listTopLevel(db, t.id, { after, sort: q.sort === 'new' ? 'new' : 'old', limit, replyLimit: REPLY_PAGE }));
}
}
- const comments = await shapeComments(rows, v);
+ const comments = await shapeComments(rows, v, t);
return {
return {
thread: shapeThread(t, v),
thread: shapeThread(t, v),
comments,
comments,
@@ -203,7 +219,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -203,7 +219,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
commentLimiter.check(key, message);
commentLimiter.check(key, message);
const c = store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });
const c = store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });
commentLimiter.record(key, message);
commentLimiter.record(key, message);
- return { comment: (await shapeComments([c], v))[0] };
+ return { comment: (await shapeComments([c], v, t))[0] };
},
},
/** DELETE /comments/:id — the author or a moderator. */
/** DELETE /comments/:id — the author or a moderator. */
@@ -234,7 +250,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
@@ -234,7 +250,7 @@ function createCommentService({ db, network = null, pastesLocal = false, limits
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');
if (!moderator(v)) fail(403, 'capability.denied', 'Only moderators change a thread\'s visibility');
const visibility = body.visibility;
const visibility = body.visibility;
if (!VISIBILITIES.includes(visibility)) fail(400, 'thread.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);
if (!VISIBILITIES.includes(visibility)) fail(400, 'thread.invalid_visibility', `visibility must be one of ${VISIBILITIES.join(', ')}`);
- const t = /^\d{1,15}$/.test(String(id)) ? store.getThread(db, Number(id)) : null;
+ const t = threadById(v, id);
if (!t) fail(404, 'thread.not_found', 'Comment thread not found');
if (!t) fail(404, 'thread.not_found', 'Comment thread not found');
return { thread: shapeThread(store.setThreadVisibility(db, t.id, visibility), v) };
return { thread: shapeThread(store.setThreadVisibility(db, t.id, visibility), v) };
},
},
@@ -9,11 +9,17 @@
@@ -9,11 +9,17 @@
* soft (message scrubbed); a deleted top-level comment that still has replies stays as a
* soft (message scrubbed); a deleted top-level comment that still has replies stays as a
* tombstone so the replies keep their context.
* tombstone so the replies keep their context.
*/
*/
+const { newThreadAccessId } = require('../db');
function getThread(db, id) {
function getThread(db, id) {
return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;
return db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;
}
}
+/** A thread by the unguessable handle browsers use (server/db.js: access_id). */
+function getThreadByAccessId(db, accessId) {
+ return db.prepare('SELECT * FROM comment_threads WHERE access_id = ?').get(String(accessId)) || null;
+}
+
function getThreadByRef(db, ref) {
function getThreadByRef(db, ref) {
return db.prepare('SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?')
return db.prepare('SELECT * FROM comment_threads WHERE ref_service = ? AND ref_type = ? AND ref_id = ?')
.get(ref.service, ref.type, String(ref.id)) || null;
.get(ref.service, ref.type, String(ref.id)) || null;
@@ -26,9 +32,9 @@ function getThreadByRef(db, ref) {
@@ -26,9 +32,9 @@ function getThreadByRef(db, ref) {
*/
*/
function resolveThread(db, ref, { label = null, createdBy = null } = {}) {
function resolveThread(db, ref, { label = null, createdBy = null } = {}) {
return db.transaction(() => {
return db.transaction(() => {
- const info = db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by) VALUES (?, ?, ?, ?, ?)
+ const info = db.prepare(`INSERT INTO comment_threads (ref_service, ref_type, ref_id, ref_label, created_by, access_id) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING`)
ON CONFLICT(ref_service, ref_type, ref_id) DO NOTHING`)
- .run(ref.service, ref.type, String(ref.id), label, createdBy);
+ .run(ref.service, ref.type, String(ref.id), label, createdBy, newThreadAccessId());
if (!info.changes && label) {
if (!info.changes && label) {
db.prepare('UPDATE comment_threads SET ref_label = ?, updated_at = CURRENT_TIMESTAMP WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, \'\') <> ?')
db.prepare('UPDATE comment_threads SET ref_label = ?, updated_at = CURRENT_TIMESTAMP WHERE ref_service = ? AND ref_type = ? AND ref_id = ? AND COALESCE(ref_label, \'\') <> ?')
.run(label, ref.service, ref.type, String(ref.id), label);
.run(label, ref.service, ref.type, String(ref.id), label);
@@ -99,4 +105,4 @@ function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
@@ -99,4 +105,4 @@ function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
return { rows, hasMore };
return { rows, hasMore };
}
}
-module.exports = { getThread, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, softDeleteComment, listTopLevel, listReplies };
+module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, softDeleteComment, listTopLevel, listReplies };
@@ -11,6 +11,7 @@
@@ -11,6 +11,7 @@
*
*
* Schema creation is idempotent (CREATE … IF NOT EXISTS) and runs on open.
* Schema creation is idempotent (CREATE … IF NOT EXISTS) and runs on open.
*/
*/
+const crypto = require('crypto');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
@@ -137,6 +138,7 @@ CREATE TABLE IF NOT EXISTS comment_threads (
@@ -137,6 +138,7 @@ CREATE TABLE IF NOT EXISTS comment_threads (
created_by TEXT, -- subject or service principal that first resolved it
created_by TEXT, -- subject or service principal that first resolved it
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ access_id TEXT, -- unguessable handle for browsers (migrate() fills old rows, unique index)
UNIQUE (ref_service, ref_type, ref_id)
UNIQUE (ref_service, ref_type, ref_id)
);
);
@@ -323,10 +325,33 @@ function openDb(file) {
@@ -323,10 +325,33 @@ function openDb(file) {
db.pragma('foreign_keys = ON');
db.pragma('foreign_keys = ON');
db.pragma('busy_timeout = 5000');
db.pragma('busy_timeout = 5000');
db.exec(SCHEMA);
db.exec(SCHEMA);
+ migrate(db);
registerFunctions(db);
registerFunctions(db);
return db;
return db;
}
}
+/**
+ * Additive changes to tables that already exist (CREATE TABLE IF NOT EXISTS never alters one).
+ * Idempotent: every boot checks and fills in only what is missing.
+ *
+ * comment_threads.access_id — the unguessable handle browsers address a thread by (128 random
+ * bits); the sequential id is for services only, so threads cannot be enumerated. Threads made
+ * before the column existed get one here.
+ */
+function migrate(db) {
+ const cols = new Set(db.prepare('PRAGMA table_info(comment_threads)').all().map((c) => c.name));
+ if (!cols.has('access_id')) db.exec('ALTER TABLE comment_threads ADD COLUMN access_id TEXT');
+ const missing = db.prepare('SELECT id FROM comment_threads WHERE access_id IS NULL').all();
+ if (missing.length) {
+ const set = db.prepare('UPDATE comment_threads SET access_id = ? WHERE id = ?');
+ db.transaction(() => { for (const r of missing) set.run(newThreadAccessId(), r.id); })();
+ }
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_comment_threads_access ON comment_threads(access_id)');
+}
+
+/** cth_ + 22 base64url characters (16 random bytes). */
+function newThreadAccessId() { return `cth_${crypto.randomBytes(16).toString('base64url')}`; }
+
let _shared = null;
let _shared = null;
/** The process-wide database at COMMUNITY_DB_PATH (opened on first use). */
/** The process-wide database at COMMUNITY_DB_PATH (opened on first use). */
function getDb() {
function getDb() {
@@ -334,4 +359,4 @@ function getDb() {
@@ -334,4 +359,4 @@ function getDb() {
return _shared;
return _shared;
}
}
-module.exports = { openDb, getDb, registerFunctions, SCHEMA };
+module.exports = { openDb, getDb, registerFunctions, SCHEMA, newThreadAccessId };
@@ -21,7 +21,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -21,7 +21,7 @@ const { checkCapability } = require('../server/identity/capabilities');
const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
const WRITE = 'community.comment.write', MOD = 'community.comment.moderate';
const WRITE = 'community.comment.write', MOD = 'community.comment.moderate';
- const call = (path, { method = 'GET', token, cookie, headers = {}, json, ip } = {}) => {
+ const call0 = (path, { method = 'GET', token, cookie, headers = {}, json, ip } = {}) => {
const h = { ...headers };
const h = { ...headers };
if (token) h.authorization = `Bearer ${token}`;
if (token) h.authorization = `Bearer ${token}`;
if (ip) h['x-forwarded-for'] = ip;
if (ip) h['x-forwarded-for'] = ip;
@@ -29,6 +29,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -29,6 +29,7 @@ const { checkCapability } = require('../server/identity/capabilities');
if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
return t.get(path, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
};
};
+ const call = call0;
const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });
const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });
const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });
const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });
@@ -108,7 +109,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -108,7 +109,7 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual(c.anon_name, null);
assert.strictEqual(c.anon_name, null);
assert.strictEqual(c.origin, 'user');
assert.strictEqual(c.origin, 'user');
assert.strictEqual(c.can_delete, true);
assert.strictEqual(c.can_delete, true);
- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count, 1);
+ assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, 1);
assert.strictEqual((await post(vod.id, { message: ' ' }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(vod.id, { message: ' ' }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(vod.id, { message: 'x'.repeat(5001) }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(vod.id, { message: 'x'.repeat(5001) }, { cookie: alexJwt })).status, 400);
assert.strictEqual((await post(99999, { message: 'hi' }, { cookie: alexJwt })).status, 404);
assert.strictEqual((await post(99999, { message: 'hi' }, { cookie: alexJwt })).status, 404);
@@ -199,13 +200,13 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -199,13 +200,13 @@ const { checkCapability } = require('../server/identity/capabilities');
await check('delete: author or staff; others 403; tombstone keeps replies; counts follow', async () => {
await check('delete: author or staff; others 403; tombstone keeps replies; counts follow', async () => {
const top = (await post(vod.id, { message: 'to be deleted' }, { cookie: alexJwt })).json().comment;
const top = (await post(vod.id, { message: 'to be deleted' }, { cookie: alexJwt })).json().comment;
await post(vod.id, { message: 'a reply that stays', parent_id: top.id }, { cookie: samJwt });
await post(vod.id, { message: 'a reply that stays', parent_id: top.id }, { cookie: samJwt });
- const before = t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count;
+ const before = t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count;
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: samJwt })).status, 403);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE' })).status, 401);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE' })).status, 401);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).status, 403);
assert.strictEqual((await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id } })).status, 403);
const ok = await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: alexJwt });
const ok = await call(`/api/v1/comments/${top.id}`, { method: 'DELETE', cookie: alexJwt });
assert.strictEqual(ok.status, 200, ok.text);
assert.strictEqual(ok.status, 200, ok.text);
- assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE id = ?').get(vod.id).comment_count, before - 1);
+ assert.strictEqual(t.db.prepare('SELECT comment_count FROM comment_threads WHERE access_id = ?').get(vod.id).comment_count, before - 1);
assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(top.id).message, '', 'scrubbed');
assert.strictEqual(t.db.prepare('SELECT message FROM comments WHERE id = ?').get(top.id).message, '', 'scrubbed');
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
const page = (await call(`/api/v1/comments/threads/${vod.id}?limit=100`)).json();
const tomb = page.comments.find((c) => c.id === top.id);
const tomb = page.comments.find((c) => c.id === top.id);
@@ -246,7 +247,7 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -246,7 +247,7 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await post(th.id, { message: 'hidden?' }, { cookie: samJwt })).status, 404);
assert.strictEqual((await post(th.id, { message: 'hidden?' }, { cookie: samJwt })).status, 404);
assert.strictEqual((await call(`/api/v1/comments/${c.id}`, { method: 'DELETE', cookie: samJwt })).status, 404);
assert.strictEqual((await call(`/api/v1/comments/${c.id}`, { method: 'DELETE', cookie: samJwt })).status, 404);
const again = (await resolve({ service: 'live', type: 'clip', id: 'vis-1' })).json().thread;
const again = (await resolve({ service: 'live', type: 'clip', id: 'vis-1' })).json().thread;
- assert.deepStrictEqual(again, { id: th.id, ref: { service: 'live', type: 'clip', id: 'vis-1' }, visibility: 'hidden', comment_count: null });
+ assert.deepStrictEqual(again, { id: th.id, access_id: th.id, ref: { service: 'live', type: 'clip', id: 'vis-1' }, visibility: 'hidden', comment_count: null });
const mod = await call(`/api/v1/comments/threads/${th.id}`, { token: svc([MOD]) });
const mod = await call(`/api/v1/comments/threads/${th.id}`, { token: svc([MOD]) });
assert.strictEqual(mod.status, 200);
assert.strictEqual(mod.status, 200);
assert.strictEqual(mod.json().comments.length, 2);
assert.strictEqual(mod.json().comments.length, 2);
@@ -267,6 +268,80 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -267,6 +268,80 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await call(`/api/v1/comments/threads/${vod.id}`, { token: svc(['community.pulse.write']) })).status, 403, 'reads need a comment capability');
assert.strictEqual((await call(`/api/v1/comments/threads/${vod.id}`, { token: svc(['community.pulse.write']) })).status, 403, 'reads need a comment capability');
});
});
+ await check('thread ids cannot be enumerated: browsers need the access id, sequential ids are for services', async () => {
+ // Every request from its own address: this test makes more calls than the /api/ read limiter allows one.
+ let n = 0;
+ const ipOf = () => `198.18.${(n >> 8) & 255}.${(n++ & 255)}`;
+ const call = (p, o = {}) => call0(p, { ip: ipOf(), ...o });
+ const post = (id, json, who = {}) => call(`/api/v1/comments/threads/${id}/comments`, { method: 'POST', json, ...who });
+ const resolve = (ref, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref }, ...who });
+ // A service resolves (Blog, Wiki, Deals, Live): it gets the sequential id it stores, plus the access id.
+ const svcW = svc([WRITE]);
+ const made = await resolve({ service: 'wiki', type: 'page', id: 'pg_private_space_1' }, { token: svcW });
+ assert.strictEqual(made.status, 201, made.text);
+ const th = made.json().thread;
+ assert.ok(Number.isInteger(th.id), 'services keep the sequential id');
+ assert.match(th.access_id, /^cth_[A-Za-z0-9_-]{22}$/);
+ assert.strictEqual((await post(th.id, { message: 'said by a service' }, { token: svcW, headers: { 'x-ov-subject': alex.subject_id } })).status, 201);
+ const bySvc = await call(`/api/v1/comments/threads/${th.id}`, { token: svcW });
+ assert.strictEqual(bySvc.status, 200, 'services still read by sequential id');
+ assert.strictEqual(bySvc.json().comments[0].thread_id, th.id);
+ assert.strictEqual((await call(`/api/v1/comments/threads/${th.access_id}`, { token: svcW })).status, 200, 'and by access id');
+
+ // Browsers walking ids 1..N find nothing, signed in or not, staff included.
+ const max = t.db.prepare('SELECT MAX(id) AS m FROM comment_threads').get().m;
+ for (let id = 1; id <= max; id++) {
+ for (const who of [{}, { cookie: samJwt }, { token: alexJwt }, { cookie: adminJwt }]) {
+ const r = await call(`/api/v1/comments/threads/${id}`, who);
+ assert.strictEqual(r.status, 404, `GET /threads/${id} as ${JSON.stringify(Object.keys(who))}`);
+ assert.strictEqual(r.json().code, 'thread.not_found');
+ }
+ }
+ assert.strictEqual((await post(th.id, { message: 'guessing' }, { cookie: samJwt })).status, 404);
+ assert.strictEqual((await post(th.id, { message: 'guessing' })).status, 404);
+ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}/visibility`, { method: 'PUT', json: { visibility: 'locked' }, cookie: adminJwt })).status, 404);
+ for (const bad of ['cth_short', `${th.access_id}x`, `cth_${'A'.repeat(22)}`, '0x1', ' 1']) {
+ assert.strictEqual((await call(`/api/v1/comments/threads/${encodeURIComponent(bad)}`)).status, 404, bad);
+ }
+
+ // ...but a browser handed the access id (by resolve, or by the product's page) reads and writes as before.
+ const byBrowser = await call(`/api/v1/comments/threads/${th.access_id}`, { cookie: samJwt });
+ assert.strictEqual(byBrowser.status, 200, byBrowser.text);
+ assert.strictEqual(byBrowser.json().thread.id, th.access_id);
+ assert.strictEqual(byBrowser.json().comments[0].thread_id, th.access_id, 'no sequential id reaches a browser');
+ assert.ok(!byBrowser.text.includes(`"thread_id":${th.id}`));
+ assert.strictEqual((await post(th.access_id, { message: 'with the handle' }, { cookie: samJwt })).status, 201);
+ const again = await resolve({ service: 'live', type: 'vod', id: '123' }, { cookie: samJwt });
+ assert.strictEqual(again.json().thread.id, vod.id, 'browser resolve hands out the access id');
+ assert.match(vod.id, /^cth_/);
+ const all = t.db.prepare('SELECT access_id FROM comment_threads').all().map((r) => r.access_id);
+ assert.ok(all.every(Boolean) && new Set(all).size === all.length, 'every thread has its own');
+ });
+
+ await check('threads made before access ids get one on boot (migration is idempotent)', async () => {
+ const fs = require('fs'), os = require('os'), path = require('path');
+ const Database = require('better-sqlite3');
+ const { openDb } = require('../server/db');
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'ov-community-mig-')), 'old.db');
+ const old = new Database(file);
+ old.exec(`CREATE TABLE comment_threads (id INTEGER PRIMARY KEY AUTOINCREMENT, ref_service TEXT NOT NULL, ref_type TEXT NOT NULL, ref_id TEXT NOT NULL, ref_label TEXT,
+ visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'hidden', 'locked')), comment_count INTEGER NOT NULL DEFAULT 0, created_by TEXT,
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (ref_service, ref_type, ref_id))`);
+ for (let i = 0; i < 5; i++) old.prepare("INSERT INTO comment_threads (ref_service, ref_type, ref_id) VALUES ('live', 'vod', ?)").run(String(i));
+ old.close();
+ const db = openDb(file);
+ const rows = db.prepare('SELECT id, access_id FROM comment_threads').all();
+ assert.strictEqual(rows.length, 5);
+ for (const r of rows) assert.match(r.access_id, /^cth_[A-Za-z0-9_-]{22}$/);
+ assert.strictEqual(new Set(rows.map((r) => r.access_id)).size, 5);
+ db.close();
+ const db2 = openDb(file);
+ assert.deepStrictEqual(db2.prepare('SELECT id, access_id FROM comment_threads').all(), rows, 'a second boot changes nothing');
+ assert.throws(() => db2.prepare('UPDATE comment_threads SET access_id = ? WHERE id = ?').run(rows[0].access_id, rows[1].id), /UNIQUE/);
+ db2.close();
+ fs.rmSync(path.dirname(file), { recursive: true, force: true });
+ });
+
await check('CORS: OpenVibe origins get Bearer-only CORS headers; others none; preflight answers 204', async () => {
await check('CORS: OpenVibe origins get Bearer-only CORS headers; others none; preflight answers 204', async () => {
const pre = await call(`/api/v1/comments/threads/${vod.id}/comments`, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST' } });
const pre = await call(`/api/v1/comments/threads/${vod.id}/comments`, { method: 'OPTIONS', headers: { origin: 'https://openvibe.live', 'access-control-request-method': 'POST' } });
assert.strictEqual(pre.status, 204);
assert.strictEqual(pre.status, 204);