openvibers/openvibe.community Commits
Private things stay private on every read path (roadmap WS-R task 5: private bypass), with four fixes the new test found. test/security-private.test.js seeds private text and screenshot pastes (and a comment on one), an unlisted paste, burn-after-read pastes, a members-only space and a members-only thread (VIP down: fail closed), a staff space, a members space, a hidden comment thread and a Live VOD's thread, each with a marker, then crawls every GET route from the router stack with their ids and a query string (search, include_unlisted, ?fork=) as anonymous, another user and a service acting for them (about 6000 requests): no marker may appear (a gated thread's title is listed by design), and every route must answer a private paste, a staff space or post, or a hidden comment thread exactly as an unknown one (status and error code or page title). On top: each read and read-like write of a private paste answers like a missing one and writes nothing, the owner still reads it; unlisted pastes open by link but are never listed or counted; hidden threads and a VOD thread's sequential id look missing to browsers; nothing private is a Pulse item, a search document or a public event. Extends members-only.test.js and security.test.js without repeating them.
Changes
@@@ -316,7 +316,11 @@ function createApp(opts = {}) {app.get('/p/:slug/screenshot', withUser, (req, res) => {if (!SLUG_RE.test(req.params.slug)) return notFound(res);try { res.redirect(302, app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }-- catch (err) { if (err.status === 404) return notFound(res); throw err; }++ catch (err) {++ if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');++ if (err.status === 404) return notFound(res);++ throw err;++ }});} else {// Raw text and screenshots are public on OpenVibe.Media — bounce there.@@@ -52,6 +52,24 @@ function getComment(db, id) {return db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;}++/**++ * Is the thread's item known to be public? Only Community's own can be checked: a public paste that is++ * not deleted and does not burn, or a live post in a public space outside members-only. Anything else (a++ * Live VOD or clip may be private; only Live knows) is not, so its comment events stay internal.++ */++function refIsPublic(db, t) {++ if (!t || t.ref_service !== 'community') return false;++ if (t.ref_type === 'paste') {++ return !!db.prepare("SELECT 1 FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0").get(String(t.ref_id));++ }++ if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) {++ return !!db.prepare(`SELECT 1 FROM posts p JOIN threads th ON th.id = p.thread_id JOIN spaces s ON s.id = th.space_id++ WHERE p.id = ? AND p.deleted_at IS NULL AND th.deleted_at IS NULL AND s.visibility = 'public'++ AND s.members_only_owner IS NULL AND th.members_only_owner IS NULL`).get(Number(t.ref_id));++ }++ return false;++}++/** Insert a comment and keep the thread's and parent's counters in step. */function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {return db.transaction(() => {@@@ -61,7 +79,7 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);const comment = getComment(db, info.lastInsertRowid);const cthread = db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);-- if (cthread) require('../events').commentCreated(comment, cthread); // community.comment.created++ if (cthread) require('../events').commentCreated(comment, cthread, { itemPublic: refIsPublic(db, cthread) }); // community.comment.createdreturn comment;})();}@@@ -99,12 +99,18 @@ function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });}--function commentCreated(comment, cthread) {++/**++ * Public only when the item the thread belongs to is known to be public (itemPublic: comments/store.js++ * refIsPublic): the thread's access id and its ref (an unlisted paste's slug, a private VOD's id) are++ * what keep a private item's comments private, and public events reach every realtime listener and app.++ */++function commentCreated(comment, cthread, { itemPublic = false } = {}) {const visibility = ['public', 'hidden', 'locked'].includes(cthread.visibility) ? cthread.visibility : 'public';++ const isPublic = visibility !== 'hidden' && !!itemPublic;return record('community.comment.created', { type: 'comment', id: String(comment.id) }, {comment_id: Number(comment.id), thread_access_id: String(cthread.access_id), ref: { service: cthread.ref_service, type: cthread.ref_type, id: String(cthread.ref_id) },-- author: subjectRef(comment.author_subject), visibility, url: visibility === 'hidden' ? null : `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}`,-- }, { isPublic: visibility !== 'hidden', actor: actorOf(comment.author_subject) });++ author: subjectRef(comment.author_subject), visibility, url: isPublic ? `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}` : null,++ }, { isPublic, actor: actorOf(comment.author_subject) });}/**@@@ -156,7 +156,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vifail(403, 'vip.members_only', `Only members of this creator's OpenVibe.VIP can read and post in this ${g.type}`, {reason: d.reason || 'denied', gate: g.type, members_only: mo,space: { slug: space.slug, name: space.name, description: space.description || null },-- thread: thread ? { slug: thread.slug, title: thread.title } : null,++ // A thread's title is shown only for its own gate: inside a members-only space it is not listed, and++ // post ids are sequential (GET /posts/:id/versions would name every thread in the space).++ thread: thread && g.type === 'thread' ? { slug: thread.slug, title: thread.title } : null,});}}@@@ -190,7 +192,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst thread = post ? store.getThread(db, post.thread_id) : null;const space = thread ? store.getSpaceById(db, thread.space_id) : null;if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');-- spaceFor(v, space.slug);++ // A post in a staff space looks exactly like a missing one (not space.not_found, which would confirm it).++ try { spaceFor(v, space.slug); } catch (err) { if (err.status === 404) fail(404, 'post.not_found', 'No such post'); throw err; }return { post, thread, space };}@@@ -117,8 +117,9 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'usconst p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)').run(threadId, author_subject, origin, body_markdown);const thread = getThread(db, threadId);-- const space = db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(space_id);-- require('../events').threadCreated(thread, space ? space.slug : String(space_id), space ? space.visibility : 'members'); // community.thread.created++ const space = db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(space_id);++ // A members-only (VIP) space's threads are not public either.++ require('../events').threadCreated(thread, space ? space.slug : String(space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.thread.createdreturn { thread, post: getPost(db, p.lastInsertRowid) };})();}@@@ -250,8 +251,8 @@ function addPost(db, { thread_id, author_subject = null, origin = 'user', body_mdb.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);const post = getPost(db, info.lastInsertRowid);const thread = getThread(db, thread_id);-- const space = thread && db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(thread.space_id);-- if (thread) require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space ? space.visibility : 'members'); // community.post.created++ const space = thread && db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(thread.space_id);++ if (thread) require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.post.createdreturn post;})();}@@@ -360,10 +360,14 @@ function createPasteService({ db, network = null, media = null, config = {}, limreturn { content: String(p.content || '') };},-- /** GET /p/:slug/screenshot target (the stored Media URL). */++ /** GET /p/:slug/screenshot target (the stored Media URL). For a burn-after-read screenshot it is a read (raw's rule). */screenshotUrl(v, slug) {const p = store.getBySlug(db, String(slug));if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');++ if (p.burn_after_read && !isOwner(v, p)) {++ if (p.views > 0) burn(p);++ store.bumpViews(db, p.id);++ }return p.screenshot_url;},nnew file mode 100644@@@ -0,0 +1,312 @@++'use strict';++/**++ * Private things stay private on every read path (roadmap WS-R task 5: private bypass). Extends++ * members-only.test.js (VIP gating rules) and security.test.js (burn-after-read, unlisted slugs) with a++ * crawl and the paths those do not cover.++ *++ * Seeded, each with a marker in its title/body: alex's private text and screenshot pastes (and a comment++ * on one), an unlisted paste, burn-after-read pastes, a members-only (VIP) space and a members-only thread++ * in a public space (VIP is down: fail closed), a staff space, a members (signed-in) space, a hidden++ * comment thread, and the comment thread of a (possibly private) Live VOD that only Live can open.++ *++ * - Every GET route Express knows after boot (router stack) is requested with those ids and with the++ * ids of things that do not exist, plus a query string (lists, search, ?fork=, include_unlisted), as++ * anonymous, another user, and a service acting for that user: no marker ever appears (a gated++ * thread's title is the documented exception: it is listed, never its posts), and every route++ * answers a private paste, a staff space, a hidden comment thread or a staff post exactly as it++ * answers an unknown one (same status and error code or page title).++ * - Explicitly: each read and read-like write of a private paste answers like a missing one (and the++ * owner still gets it); unlisted pastes open by link but are never listed; a burn-after-read++ * screenshot's link is the read; hidden threads and a VOD thread's sequential id look missing to++ * browsers; nothing private is a Pulse item, a search document, or a public event (comment events++ * are public only for items Community knows are public).++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { listRoutes, fill } = require('./helpers/routes');++++const M = {++ PRIVTITLE: 'PRIVTITLE-kestrel', PRIVBODY: 'PRIVBODY-kestrel', PRIVSHOT: 'PRIVSHOT-heron', PRIVCOMMENT: 'PRIVCOMMENT-wren',++ UNLTITLE: 'UNLTITLE-osprey', UNLBODY: 'UNLBODY-osprey', BURNTITLE: 'BURNTITLE-falcon', BURNBODY: 'BURNBODY-falcon',++ GATEDBODY: 'GATEDBODY-otter', GATEDREPLY: 'GATEDREPLY-otter', GATEDSPACETITLE: 'GATEDSPACETITLE-lynx', GATEDSPACEBODY: 'GATEDSPACEBODY-lynx',++ STAFFSPACE: 'STAFFSPACE-ibis', STAFFTITLE: 'STAFFTITLE-ibis', STAFFBODY: 'STAFFBODY-ibis',++ MEMBERSPACE: 'MEMBERSPACE-puffin', MEMBERTITLE: 'MEMBERTITLE-puffin', MEMBERBODY: 'MEMBERBODY-puffin',++ HIDDENCOMMENT: 'HIDDENCOMMENT-crane', VODCOMMENT: 'VODCOMMENT-swift',++};++const GATEDTITLE = 'GATEDTITLE-otter'; // listed by title, by design++const UNKNOWN_SLUG = 'calm-otter-AAAAAAAAAAAAAAAA';++const UNKNOWN_ACCESS = `cth_${'A'.repeat(22)}`;++const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');++++(async () => {++ const t = await boot({++ authority: 'community',++ // VIP, Search and Events are down (nothing listens on port 9): gates fail closed.++ env: { OV_VIP_INTERNAL_URL: 'http://127.0.0.1:9', OV_SEARCH_INTERNAL_URL: 'http://127.0.0.1:9' },++ pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 },++ appOpts: {++ startRelay: false,++ forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, votes: { perMinute: 1000 }, threadsPerDay: 1000 },++ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },++ },++ });++ const events = require('../server/events');++ events.init(t.db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'shh', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });++ const net = t.network;++ const mk = (id, username, role = 'user') => {++ const u = net.addUser({ network_user_id: id, username, display_name: username });++ return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };++ };++ const alex = mk(31, 'alex'), sam = mk(32, 'sam'), cora = mk(33, 'cora'), boss = mk(34, 'boss', 'global_mod');++ const svcFor = (who, cap = ['community.paste.write', 'community.comment.write', 'community.post.create']) => ({ token: net.signService({ sub: 'svc:tools', cap }), headers: { 'x-ov-subject': who.subject_id } });++ const liveSvc = { token: net.signService({ sub: 'svc:live', cap: ['community.comment.write', 'community.comment.moderate'] }) };++++ let ipN = 0;++ async function send(method, path, { who, token, headers = {}, json, body } = {}) {++ const h = { 'x-forwarded-for': `198.51.100.${(ipN++ % 250) + 1}`, ...headers };++ if (who) h.cookie = `ov_token=${who.jwt}`;++ if (token) h.authorization = `Bearer ${token}`;++ let payload = body;++ if (json !== undefined) { h['content-type'] = 'application/json'; payload = JSON.stringify(json); }++ const res = await fetch(t.base + path, { method, headers: h, body: payload, redirect: 'manual' });++ return { status: res.status, headers: res.headers, text: await res.text(), json() { return JSON.parse(this.text); } };++ }++ const ok = (r, what) => { assert.ok(r.status === 200 || r.status === 201, `${what}: ${r.status} ${r.text.slice(0, 300)}`); return r.json(); };++ const paste = async (who, fields) => ok(await send('POST', '/api/pastes', { who, json: fields }), `paste ${fields.title}`);++ const shotPaste = async (who, fields) => {++ const f = new FormData();++ for (const [k, v] of Object.entries(fields)) f.append(k, String(v));++ f.append('screenshot', new Blob([PNG], { type: 'image/png' }), 'shot.png');++ return ok(await send('POST', '/api/pastes/screenshot', { who, body: f }), `screenshot ${fields.title}`);++ };++++ // ── seed ──++ const pub = await paste(alex, { title: 'Public paste', content: 'public words zebra' });++ const priv = await paste(alex, { title: M.PRIVTITLE, content: `${M.PRIVBODY} zebra`, visibility: 'private' });++ const privShot = await shotPaste(alex, { title: M.PRIVSHOT, description: M.PRIVSHOT, visibility: 'private' });++ const unl = await paste(alex, { title: M.UNLTITLE, content: `${M.UNLBODY} zebra`, visibility: 'unlisted' });++ const burn = await paste(alex, { title: M.BURNTITLE, content: `${M.BURNBODY} zebra`, burn_after_read: true });++ const burnShot = await shotPaste(alex, { title: 'burning screenshot', burn_after_read: 'true' });++ const privComment = ok(await send('POST', `/api/pastes/${priv.slug}/comments`, { who: alex, json: { message: M.PRIVCOMMENT } }), 'private paste comment');++++ ok(await send('PUT', '/api/v1/spaces/showcase/members-only', { who: boss, json: { owner: cora.subject_id } }), 'gate showcase');++ const gatedSpaceThread = ok(await send('POST', '/api/v1/spaces/showcase/threads', { who: cora, json: { title: M.GATEDSPACETITLE, body: M.GATEDSPACEBODY } }), 'thread in the gated space');++ const gated = ok(await send('POST', '/api/v1/spaces/general/threads', { who: cora, json: { title: GATEDTITLE, body: `${M.GATEDBODY} zebra`, members_only: true } }), 'gated thread');++ const gatedReply = ok(await send('POST', `/api/v1/spaces/general/threads/${gated.thread.slug}/posts`, { who: cora, json: { body: M.GATEDREPLY } }), 'gated reply');++ ok(await send('POST', '/api/v1/spaces', { who: boss, json: { slug: 'mods-room', name: M.STAFFSPACE, description: M.STAFFSPACE, visibility: 'staff' } }), 'staff space');++ const staff = ok(await send('POST', '/api/v1/spaces/mods-room/threads', { who: boss, json: { title: M.STAFFTITLE, body: M.STAFFBODY } }), 'staff thread');++ ok(await send('POST', '/api/v1/spaces', { who: boss, json: { slug: 'members-lounge', name: M.MEMBERSPACE, description: M.MEMBERSPACE, visibility: 'members' } }), 'members space');++ const member = ok(await send('POST', '/api/v1/spaces/members-lounge/threads', { who: alex, json: { title: M.MEMBERTITLE, body: M.MEMBERBODY } }), 'members thread');++ const publicThread = ok(await send('POST', '/api/v1/spaces/general/threads', { who: alex, json: { title: 'Open thread', body: 'open words zebra' } }), 'public thread');++++ const hidden = ok(await send('POST', '/api/v1/comments/threads/resolve', { ...liveSvc, json: { ref: { service: 'live', type: 'vod', id: '501' } } }), 'hidden thread').thread;++ const hiddenComment = ok(await send('POST', `/api/v1/comments/threads/${hidden.id}/comments`, { ...liveSvc, headers: { 'x-ov-subject': alex.subject_id }, json: { message: M.HIDDENCOMMENT } }), 'hidden comment').comment;++ ok(await send('PUT', `/api/v1/comments/threads/${hidden.access_id}/visibility`, { who: boss, json: { visibility: 'hidden' } }), 'hide the thread');++ const vod = ok(await send('POST', '/api/v1/comments/threads/resolve', { ...liveSvc, json: { ref: { service: 'live', type: 'vod', id: '502' } } }), 'vod thread').thread;++ const vodComment = ok(await send('POST', `/api/v1/comments/threads/${vod.id}/comments`, { ...liveSvc, headers: { 'x-ov-subject': alex.subject_id }, json: { message: M.VODCOMMENT } }), 'vod comment').comment;++ const unlThread = ok(await send('POST', '/api/v1/comments/threads/resolve', { who: alex, json: { ref: { service: 'community', type: 'paste', id: unl.slug } } }), 'unlisted paste thread').thread;++ ok(await send('POST', `/api/v1/comments/threads/${unlThread.id}/comments`, { who: alex, json: { message: 'on the unlisted paste' } }), 'unlisted paste comment');++ const pubThread = ok(await send('POST', '/api/v1/comments/threads/resolve', { who: alex, json: { ref: { service: 'community', type: 'paste', id: pub.slug } } }), 'public paste thread').thread;++ ok(await send('POST', `/api/v1/comments/threads/${pubThread.id}/comments`, { who: alex, json: { message: 'on the public paste' } }), 'public paste comment');++ require('../server/search/documents').createSearchDocuments({ db: t.db }).scan();++++ const outsiders = { anonymous: {}, sam: { who: sam }, 'a service for sam': svcFor(sam) };++ const allMarkers = Object.values(M);++ // Signed-in people are members of a members space (every account, for now); first-party services read threads by their sequential id.++ const forbiddenFor = {++ anonymous: allMarkers,++ sam: allMarkers.filter((m) => !m.startsWith('MEMBER')),++ 'a service for sam': allMarkers.filter((m) => !m.startsWith('MEMBER') && m !== M.VODCOMMENT),++ };++++ await check('private pastes: every read path answers an outsider exactly as it answers a paste that does not exist; the owner still reads them', async () => {++ const reads = (slug) => [`/api/pastes/${slug}`, `/api/pastes/${slug}?no_view=1`, `/p/${slug}`, `/p/${slug}/raw`, `/p/${slug}/download`, `/p/${slug}/screenshot`,++ `/api/pastes/${slug}/comments`, `/api/pastes/${slug}/versions`, `/api/pastes/${slug}/raw`, `/pastes/${slug}`];++ for (const [name, p] of Object.entries(outsiders)) {++ for (const slug of [priv.slug, privShot.slug]) {++ const paths = reads(slug), unknown = reads(UNKNOWN_SLUG);++ for (let i = 0; i < paths.length; i++) {++ const a = await send('GET', paths[i], p), b = await send('GET', unknown[i], p);++ assert.strictEqual(a.status, b.status, `${paths[i]} as ${name}: ${a.status} vs ${b.status} for a missing paste`);++ for (const m of allMarkers) assert.ok(!a.text.includes(m), `${paths[i]} as ${name} shows ${m}`);++ }++ }++ // Read-like writes: like, copy, fork, comment, delete a comment — 404 like a missing paste, nothing written.++ const before = JSON.stringify(t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get().n;++ for (const [method, suffix, json] of [['POST', '/like'], ['POST', '/copy'], ['POST', '/fork', {}], ['POST', '/comments', { message: 'hi there' }], ['DELETE', `/comments/${privComment.comment.id}`]]) {++ const a = await send(method, `/api/pastes/${priv.slug}${suffix}`, { ...p, json }), b = await send(method, `/api/pastes/${UNKNOWN_SLUG}${suffix}`, { ...p, json });++ assert.strictEqual(a.status, b.status, `${method} ${suffix} as ${name}: ${a.status} vs ${b.status}`);++ assert.ok(!allMarkers.some((m) => a.text.includes(m)), `${method} ${suffix} as ${name} shows a marker`);++ }++ const after = JSON.stringify(t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get().n;++ assert.strictEqual(after, before, `a refused write changed something (${name})`);++ // A browser cannot open a comment thread on it either (it looks missing).++ if (p.who) {++ const r = await send('POST', '/api/v1/comments/threads/resolve', { ...p, json: { ref: { service: 'community', type: 'paste', id: priv.slug } } });++ const u = await send('POST', '/api/v1/comments/threads/resolve', { ...p, json: { ref: { service: 'community', type: 'paste', id: UNKNOWN_SLUG } } });++ assert.deepStrictEqual([r.status, r.json().code], [u.status, u.json().code]);++ }++ }++ // Positive controls: the owner reads everything.++ assert.strictEqual((await send('GET', `/api/pastes/${priv.slug}`, { who: alex })).json().paste.content, `${M.PRIVBODY} zebra`);++ assert.strictEqual((await send('GET', `/p/${priv.slug}/raw`, { who: alex })).text, `${M.PRIVBODY} zebra`);++ assert.ok((await send('GET', `/p/${priv.slug}`, { who: alex })).text.includes(M.PRIVTITLE));++ assert.strictEqual((await send('GET', `/p/${privShot.slug}/screenshot`, { who: alex })).status, 302);++ assert.ok((await send('GET', `/api/pastes/${priv.slug}/comments`, { who: alex })).text.includes(M.PRIVCOMMENT));++ assert.strictEqual((await send('GET', `/api/pastes/${priv.slug}`, svcFor(alex))).status, 200, 'a service acting for the owner');++ });++++ await check('unlisted pastes open by their link but are never listed: lists, user lists, counts, search (also include_unlisted from someone else)', async () => {++ for (const [name, p] of Object.entries(outsiders)) {++ assert.strictEqual((await send('GET', `/api/pastes/${unl.slug}`, p)).status, 200, `the link works (${name})`);++ for (const path of ['/api/pastes?limit=200', '/api/pastes?search=zebra', '/api/pastes?username=alex&include_unlisted=1', '/api/pastes/by-user/alex?limit=100', '/pastes?q=zebra', '/']) {++ const r = await send('GET', path, p);++ for (const m of [M.UNLTITLE, M.UNLBODY, M.PRIVTITLE, M.BURNTITLE, M.BURNBODY]) assert.ok(!r.text.includes(m), `${path} as ${name} shows ${m}`);++ for (const s of [unl.slug, priv.slug, privShot.slug, burn.slug, burnShot.slug]) assert.ok(!r.text.includes(s), `${path} as ${name} lists a hidden slug`);++ }++ const mine = (await send('GET', '/api/pastes/by-user/alex?limit=100', p)).json();++ assert.strictEqual(mine.total, mine.pastes.length, `the count is of what is listed (${name})`);++ }++ const own = (await send('GET', '/api/pastes/by-user/alex?limit=100', { who: alex })).json().pastes.map((x) => x.slug);++ for (const s of [unl.slug, priv.slug]) assert.ok(own.includes(s), 'the owner sees their own');++ });++++ await check('burn-after-read screenshots: the screenshot link is the read (a second read of any kind finds it burned)', async () => {++ const first = await send('GET', `/p/${burnShot.slug}/screenshot`, { who: sam });++ assert.strictEqual(first.status, 302, first.text);++ const again = await send('GET', `/p/${burnShot.slug}/screenshot`, { who: sam });++ assert.ok(again.status === 404 || again.status === 410, `the screenshot was served again: ${again.status}`);++ assert.ok([404, 410].includes((await send('GET', `/api/pastes/${burnShot.slug}`)).status), 'the paste burned');++ // The owner's own looks do not burn it.++ const mineToo = await shotPaste(alex, { title: 'mine', burn_after_read: 'true' });++ for (let i = 0; i < 3; i++) assert.strictEqual((await send('GET', `/p/${mineToo.slug}/screenshot`, { who: alex })).status, 302);++ assert.strictEqual((await send('GET', `/api/pastes/${mineToo.slug}`, { who: sam })).status, 200, 'still unread');++ });++++ await check('comment threads: hidden ones and a VOD thread\'s sequential id look missing to browsers; moderators and Live still reach them', async () => {++ for (const [name, p] of Object.entries(outsiders)) {++ const pairs = [[`/api/v1/comments/threads/${hidden.access_id}`, `/api/v1/comments/threads/${UNKNOWN_ACCESS}`], [`/c/${hidden.access_id}`, `/c/${UNKNOWN_ACCESS}`],++ [`/api/v1/comments/${hiddenComment.id}`, '/api/v1/comments/999999'], [`/api/v1/comments/threads/${hidden.access_id}/comments`, `/api/v1/comments/threads/${UNKNOWN_ACCESS}/comments`, 'POST'],++ [`/api/v1/comments/${hiddenComment.id}/votes`, '/api/v1/comments/999999/votes', 'POST']];++ if (p.who || name === 'anonymous') pairs.push([`/api/v1/comments/threads/${vod.id}`, '/api/v1/comments/threads/999999'], [`/api/v1/comments/${vodComment.id}`, '/api/v1/comments/999998']);++ for (const [a, b, method = 'GET'] of pairs) {++ const json = method === 'POST' ? { message: 'x', value: 1 } : undefined;++ const ra = await send(method, a, { ...p, json }), rb = await send(method, b, { ...p, json });++ assert.strictEqual(ra.status, rb.status, `${method} ${a} as ${name}: ${ra.status} vs ${rb.status}`);++ assert.ok(!ra.text.includes(M.HIDDENCOMMENT) && !(p.who && ra.text.includes(M.VODCOMMENT)), `${a} as ${name} shows a comment`);++ }++ // Browsers cannot open a VOD's thread themselves (only Live knows who may see the VOD).++ if (p.who) assert.strictEqual((await send('POST', '/api/v1/comments/threads/resolve', { ...p, json: { ref: { service: 'live', type: 'vod', id: '502' } } })).status, 403);++ }++ assert.ok((await send('GET', `/api/v1/comments/threads/${hidden.access_id}`, { who: boss })).text.includes(M.HIDDENCOMMENT), 'moderators read hidden threads');++ assert.ok((await send('GET', `/api/v1/comments/threads/${vod.id}`, liveSvc)).text.includes(M.VODCOMMENT), 'Live reads by the sequential id');++ assert.ok((await send('GET', `/api/v1/comments/threads/${vod.access_id}`, { who: sam })).text.includes(M.VODCOMMENT), 'whoever Live gave the access id reads it');++ });++++ await check('events, Pulse and search documents: nothing private is public; comment events are public only for items Community knows are public', async () => {++ const outbox = t.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));++ const text = JSON.stringify(outbox);++ for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!text.includes(m), `an event carries ${m}`);++ const pub = (e) => e.visibility === 'public';++ const pasteEvent = (slug) => outbox.find((e) => e.event_type === 'community.paste.created' && e.payload.paste_id === slug);++ for (const s of [priv.slug, privShot.slug, unl.slug]) {++ assert.ok(!pub(pasteEvent(s)), `the paste ${s} is a public event`);++ assert.strictEqual(pasteEvent(s).payload.url, null);++ }++ for (const id of [gated.thread.id, gatedSpaceThread.thread.id, staff.thread.id, member.thread.id]) {++ const e = outbox.find((x) => x.event_type === 'community.thread.created' && x.payload.thread_id === id);++ assert.ok(e && !pub(e) && e.payload.url === null, `thread ${id} is a public event`);++ }++ assert.ok(!pub(outbox.find((x) => x.event_type === 'community.post.created' && x.payload.post_id === gatedReply.post.id)));++ const commentEvent = (accessId) => outbox.filter((e) => e.event_type === 'community.comment.created' && e.payload.thread_access_id === accessId);++ for (const [what, th] of [['a Live VOD (maybe private)', vod], ['a hidden thread', hidden], ['an unlisted paste', unlThread]]) {++ const evs = commentEvent(th.access_id);++ assert.ok(evs.length, `no event for the comment on ${what}`);++ for (const e of evs) {++ assert.strictEqual(e.visibility, 'internal', `the comment on ${what} is a public event: its thread's access id and ref (${JSON.stringify(e.payload.ref)}) reach every realtime listener`);++ assert.strictEqual(e.payload.url, null);++ }++ }++ const open = commentEvent(pubThread.id);++ assert.ok(open.length && open.every(pub) && open[0].payload.url.endsWith(`/c/${pubThread.id}`), 'a comment on a public paste stays a public event');++ const docs = JSON.stringify(outbox.filter((e) => /^community\.index_document\./.test(e.event_type)));++ assert.ok(docs.includes(pub.slug) || docs.includes('Open thread'), 'search documents were built');++ for (const s of [priv.slug, privShot.slug, unl.slug, burn.slug, burnShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug]) assert.ok(!docs.includes(s), `${s} is a search document`);++ const pulse = JSON.stringify(t.db.prepare('SELECT * FROM pulse_items').all());++ assert.ok(pulse.includes('Open thread'), 'Pulse took the public thread');++ for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!pulse.includes(m), `Pulse holds ${m}`);++ });++++ // ── the crawl ──++ const routes = listRoutes(t.app).filter((r) => r.method === 'GET' || r.method === 'ALL');++ const values = {++ slug: [priv.slug, privShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug, publicThread.thread.slug, UNKNOWN_SLUG],++ space: ['general', 'showcase', 'mods-room', 'members-lounge', 'no-such-space'],++ username: ['alex', 'cora', 'boss'],++ id: [String(gatedReply.post.id), String(staff.post.id), String(member.post.id), String(gatedSpaceThread.post.id), hidden.access_id, String(hidden.id), String(vod.id), '999999'],++ accessId: [hidden.access_id, UNKNOWN_ACCESS],++ commentId: [String(privComment.comment.id), String(hiddenComment.id), String(vodComment.id), '999999'],++ category: ['no-such-category'], group: ['no-such-group'], service: ['community'], type: ['paste'], '*': ['x'],++ };++ const QUERY = `?limit=200&sort=new&q=zebra&search=zebra&type=paste&username=alex&include_unlisted=1&fork=${priv.slug}&page=1`;++++ await check(`every GET route (${routes.length}, from the router stack) with the private ids, as anonymous, another user and a service for them: no private title or body anywhere`, async () => {++ assert.ok(routes.length >= 50, 'the router walk is broken');++ const problems = [];++ let n = 0;++ for (const r of routes) {++ for (const path of fill(r.path, values, 80)) {++ for (const [name, p] of Object.entries(outsiders)) {++ for (const q of ['', QUERY]) {++ const res = await send('GET', path + q, p);++ n++;++ const found = forbiddenFor[name].filter((m) => res.text.includes(m) || String(res.headers.get('location') || '').includes(m));++ if (found.length) problems.push(`GET ${path}${q ? '?…' : ''} as ${name} (${res.status}): ${found.join(', ')}`);++ }++ }++ }++ }++ assert.ok(n > 1000, `only ${n} requests`);++ if (problems.length) console.log(` ${problems.slice(0, 60).join('\n ')}`);++ assert.deepStrictEqual(problems, []);++ });++++ await check('every GET route answers a private paste, a staff space, a staff post and a hidden comment thread exactly as an unknown one', async () => {++ const sig = (res) => {++ let code = '';++ try { const j = JSON.parse(res.text); code = j.code || j.error || ''; } catch { const m = /<title>([^<]*)<\/title>/.exec(res.text); code = m ? m[1] : ''; }++ return `${res.status} ${code}`.trim();++ };++ // [param values for the private thing, the same for a missing one]++ const families = [++ { when: (p) => /^\/(api\/pastes|p|pastes)\/:slug/.test(p), hidden: [{ slug: priv.slug }, { slug: privShot.slug }], missing: { slug: UNKNOWN_SLUG } },++ { when: (p) => p.includes(':space'), hidden: [{ space: 'mods-room', slug: staff.thread.slug }], missing: { space: 'no-such-space', slug: staff.thread.slug } },++ { when: (p) => p.includes(':space') && p.includes(':slug'), hidden: [{ space: 'general', slug: staff.thread.slug }], missing: { space: 'general', slug: 'no-such-thread' } },++ { when: (p) => /^\/api\/v1\/posts\/:id/.test(p), hidden: [{ id: String(staff.post.id) }], missing: { id: '999999' } },++ { when: (p) => p.includes(':accessId') || /\/comments\/threads\/:id/.test(p), hidden: [{ accessId: hidden.access_id, id: hidden.access_id }], missing: { accessId: UNKNOWN_ACCESS, id: UNKNOWN_ACCESS } },++ { when: (p) => /^\/api\/v1\/comments\/:commentId/.test(p), hidden: [{ commentId: String(hiddenComment.id) }], missing: { commentId: '999999' } },++ ];++ const problems = [];++ let compared = 0;++ for (const r of routes) {++ for (const fam of families) {++ if (!fam.when(r.path)) continue;++ for (const h of fam.hidden) {++ const a = fill(r.path, { ...values, ...Object.fromEntries(Object.entries(h).map(([k, v]) => [k, [v]])), commentId: ['999999'] }, 1)[0];++ const b = fill(r.path, { ...values, ...Object.fromEntries(Object.entries(fam.missing).map(([k, v]) => [k, [v]])), commentId: ['999999'] }, 1)[0];++ for (const [name, p] of Object.entries(outsiders)) {++ const ra = await send('GET', a, p), rb = await send('GET', b, p);++ compared++;++ if (sig(ra) !== sig(rb)) problems.push(`GET ${a} as ${name}: ${sig(ra)} vs ${sig(rb)} for ${b}`);++ }++ }++ }++ }++ assert.ok(compared > 50, `only ${compared} comparisons`);++ if (problems.length) console.log(` ${problems.slice(0, 60).join('\n ')}`);++ assert.deepStrictEqual(problems, []);++ });++++ await t.close();++ done();++})();
@@ -316,7 +316,11 @@ function createApp(opts = {}) {
@@ -316,7 +316,11 @@ function createApp(opts = {}) {
app.get('/p/:slug/screenshot', withUser, (req, res) => {
app.get('/p/:slug/screenshot', withUser, (req, res) => {
if (!SLUG_RE.test(req.params.slug)) return notFound(res);
if (!SLUG_RE.test(req.params.slug)) return notFound(res);
try { res.redirect(302, app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }
try { res.redirect(302, app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }
- catch (err) { if (err.status === 404) return notFound(res); throw err; }
+ catch (err) {
+ if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
+ if (err.status === 404) return notFound(res);
+ throw err;
+ }
});
});
} else {
} else {
// Raw text and screenshots are public on OpenVibe.Media — bounce there.
// Raw text and screenshots are public on OpenVibe.Media — bounce there.
@@ -52,6 +52,24 @@ function getComment(db, id) {
@@ -52,6 +52,24 @@ function getComment(db, id) {
return db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;
return db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;
}
}
+/**
+ * Is the thread's item known to be public? Only Community's own can be checked: a public paste that is
+ * not deleted and does not burn, or a live post in a public space outside members-only. Anything else (a
+ * Live VOD or clip may be private; only Live knows) is not, so its comment events stay internal.
+ */
+function refIsPublic(db, t) {
+ if (!t || t.ref_service !== 'community') return false;
+ if (t.ref_type === 'paste') {
+ return !!db.prepare("SELECT 1 FROM pastes WHERE slug = ? AND deleted_at IS NULL AND visibility = 'public' AND burn_after_read = 0").get(String(t.ref_id));
+ }
+ if (t.ref_type === 'post' && /^\d{1,15}$/.test(String(t.ref_id))) {
+ return !!db.prepare(`SELECT 1 FROM posts p JOIN threads th ON th.id = p.thread_id JOIN spaces s ON s.id = th.space_id
+ WHERE p.id = ? AND p.deleted_at IS NULL AND th.deleted_at IS NULL AND s.visibility = 'public'
+ AND s.members_only_owner IS NULL AND th.members_only_owner IS NULL`).get(Number(t.ref_id));
+ }
+ return false;
+}
+
/** Insert a comment and keep the thread's and parent's counters in step. */
/** Insert a comment and keep the thread's and parent's counters in step. */
function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {
function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {
return db.transaction(() => {
return db.transaction(() => {
@@ -61,7 +79,7 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,
@@ -61,7 +79,7 @@ function insertComment(db, { thread_id, parent_id = null, author_subject = null,
if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
if (parent_id) db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
const comment = getComment(db, info.lastInsertRowid);
const comment = getComment(db, info.lastInsertRowid);
const cthread = db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);
const cthread = db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);
- if (cthread) require('../events').commentCreated(comment, cthread); // community.comment.created
+ if (cthread) require('../events').commentCreated(comment, cthread, { itemPublic: refIsPublic(db, cthread) }); // community.comment.created
return comment;
return comment;
})();
})();
}
}
@@ -99,12 +99,18 @@ function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
@@ -99,12 +99,18 @@ function postCreated(post, thread, spaceSlug, spaceVisibility = 'public') {
url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
url: visibility === 'public' ? `${config.baseUrl}/s/${encodeURIComponent(spaceSlug)}/t/${encodeURIComponent(thread.slug)}#p${post.id}` : null,
}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
}, { isPublic: visibility === 'public', actor: actorOf(post.author_subject) });
}
}
-function commentCreated(comment, cthread) {
+/**
+ * Public only when the item the thread belongs to is known to be public (itemPublic: comments/store.js
+ * refIsPublic): the thread's access id and its ref (an unlisted paste's slug, a private VOD's id) are
+ * what keep a private item's comments private, and public events reach every realtime listener and app.
+ */
+function commentCreated(comment, cthread, { itemPublic = false } = {}) {
const visibility = ['public', 'hidden', 'locked'].includes(cthread.visibility) ? cthread.visibility : 'public';
const visibility = ['public', 'hidden', 'locked'].includes(cthread.visibility) ? cthread.visibility : 'public';
+ const isPublic = visibility !== 'hidden' && !!itemPublic;
return record('community.comment.created', { type: 'comment', id: String(comment.id) }, {
return record('community.comment.created', { type: 'comment', id: String(comment.id) }, {
comment_id: Number(comment.id), thread_access_id: String(cthread.access_id), ref: { service: cthread.ref_service, type: cthread.ref_type, id: String(cthread.ref_id) },
comment_id: Number(comment.id), thread_access_id: String(cthread.access_id), ref: { service: cthread.ref_service, type: cthread.ref_type, id: String(cthread.ref_id) },
- author: subjectRef(comment.author_subject), visibility, url: visibility === 'hidden' ? null : `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}`,
- }, { isPublic: visibility !== 'hidden', actor: actorOf(comment.author_subject) });
+ author: subjectRef(comment.author_subject), visibility, url: isPublic ? `${config.baseUrl}/c/${encodeURIComponent(cthread.access_id)}` : null,
+ }, { isPublic, actor: actorOf(comment.author_subject) });
}
}
/**
/**
@@ -156,7 +156,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -156,7 +156,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
fail(403, 'vip.members_only', `Only members of this creator's OpenVibe.VIP can read and post in this ${g.type}`, {
fail(403, 'vip.members_only', `Only members of this creator's OpenVibe.VIP can read and post in this ${g.type}`, {
reason: d.reason || 'denied', gate: g.type, members_only: mo,
reason: d.reason || 'denied', gate: g.type, members_only: mo,
space: { slug: space.slug, name: space.name, description: space.description || null },
space: { slug: space.slug, name: space.name, description: space.description || null },
- thread: thread ? { slug: thread.slug, title: thread.title } : null,
+ // A thread's title is shown only for its own gate: inside a members-only space it is not listed, and
+ // post ids are sequential (GET /posts/:id/versions would name every thread in the space).
+ thread: thread && g.type === 'thread' ? { slug: thread.slug, title: thread.title } : null,
});
});
}
}
}
}
@@ -190,7 +192,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -190,7 +192,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const thread = post ? store.getThread(db, post.thread_id) : null;
const thread = post ? store.getThread(db, post.thread_id) : null;
const space = thread ? store.getSpaceById(db, thread.space_id) : null;
const space = thread ? store.getSpaceById(db, thread.space_id) : null;
if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');
if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');
- spaceFor(v, space.slug);
+ // A post in a staff space looks exactly like a missing one (not space.not_found, which would confirm it).
+ try { spaceFor(v, space.slug); } catch (err) { if (err.status === 404) fail(404, 'post.not_found', 'No such post'); throw err; }
return { post, thread, space };
return { post, thread, space };
}
}
@@ -117,8 +117,9 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us
@@ -117,8 +117,9 @@ function createThread(db, { space_id, title, author_subject = null, origin = 'us
const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
const p = db.prepare('INSERT INTO posts (thread_id, author_subject, origin, is_opening, body_markdown) VALUES (?, ?, ?, 1, ?)')
.run(threadId, author_subject, origin, body_markdown);
.run(threadId, author_subject, origin, body_markdown);
const thread = getThread(db, threadId);
const thread = getThread(db, threadId);
- const space = db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(space_id);
- require('../events').threadCreated(thread, space ? space.slug : String(space_id), space ? space.visibility : 'members'); // community.thread.created
+ const space = db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(space_id);
+ // A members-only (VIP) space's threads are not public either.
+ require('../events').threadCreated(thread, space ? space.slug : String(space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.thread.created
return { thread, post: getPost(db, p.lastInsertRowid) };
return { thread, post: getPost(db, p.lastInsertRowid) };
})();
})();
}
}
@@ -250,8 +251,8 @@ function addPost(db, { thread_id, author_subject = null, origin = 'user', body_m
@@ -250,8 +251,8 @@ function addPost(db, { thread_id, author_subject = null, origin = 'user', body_m
db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
db.prepare('UPDATE threads SET reply_count = reply_count + 1, last_activity_at = CURRENT_TIMESTAMP WHERE id = ?').run(thread_id);
const post = getPost(db, info.lastInsertRowid);
const post = getPost(db, info.lastInsertRowid);
const thread = getThread(db, thread_id);
const thread = getThread(db, thread_id);
- const space = thread && db.prepare('SELECT slug, visibility FROM spaces WHERE id = ?').get(thread.space_id);
- if (thread) require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space ? space.visibility : 'members'); // community.post.created
+ const space = thread && db.prepare('SELECT slug, visibility, members_only_owner FROM spaces WHERE id = ?').get(thread.space_id);
+ if (thread) require('../events').postCreated(post, thread, space ? space.slug : String(thread.space_id), space && !space.members_only_owner ? space.visibility : 'members'); // community.post.created
return post;
return post;
})();
})();
}
}
@@ -360,10 +360,14 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -360,10 +360,14 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { content: String(p.content || '') };
return { content: String(p.content || '') };
},
},
- /** GET /p/:slug/screenshot target (the stored Media URL). */
+ /** GET /p/:slug/screenshot target (the stored Media URL). For a burn-after-read screenshot it is a read (raw's rule). */
screenshotUrl(v, slug) {
screenshotUrl(v, slug) {
const p = store.getBySlug(db, String(slug));
const p = store.getBySlug(db, String(slug));
if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
+ if (p.burn_after_read && !isOwner(v, p)) {
+ if (p.views > 0) burn(p);
+ store.bumpViews(db, p.id);
+ }
return p.screenshot_url;
return p.screenshot_url;
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,312 @@
@@ -0,0 +1,312 @@
+'use strict';
+/**
+ * Private things stay private on every read path (roadmap WS-R task 5: private bypass). Extends
+ * members-only.test.js (VIP gating rules) and security.test.js (burn-after-read, unlisted slugs) with a
+ * crawl and the paths those do not cover.
+ *
+ * Seeded, each with a marker in its title/body: alex's private text and screenshot pastes (and a comment
+ * on one), an unlisted paste, burn-after-read pastes, a members-only (VIP) space and a members-only thread
+ * in a public space (VIP is down: fail closed), a staff space, a members (signed-in) space, a hidden
+ * comment thread, and the comment thread of a (possibly private) Live VOD that only Live can open.
+ *
+ * - Every GET route Express knows after boot (router stack) is requested with those ids and with the
+ * ids of things that do not exist, plus a query string (lists, search, ?fork=, include_unlisted), as
+ * anonymous, another user, and a service acting for that user: no marker ever appears (a gated
+ * thread's title is the documented exception: it is listed, never its posts), and every route
+ * answers a private paste, a staff space, a hidden comment thread or a staff post exactly as it
+ * answers an unknown one (same status and error code or page title).
+ * - Explicitly: each read and read-like write of a private paste answers like a missing one (and the
+ * owner still gets it); unlisted pastes open by link but are never listed; a burn-after-read
+ * screenshot's link is the read; hidden threads and a VOD thread's sequential id look missing to
+ * browsers; nothing private is a Pulse item, a search document, or a public event (comment events
+ * are public only for items Community knows are public).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { listRoutes, fill } = require('./helpers/routes');
+
+const M = {
+ PRIVTITLE: 'PRIVTITLE-kestrel', PRIVBODY: 'PRIVBODY-kestrel', PRIVSHOT: 'PRIVSHOT-heron', PRIVCOMMENT: 'PRIVCOMMENT-wren',
+ UNLTITLE: 'UNLTITLE-osprey', UNLBODY: 'UNLBODY-osprey', BURNTITLE: 'BURNTITLE-falcon', BURNBODY: 'BURNBODY-falcon',
+ GATEDBODY: 'GATEDBODY-otter', GATEDREPLY: 'GATEDREPLY-otter', GATEDSPACETITLE: 'GATEDSPACETITLE-lynx', GATEDSPACEBODY: 'GATEDSPACEBODY-lynx',
+ STAFFSPACE: 'STAFFSPACE-ibis', STAFFTITLE: 'STAFFTITLE-ibis', STAFFBODY: 'STAFFBODY-ibis',
+ MEMBERSPACE: 'MEMBERSPACE-puffin', MEMBERTITLE: 'MEMBERTITLE-puffin', MEMBERBODY: 'MEMBERBODY-puffin',
+ HIDDENCOMMENT: 'HIDDENCOMMENT-crane', VODCOMMENT: 'VODCOMMENT-swift',
+};
+const GATEDTITLE = 'GATEDTITLE-otter'; // listed by title, by design
+const UNKNOWN_SLUG = 'calm-otter-AAAAAAAAAAAAAAAA';
+const UNKNOWN_ACCESS = `cth_${'A'.repeat(22)}`;
+const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
+
+(async () => {
+ const t = await boot({
+ authority: 'community',
+ // VIP, Search and Events are down (nothing listens on port 9): gates fail closed.
+ env: { OV_VIP_INTERNAL_URL: 'http://127.0.0.1:9', OV_SEARCH_INTERNAL_URL: 'http://127.0.0.1:9' },
+ pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 },
+ appOpts: {
+ startRelay: false,
+ forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, votes: { perMinute: 1000 }, threadsPerDay: 1000 },
+ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },
+ },
+ });
+ const events = require('../server/events');
+ events.init(t.db, { eventsUrl: 'http://127.0.0.1:9', clientSecret: 'shh', intervalMs: 3_600_000, fetchImpl: async () => { throw new Error('offline'); } });
+ const net = t.network;
+ const mk = (id, username, role = 'user') => {
+ const u = net.addUser({ network_user_id: id, username, display_name: username });
+ return { ...u, jwt: net.sign({ id, subject_id: u.subject_id, username, display_name: username, role }) };
+ };
+ const alex = mk(31, 'alex'), sam = mk(32, 'sam'), cora = mk(33, 'cora'), boss = mk(34, 'boss', 'global_mod');
+ const svcFor = (who, cap = ['community.paste.write', 'community.comment.write', 'community.post.create']) => ({ token: net.signService({ sub: 'svc:tools', cap }), headers: { 'x-ov-subject': who.subject_id } });
+ const liveSvc = { token: net.signService({ sub: 'svc:live', cap: ['community.comment.write', 'community.comment.moderate'] }) };
+
+ let ipN = 0;
+ async function send(method, path, { who, token, headers = {}, json, body } = {}) {
+ const h = { 'x-forwarded-for': `198.51.100.${(ipN++ % 250) + 1}`, ...headers };
+ if (who) h.cookie = `ov_token=${who.jwt}`;
+ if (token) h.authorization = `Bearer ${token}`;
+ let payload = body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; payload = JSON.stringify(json); }
+ const res = await fetch(t.base + path, { method, headers: h, body: payload, redirect: 'manual' });
+ return { status: res.status, headers: res.headers, text: await res.text(), json() { return JSON.parse(this.text); } };
+ }
+ const ok = (r, what) => { assert.ok(r.status === 200 || r.status === 201, `${what}: ${r.status} ${r.text.slice(0, 300)}`); return r.json(); };
+ const paste = async (who, fields) => ok(await send('POST', '/api/pastes', { who, json: fields }), `paste ${fields.title}`);
+ const shotPaste = async (who, fields) => {
+ const f = new FormData();
+ for (const [k, v] of Object.entries(fields)) f.append(k, String(v));
+ f.append('screenshot', new Blob([PNG], { type: 'image/png' }), 'shot.png');
+ return ok(await send('POST', '/api/pastes/screenshot', { who, body: f }), `screenshot ${fields.title}`);
+ };
+
+ // ── seed ──
+ const pub = await paste(alex, { title: 'Public paste', content: 'public words zebra' });
+ const priv = await paste(alex, { title: M.PRIVTITLE, content: `${M.PRIVBODY} zebra`, visibility: 'private' });
+ const privShot = await shotPaste(alex, { title: M.PRIVSHOT, description: M.PRIVSHOT, visibility: 'private' });
+ const unl = await paste(alex, { title: M.UNLTITLE, content: `${M.UNLBODY} zebra`, visibility: 'unlisted' });
+ const burn = await paste(alex, { title: M.BURNTITLE, content: `${M.BURNBODY} zebra`, burn_after_read: true });
+ const burnShot = await shotPaste(alex, { title: 'burning screenshot', burn_after_read: 'true' });
+ const privComment = ok(await send('POST', `/api/pastes/${priv.slug}/comments`, { who: alex, json: { message: M.PRIVCOMMENT } }), 'private paste comment');
+
+ ok(await send('PUT', '/api/v1/spaces/showcase/members-only', { who: boss, json: { owner: cora.subject_id } }), 'gate showcase');
+ const gatedSpaceThread = ok(await send('POST', '/api/v1/spaces/showcase/threads', { who: cora, json: { title: M.GATEDSPACETITLE, body: M.GATEDSPACEBODY } }), 'thread in the gated space');
+ const gated = ok(await send('POST', '/api/v1/spaces/general/threads', { who: cora, json: { title: GATEDTITLE, body: `${M.GATEDBODY} zebra`, members_only: true } }), 'gated thread');
+ const gatedReply = ok(await send('POST', `/api/v1/spaces/general/threads/${gated.thread.slug}/posts`, { who: cora, json: { body: M.GATEDREPLY } }), 'gated reply');
+ ok(await send('POST', '/api/v1/spaces', { who: boss, json: { slug: 'mods-room', name: M.STAFFSPACE, description: M.STAFFSPACE, visibility: 'staff' } }), 'staff space');
+ const staff = ok(await send('POST', '/api/v1/spaces/mods-room/threads', { who: boss, json: { title: M.STAFFTITLE, body: M.STAFFBODY } }), 'staff thread');
+ ok(await send('POST', '/api/v1/spaces', { who: boss, json: { slug: 'members-lounge', name: M.MEMBERSPACE, description: M.MEMBERSPACE, visibility: 'members' } }), 'members space');
+ const member = ok(await send('POST', '/api/v1/spaces/members-lounge/threads', { who: alex, json: { title: M.MEMBERTITLE, body: M.MEMBERBODY } }), 'members thread');
+ const publicThread = ok(await send('POST', '/api/v1/spaces/general/threads', { who: alex, json: { title: 'Open thread', body: 'open words zebra' } }), 'public thread');
+
+ const hidden = ok(await send('POST', '/api/v1/comments/threads/resolve', { ...liveSvc, json: { ref: { service: 'live', type: 'vod', id: '501' } } }), 'hidden thread').thread;
+ const hiddenComment = ok(await send('POST', `/api/v1/comments/threads/${hidden.id}/comments`, { ...liveSvc, headers: { 'x-ov-subject': alex.subject_id }, json: { message: M.HIDDENCOMMENT } }), 'hidden comment').comment;
+ ok(await send('PUT', `/api/v1/comments/threads/${hidden.access_id}/visibility`, { who: boss, json: { visibility: 'hidden' } }), 'hide the thread');
+ const vod = ok(await send('POST', '/api/v1/comments/threads/resolve', { ...liveSvc, json: { ref: { service: 'live', type: 'vod', id: '502' } } }), 'vod thread').thread;
+ const vodComment = ok(await send('POST', `/api/v1/comments/threads/${vod.id}/comments`, { ...liveSvc, headers: { 'x-ov-subject': alex.subject_id }, json: { message: M.VODCOMMENT } }), 'vod comment').comment;
+ const unlThread = ok(await send('POST', '/api/v1/comments/threads/resolve', { who: alex, json: { ref: { service: 'community', type: 'paste', id: unl.slug } } }), 'unlisted paste thread').thread;
+ ok(await send('POST', `/api/v1/comments/threads/${unlThread.id}/comments`, { who: alex, json: { message: 'on the unlisted paste' } }), 'unlisted paste comment');
+ const pubThread = ok(await send('POST', '/api/v1/comments/threads/resolve', { who: alex, json: { ref: { service: 'community', type: 'paste', id: pub.slug } } }), 'public paste thread').thread;
+ ok(await send('POST', `/api/v1/comments/threads/${pubThread.id}/comments`, { who: alex, json: { message: 'on the public paste' } }), 'public paste comment');
+ require('../server/search/documents').createSearchDocuments({ db: t.db }).scan();
+
+ const outsiders = { anonymous: {}, sam: { who: sam }, 'a service for sam': svcFor(sam) };
+ const allMarkers = Object.values(M);
+ // Signed-in people are members of a members space (every account, for now); first-party services read threads by their sequential id.
+ const forbiddenFor = {
+ anonymous: allMarkers,
+ sam: allMarkers.filter((m) => !m.startsWith('MEMBER')),
+ 'a service for sam': allMarkers.filter((m) => !m.startsWith('MEMBER') && m !== M.VODCOMMENT),
+ };
+
+ await check('private pastes: every read path answers an outsider exactly as it answers a paste that does not exist; the owner still reads them', async () => {
+ const reads = (slug) => [`/api/pastes/${slug}`, `/api/pastes/${slug}?no_view=1`, `/p/${slug}`, `/p/${slug}/raw`, `/p/${slug}/download`, `/p/${slug}/screenshot`,
+ `/api/pastes/${slug}/comments`, `/api/pastes/${slug}/versions`, `/api/pastes/${slug}/raw`, `/pastes/${slug}`];
+ for (const [name, p] of Object.entries(outsiders)) {
+ for (const slug of [priv.slug, privShot.slug]) {
+ const paths = reads(slug), unknown = reads(UNKNOWN_SLUG);
+ for (let i = 0; i < paths.length; i++) {
+ const a = await send('GET', paths[i], p), b = await send('GET', unknown[i], p);
+ assert.strictEqual(a.status, b.status, `${paths[i]} as ${name}: ${a.status} vs ${b.status} for a missing paste`);
+ for (const m of allMarkers) assert.ok(!a.text.includes(m), `${paths[i]} as ${name} shows ${m}`);
+ }
+ }
+ // Read-like writes: like, copy, fork, comment, delete a comment — 404 like a missing paste, nothing written.
+ const before = JSON.stringify(t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get().n;
+ for (const [method, suffix, json] of [['POST', '/like'], ['POST', '/copy'], ['POST', '/fork', {}], ['POST', '/comments', { message: 'hi there' }], ['DELETE', `/comments/${privComment.comment.id}`]]) {
+ const a = await send(method, `/api/pastes/${priv.slug}${suffix}`, { ...p, json }), b = await send(method, `/api/pastes/${UNKNOWN_SLUG}${suffix}`, { ...p, json });
+ assert.strictEqual(a.status, b.status, `${method} ${suffix} as ${name}: ${a.status} vs ${b.status}`);
+ assert.ok(!allMarkers.some((m) => a.text.includes(m)), `${method} ${suffix} as ${name} shows a marker`);
+ }
+ const after = JSON.stringify(t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get().n;
+ assert.strictEqual(after, before, `a refused write changed something (${name})`);
+ // A browser cannot open a comment thread on it either (it looks missing).
+ if (p.who) {
+ const r = await send('POST', '/api/v1/comments/threads/resolve', { ...p, json: { ref: { service: 'community', type: 'paste', id: priv.slug } } });
+ const u = await send('POST', '/api/v1/comments/threads/resolve', { ...p, json: { ref: { service: 'community', type: 'paste', id: UNKNOWN_SLUG } } });
+ assert.deepStrictEqual([r.status, r.json().code], [u.status, u.json().code]);
+ }
+ }
+ // Positive controls: the owner reads everything.
+ assert.strictEqual((await send('GET', `/api/pastes/${priv.slug}`, { who: alex })).json().paste.content, `${M.PRIVBODY} zebra`);
+ assert.strictEqual((await send('GET', `/p/${priv.slug}/raw`, { who: alex })).text, `${M.PRIVBODY} zebra`);
+ assert.ok((await send('GET', `/p/${priv.slug}`, { who: alex })).text.includes(M.PRIVTITLE));
+ assert.strictEqual((await send('GET', `/p/${privShot.slug}/screenshot`, { who: alex })).status, 302);
+ assert.ok((await send('GET', `/api/pastes/${priv.slug}/comments`, { who: alex })).text.includes(M.PRIVCOMMENT));
+ assert.strictEqual((await send('GET', `/api/pastes/${priv.slug}`, svcFor(alex))).status, 200, 'a service acting for the owner');
+ });
+
+ await check('unlisted pastes open by their link but are never listed: lists, user lists, counts, search (also include_unlisted from someone else)', async () => {
+ for (const [name, p] of Object.entries(outsiders)) {
+ assert.strictEqual((await send('GET', `/api/pastes/${unl.slug}`, p)).status, 200, `the link works (${name})`);
+ for (const path of ['/api/pastes?limit=200', '/api/pastes?search=zebra', '/api/pastes?username=alex&include_unlisted=1', '/api/pastes/by-user/alex?limit=100', '/pastes?q=zebra', '/']) {
+ const r = await send('GET', path, p);
+ for (const m of [M.UNLTITLE, M.UNLBODY, M.PRIVTITLE, M.BURNTITLE, M.BURNBODY]) assert.ok(!r.text.includes(m), `${path} as ${name} shows ${m}`);
+ for (const s of [unl.slug, priv.slug, privShot.slug, burn.slug, burnShot.slug]) assert.ok(!r.text.includes(s), `${path} as ${name} lists a hidden slug`);
+ }
+ const mine = (await send('GET', '/api/pastes/by-user/alex?limit=100', p)).json();
+ assert.strictEqual(mine.total, mine.pastes.length, `the count is of what is listed (${name})`);
+ }
+ const own = (await send('GET', '/api/pastes/by-user/alex?limit=100', { who: alex })).json().pastes.map((x) => x.slug);
+ for (const s of [unl.slug, priv.slug]) assert.ok(own.includes(s), 'the owner sees their own');
+ });
+
+ await check('burn-after-read screenshots: the screenshot link is the read (a second read of any kind finds it burned)', async () => {
+ const first = await send('GET', `/p/${burnShot.slug}/screenshot`, { who: sam });
+ assert.strictEqual(first.status, 302, first.text);
+ const again = await send('GET', `/p/${burnShot.slug}/screenshot`, { who: sam });
+ assert.ok(again.status === 404 || again.status === 410, `the screenshot was served again: ${again.status}`);
+ assert.ok([404, 410].includes((await send('GET', `/api/pastes/${burnShot.slug}`)).status), 'the paste burned');
+ // The owner's own looks do not burn it.
+ const mineToo = await shotPaste(alex, { title: 'mine', burn_after_read: 'true' });
+ for (let i = 0; i < 3; i++) assert.strictEqual((await send('GET', `/p/${mineToo.slug}/screenshot`, { who: alex })).status, 302);
+ assert.strictEqual((await send('GET', `/api/pastes/${mineToo.slug}`, { who: sam })).status, 200, 'still unread');
+ });
+
+ await check('comment threads: hidden ones and a VOD thread\'s sequential id look missing to browsers; moderators and Live still reach them', async () => {
+ for (const [name, p] of Object.entries(outsiders)) {
+ const pairs = [[`/api/v1/comments/threads/${hidden.access_id}`, `/api/v1/comments/threads/${UNKNOWN_ACCESS}`], [`/c/${hidden.access_id}`, `/c/${UNKNOWN_ACCESS}`],
+ [`/api/v1/comments/${hiddenComment.id}`, '/api/v1/comments/999999'], [`/api/v1/comments/threads/${hidden.access_id}/comments`, `/api/v1/comments/threads/${UNKNOWN_ACCESS}/comments`, 'POST'],
+ [`/api/v1/comments/${hiddenComment.id}/votes`, '/api/v1/comments/999999/votes', 'POST']];
+ if (p.who || name === 'anonymous') pairs.push([`/api/v1/comments/threads/${vod.id}`, '/api/v1/comments/threads/999999'], [`/api/v1/comments/${vodComment.id}`, '/api/v1/comments/999998']);
+ for (const [a, b, method = 'GET'] of pairs) {
+ const json = method === 'POST' ? { message: 'x', value: 1 } : undefined;
+ const ra = await send(method, a, { ...p, json }), rb = await send(method, b, { ...p, json });
+ assert.strictEqual(ra.status, rb.status, `${method} ${a} as ${name}: ${ra.status} vs ${rb.status}`);
+ assert.ok(!ra.text.includes(M.HIDDENCOMMENT) && !(p.who && ra.text.includes(M.VODCOMMENT)), `${a} as ${name} shows a comment`);
+ }
+ // Browsers cannot open a VOD's thread themselves (only Live knows who may see the VOD).
+ if (p.who) assert.strictEqual((await send('POST', '/api/v1/comments/threads/resolve', { ...p, json: { ref: { service: 'live', type: 'vod', id: '502' } } })).status, 403);
+ }
+ assert.ok((await send('GET', `/api/v1/comments/threads/${hidden.access_id}`, { who: boss })).text.includes(M.HIDDENCOMMENT), 'moderators read hidden threads');
+ assert.ok((await send('GET', `/api/v1/comments/threads/${vod.id}`, liveSvc)).text.includes(M.VODCOMMENT), 'Live reads by the sequential id');
+ assert.ok((await send('GET', `/api/v1/comments/threads/${vod.access_id}`, { who: sam })).text.includes(M.VODCOMMENT), 'whoever Live gave the access id reads it');
+ });
+
+ await check('events, Pulse and search documents: nothing private is public; comment events are public only for items Community knows are public', async () => {
+ const outbox = t.db.prepare('SELECT envelope FROM event_outbox ORDER BY id').all().map((r) => JSON.parse(r.envelope));
+ const text = JSON.stringify(outbox);
+ for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!text.includes(m), `an event carries ${m}`);
+ const pub = (e) => e.visibility === 'public';
+ const pasteEvent = (slug) => outbox.find((e) => e.event_type === 'community.paste.created' && e.payload.paste_id === slug);
+ for (const s of [priv.slug, privShot.slug, unl.slug]) {
+ assert.ok(!pub(pasteEvent(s)), `the paste ${s} is a public event`);
+ assert.strictEqual(pasteEvent(s).payload.url, null);
+ }
+ for (const id of [gated.thread.id, gatedSpaceThread.thread.id, staff.thread.id, member.thread.id]) {
+ const e = outbox.find((x) => x.event_type === 'community.thread.created' && x.payload.thread_id === id);
+ assert.ok(e && !pub(e) && e.payload.url === null, `thread ${id} is a public event`);
+ }
+ assert.ok(!pub(outbox.find((x) => x.event_type === 'community.post.created' && x.payload.post_id === gatedReply.post.id)));
+ const commentEvent = (accessId) => outbox.filter((e) => e.event_type === 'community.comment.created' && e.payload.thread_access_id === accessId);
+ for (const [what, th] of [['a Live VOD (maybe private)', vod], ['a hidden thread', hidden], ['an unlisted paste', unlThread]]) {
+ const evs = commentEvent(th.access_id);
+ assert.ok(evs.length, `no event for the comment on ${what}`);
+ for (const e of evs) {
+ assert.strictEqual(e.visibility, 'internal', `the comment on ${what} is a public event: its thread's access id and ref (${JSON.stringify(e.payload.ref)}) reach every realtime listener`);
+ assert.strictEqual(e.payload.url, null);
+ }
+ }
+ const open = commentEvent(pubThread.id);
+ assert.ok(open.length && open.every(pub) && open[0].payload.url.endsWith(`/c/${pubThread.id}`), 'a comment on a public paste stays a public event');
+ const docs = JSON.stringify(outbox.filter((e) => /^community\.index_document\./.test(e.event_type)));
+ assert.ok(docs.includes(pub.slug) || docs.includes('Open thread'), 'search documents were built');
+ for (const s of [priv.slug, privShot.slug, unl.slug, burn.slug, burnShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug]) assert.ok(!docs.includes(s), `${s} is a search document`);
+ const pulse = JSON.stringify(t.db.prepare('SELECT * FROM pulse_items').all());
+ assert.ok(pulse.includes('Open thread'), 'Pulse took the public thread');
+ for (const m of [...allMarkers, GATEDTITLE]) assert.ok(!pulse.includes(m), `Pulse holds ${m}`);
+ });
+
+ // ── the crawl ──
+ const routes = listRoutes(t.app).filter((r) => r.method === 'GET' || r.method === 'ALL');
+ const values = {
+ slug: [priv.slug, privShot.slug, gated.thread.slug, gatedSpaceThread.thread.slug, staff.thread.slug, member.thread.slug, publicThread.thread.slug, UNKNOWN_SLUG],
+ space: ['general', 'showcase', 'mods-room', 'members-lounge', 'no-such-space'],
+ username: ['alex', 'cora', 'boss'],
+ id: [String(gatedReply.post.id), String(staff.post.id), String(member.post.id), String(gatedSpaceThread.post.id), hidden.access_id, String(hidden.id), String(vod.id), '999999'],
+ accessId: [hidden.access_id, UNKNOWN_ACCESS],
+ commentId: [String(privComment.comment.id), String(hiddenComment.id), String(vodComment.id), '999999'],
+ category: ['no-such-category'], group: ['no-such-group'], service: ['community'], type: ['paste'], '*': ['x'],
+ };
+ const QUERY = `?limit=200&sort=new&q=zebra&search=zebra&type=paste&username=alex&include_unlisted=1&fork=${priv.slug}&page=1`;
+
+ await check(`every GET route (${routes.length}, from the router stack) with the private ids, as anonymous, another user and a service for them: no private title or body anywhere`, async () => {
+ assert.ok(routes.length >= 50, 'the router walk is broken');
+ const problems = [];
+ let n = 0;
+ for (const r of routes) {
+ for (const path of fill(r.path, values, 80)) {
+ for (const [name, p] of Object.entries(outsiders)) {
+ for (const q of ['', QUERY]) {
+ const res = await send('GET', path + q, p);
+ n++;
+ const found = forbiddenFor[name].filter((m) => res.text.includes(m) || String(res.headers.get('location') || '').includes(m));
+ if (found.length) problems.push(`GET ${path}${q ? '?…' : ''} as ${name} (${res.status}): ${found.join(', ')}`);
+ }
+ }
+ }
+ }
+ assert.ok(n > 1000, `only ${n} requests`);
+ if (problems.length) console.log(` ${problems.slice(0, 60).join('\n ')}`);
+ assert.deepStrictEqual(problems, []);
+ });
+
+ await check('every GET route answers a private paste, a staff space, a staff post and a hidden comment thread exactly as an unknown one', async () => {
+ const sig = (res) => {
+ let code = '';
+ try { const j = JSON.parse(res.text); code = j.code || j.error || ''; } catch { const m = /<title>([^<]*)<\/title>/.exec(res.text); code = m ? m[1] : ''; }
+ return `${res.status} ${code}`.trim();
+ };
+ // [param values for the private thing, the same for a missing one]
+ const families = [
+ { when: (p) => /^\/(api\/pastes|p|pastes)\/:slug/.test(p), hidden: [{ slug: priv.slug }, { slug: privShot.slug }], missing: { slug: UNKNOWN_SLUG } },
+ { when: (p) => p.includes(':space'), hidden: [{ space: 'mods-room', slug: staff.thread.slug }], missing: { space: 'no-such-space', slug: staff.thread.slug } },
+ { when: (p) => p.includes(':space') && p.includes(':slug'), hidden: [{ space: 'general', slug: staff.thread.slug }], missing: { space: 'general', slug: 'no-such-thread' } },
+ { when: (p) => /^\/api\/v1\/posts\/:id/.test(p), hidden: [{ id: String(staff.post.id) }], missing: { id: '999999' } },
+ { when: (p) => p.includes(':accessId') || /\/comments\/threads\/:id/.test(p), hidden: [{ accessId: hidden.access_id, id: hidden.access_id }], missing: { accessId: UNKNOWN_ACCESS, id: UNKNOWN_ACCESS } },
+ { when: (p) => /^\/api\/v1\/comments\/:commentId/.test(p), hidden: [{ commentId: String(hiddenComment.id) }], missing: { commentId: '999999' } },
+ ];
+ const problems = [];
+ let compared = 0;
+ for (const r of routes) {
+ for (const fam of families) {
+ if (!fam.when(r.path)) continue;
+ for (const h of fam.hidden) {
+ const a = fill(r.path, { ...values, ...Object.fromEntries(Object.entries(h).map(([k, v]) => [k, [v]])), commentId: ['999999'] }, 1)[0];
+ const b = fill(r.path, { ...values, ...Object.fromEntries(Object.entries(fam.missing).map(([k, v]) => [k, [v]])), commentId: ['999999'] }, 1)[0];
+ for (const [name, p] of Object.entries(outsiders)) {
+ const ra = await send('GET', a, p), rb = await send('GET', b, p);
+ compared++;
+ if (sig(ra) !== sig(rb)) problems.push(`GET ${a} as ${name}: ${sig(ra)} vs ${sig(rb)} for ${b}`);
+ }
+ }
+ }
+ }
+ assert.ok(compared > 50, `only ${compared} comparisons`);
+ if (problems.length) console.log(` ${problems.slice(0, 60).join('\n ')}`);
+ assert.deepStrictEqual(problems, []);
+ });
+
+ await t.close();
+ done();
+})();