openvibers/openvibe.community Commits
Show and take paste comments on the paste's typed comment thread (#5)
Changes
@@@ -119,6 +119,7 @@ Pages (server-rendered HTML):| `GET /` | Home: hero, latest pastes, most viewed, "start a paste" CTA, what is coming || `GET /pastes` | Browse — `?q=` search, `?sort=new\|views`, `?lang=`, `?page=` || `GET /p/:slug` | Paste page — highlighted body (server-side, highlight.js), raw/download/copy/fork/share, screenshot, related |++| `POST /p/:slug/comments` | Comment on the paste (signed in; the no-JS form) — its typed comment thread, the one `/c/:accessId` and the APIs show || `GET /p/:slug/raw` | `live`: 302 → `https://openvibe.media/p/:slug/raw`; `community`: the text itself || `GET /p/:slug/screenshot` | `live`: 302 → Media's `/p/:slug/screenshot`; `community`: 302 → the stored image URL || `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |@@@ -220,7 +221,17 @@ Who may do what: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.++- **Paste comments** are the paste's typed thread (`community`/`paste`/`<slug>`): the paste page++ (`/p/:slug` shows it newest first, `?sort=old` and `?after=<last comment id>` as links, `#comment-<id>`++ anchors; `POST /p/:slug/comments`, signed in, the no-JS form; none on burn-after-read pastes), `/api/pastes/:slug/comments` (GET, POST and++ DELETE keep their old shapes and rules: anonymous comments with a name, the author, the paste's++ owner or staff delete; Live's adapter calls them as the person) and `/api/v1/comments` all read and++ write that one thread. A paste thread follows the paste: private ones are their owner's and staff's++ only, deleted and burned ones nobody's (404 on every call, by access id too, so a paste made private++ later takes its comments with it); unlisted ones are open to whoever has the link. Migration++ `0004_paste_comments_to_threads.sql` copied the old `paste_comments` rows onto the threads (ledger++ `legacy_id_map`, `community`/`paste_comment` → `comment`); nothing writes `paste_comments` any more and++ it stays, read-only, for one release.- Comments carry `edited_at` (null until the author edits) and `can_edit` / `can_delete` for theviewer.@@@ -735,7 +746,7 @@ server/pastes/proxy.js (removed: the /api/pastes proxy to Live is gone)pastes/api.js native /api/pastes/*pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes-- pastes/store.js pure SQL over pastes / versions / likes / comments / projections++ pastes/store.js pure SQL over pastes / versions / likes / projections (comments: comments/store.js)pastes/source.js where pages read pastes from (the store)pastes/catalog.js recent public pastes: trending, related, language filtercomments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),@@@ -771,8 +782,8 @@ docs/discord-relay.md the Discord relay: how it works, owner steps, staff API,Spaces per streamer, game and project (with membership), the Discord relay's production roundtrip (owner steps in [docs/discord-relay.md](docs/discord-relay.md)), visibility changes for--comment threads arriving through Events, and moving paste comments onto the typed comment--threads. [Submissions](#submissions) are here: send yours at [/submissions](https://openvibe.community/submissions).++comment threads arriving through Events, and dropping the read-only `paste_comments` table a++release after paste comments moved onto the typed comment threads. [Submissions](#submissions) are here: send yours at [/submissions](https://openvibe.community/submissions).## Related servicesnnew file mode 100644@@@ -0,0 +1,62 @@++-- phase: migrate++-- T10 lane E job 5: paste comments move onto the typed comment threads (server/comments). From this release++-- the paste API, the paste page and /api/v1/comments all read and write the thread of ref++-- community/paste/<slug>; this copies what paste_comments holds into it so there is a single source.++--++-- Each paste_comments row becomes one comments row on its paste's thread (opened here when the paste has++-- none): the author subject (a pre-subject Network user id goes through legacy_id_map network/user), the++-- anonymous name, the message, created_at and updated_at; a deleted row stays deleted (deleted_at =++-- its updated_at) with its message scrubbed, as the comment store deletes. Replies keep their parent.++-- legacy_id_map records every copy (community/paste_comment/<old id> → comment/<new id>): that is what++-- makes this safe to run again (copied rows are skipped) and how a reply finds its parent's new id.++--++-- paste_comments is not dropped: nothing writes it any more, and it stays read-only for one release.++-- Owner check, before and after (the two numbers match once this has run):++-- SELECT (SELECT COUNT(*) FROM paste_comments) AS paste_comments,++-- (SELECT COUNT(*) FROM legacy_id_map WHERE source_system = 'community' AND source_type = 'paste_comment') AS migrated;++++-- A thread for every paste with comments not copied yet. Its access id is cth_ + 22 base64url characters++-- (16 random bytes, as server/db.js makes them).++INSERT INTO comment_threads (ref_service, ref_type, ref_id, created_at, updated_at, access_id)++SELECT 'community', 'paste', p.slug, MIN(pc.created_at), MAX(pc.updated_at),++ 'cth_' || rtrim(translate(encode(uuid_send(gen_random_uuid()), 'base64'), '+/', '-_'), '=')++FROM paste_comments pc++JOIN pastes p ON p.id = pc.paste_id++WHERE NOT EXISTS (SELECT 1 FROM legacy_id_map m WHERE m.source_system = 'community' AND m.source_type = 'paste_comment' AND m.source_id = pc.id::text)++GROUP BY p.slug++ON CONFLICT (ref_service, ref_type, ref_id) DO NOTHING;++++-- The rows not copied yet, each with its new comment id taken from the comments sequence in the old id++-- order; the map rows and the comments go in together, in one statement. A reply's parent is either++-- copied in this same run (todo) or in an earlier one (legacy_id_map as it was before this statement: a++-- data-modifying WITH runs once, to completion, and the rest of the statement does not see its rows).++WITH todo AS (++ SELECT s.*, nextval(pg_get_serial_sequence('comments', 'id')) AS new_id++ FROM (SELECT pc.id, pc.parent_id, pc.author_subject, pc.anon_name, pc.message, pc.is_deleted, pc.created_at, pc.updated_at, p.slug++ FROM paste_comments pc++ JOIN pastes p ON p.id = pc.paste_id++ WHERE NOT EXISTS (SELECT 1 FROM legacy_id_map m WHERE m.source_system = 'community' AND m.source_type = 'paste_comment' AND m.source_id = pc.id::text)++ ORDER BY pc.id) s++), mapped AS (++ INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id)++ SELECT 'community', 'paste_comment', todo.id::text, 'comment', todo.new_id::text FROM todo++)++INSERT INTO comments (id, thread_id, parent_id, author_subject, anon_name, origin, message, created_at, updated_at, deleted_at)++OVERRIDING SYSTEM VALUE++SELECT todo.new_id, th.id, COALESCE(tp.new_id, mp.target_id::bigint),++ COALESCE(um.target_id, todo.author_subject), todo.anon_name, 'user',++ CASE WHEN todo.is_deleted <> 0 THEN '' ELSE todo.message END,++ todo.created_at, todo.updated_at,++ CASE WHEN todo.is_deleted <> 0 THEN todo.updated_at END++FROM todo++JOIN comment_threads th ON th.ref_service = 'community' AND th.ref_type = 'paste' AND th.ref_id = todo.slug++LEFT JOIN todo tp ON tp.id = todo.parent_id++LEFT JOIN legacy_id_map mp ON mp.source_system = 'community' AND mp.source_type = 'paste_comment' AND mp.source_id = todo.parent_id::text++LEFT JOIN legacy_id_map um ON um.source_system = 'network' AND um.source_type = 'user' AND um.target_type = 'subject'++ AND um.source_id = todo.author_subject AND todo.author_subject ~ '^[0-9]+$';++++-- Counters from the rows, as the comment store keeps them (deleted comments not counted).++UPDATE comments c SET reply_count = (SELECT COUNT(*) FROM comments r WHERE r.parent_id = c.id AND r.deleted_at IS NULL)++WHERE c.parent_id IS NULL AND c.thread_id IN (SELECT id FROM comment_threads WHERE ref_service = 'community' AND ref_type = 'paste');++UPDATE comment_threads t SET comment_count = (SELECT COUNT(*) FROM comments c WHERE c.thread_id = t.id AND c.deleted_at IS NULL)++WHERE t.ref_service = 'community' AND t.ref_type = 'paste';@@@ -9,6 +9,7 @@* GET / home ALL /api/pastes/* the native paste API (pastes/api.js)* GET /pastes browse /api/v1/comments/* typed comment threads (comments/api.js)* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)++ * POST /p/:slug/comments comment on it (its typed thread)* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)* GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)@@@ -65,6 +66,7 @@ const { createIndexNow } = require('openvibe-shared/indexnow');const PUBLIC_DIR = path.join(__dirname, '..', 'public');const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;++const pasteRef = (slug) => ({ service: 'community', type: 'paste', id: String(slug) });const VERSION = require('../package.json').version;async function createApp(opts = {}) {@@@ -314,7 +316,36 @@ async function createApp(opts = {}) {throw err;}const related = await catalog.related(paste, 6);-- html(res, pages.pastePage({ paste, related, user: req.user }));++ // Its comments are the paste's typed thread (community/paste/<slug>), the one /c/:accessId and the++ // comments API show, sorted (?sort=old|new) and paged (?after=<last comment id>) as there; null while++ // nobody has commented. The comment service applies the paste's visibility again.++ // A burn-after-read paste is gone once shown, so it has no comment panel.++ const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? String(req.query.after) : null;++ const sort = req.query.sort === 'old' ? 'old' : 'new';++ const thread = paste.burn_after_read ? null : await comments.forRef(req.viewer, pasteRef(paste.slug), { sort, after, limit: 30 });++ html(res, pages.pastePage({ paste, related, user: req.user, comments: thread, commentSort: sort, commentsAfter: after }));++ }));++++ // No-JS comment form: signed in, the cookie (SameSite=Lax) and no Origin from another site, as on /c/:accessId.++ // The thread opens on the first comment; the comment service decides the rest (visibility, lock, blocks, limits).++ app.post('/p/:slug/comments', withUser, express.urlencoded({ extended: false, limit: '64kb' }), wrap(async (req, res, next) => {++ const { slug } = req.params;++ const origin = req.get('origin');++ if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);++ const back = `/p/${encodeURIComponent(slug)}`;++ try {++ if (!SLUG_RE.test(slug)) throw new v1.ApiError(404, 'ref.not_found', 'No such paste');++ if (!req.viewer || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(back)}`);++ const { thread } = await comments.resolve(req.viewer, { ref: pasteRef(slug) });++ const b = req.body || {};++ const out = await comments.add(req.viewer, thread.id, { message: String(b.message || '').slice(0, 5000), parent_id: b.parent_id || null });++ return res.redirect(303, `${back}#comment-${out.comment.id}`);++ } catch (err) {++ if (!(err instanceof v1.ApiError)) return next(err);++ if (err.status === 401) return res.redirect(303, `/auth/login?next=${encodeURIComponent(back)}`);++ if (err.status === 404) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }), 404);++ return html(res, pages.errorPage({ status: err.status, title: err.status === 429 ? 'Slow down' : 'That did not work', message: err.message }), err.status);++ }}));// Raw text is served here, the screenshot link is the stored Media URL.@@@ -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). Browsers++ * - read: anyone, except hidden threads (moderators only; everyone else gets 404) and threads of++ * Community pastes the viewer may not see (pastes/service.js's rule: a private paste is its++ * owner's and staff's only, a deleted or burned one nobody's — 404, checked on every call, so a++ * paste made private later takes its thread with it). 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.@@@ -128,24 +131,31 @@ function createCommentService({ db, network = null, limits = {} } = {}) {}/** The thread, or 404 — hidden threads look missing to everyone but moderators. */-- function visible(v, t) {-- if (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');++ async function visible(v, t) {++ if (!t || (t.visibility === 'hidden' && !moderator(v)) || !await entityVisible(v, t)) fail(404, 'thread.not_found', 'Comment thread not found');return t;}-- const visibleThread = async (v, id) => visible(v, await threadById(v, id));++ const visibleThread = async (v, id) => await visible(v, await threadById(v, id));async function visibleComment(v, id) {const c = /^\d{1,15}$/.test(String(id)) ? await store.getComment(db, Number(id)) : null;if (!c || c.deleted_at) fail(404, 'comment.not_found', 'Comment not found');-- return { comment: c, thread: visible(v, await store.getThread(db, c.thread_id)) };++ return { comment: c, thread: await visible(v, await store.getThread(db, c.thread_id)) };}-- /** Community's own entities must exist (and be visible) before anyone opens a thread on them. */-- async function checkCommunityRef(ref) {++ /** The paste service's rule: a paste that is gone (deleted, burned) is nobody's; a private one its owner's and staff's. */++ async function pasteVisibleTo(v, slug) {++ const p = await pasteStore.getBySlug(db, String(slug));++ return !!p && (p.visibility !== 'private' || !!(v && (v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject))));++ }++ /** Whether the viewer may see the entity a thread belongs to (only Community's pastes can be checked here). */++ const entityVisible = async (v, t) => !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);++++ /** Community's own entities must exist (and be visible to the viewer) before anyone opens a thread on them. */++ async function checkCommunityRef(ref, v) {if (ref.service !== 'community') return;if (ref.type === 'paste') {-- const p = await pasteStore.getBySlug(db, ref.id);-- if (!p || p.visibility === 'private') fail(404, 'ref.not_found', 'No such paste');++ if (!await pasteVisibleTo(v, ref.id)) fail(404, 'ref.not_found', 'No such paste');} else if (ref.type === 'post') {const ok = /^\d{1,15}$/.test(ref.id) && await db.prepare(`SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_idWHERE p.id = ? AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'`).get(Number(ref.id));@@@ -189,13 +199,25 @@ function createCommentService({ db, network = null, limits = {} } = {}) {const types = BROWSER_REF_TYPES[ref.service];if (!types || !types.includes(ref.type)) fail(403, 'ref.type_not_allowed', `Comments on ${ref.service}/${ref.type} can only be opened by that service`);}-- await checkCommunityRef(ref);++ await checkCommunityRef(ref, v);// Labels are display caches; only a service's word is taken for them.const label = v.kind === 'service' && ref.label ? String(ref.label).slice(0, 200) : null;const { thread, created } = await store.resolveThread(db, ref, { label, createdBy: v.subject || (v.kind === 'service' ? v.service : null) });return { thread: shapeThread(thread, v), created };},++ /**++ * The thread of an entity as the entity's own page shows it (Community's paste page): get() found by++ * ref, after the same checks as resolve; reading never opens a thread, so null while nobody has++ * commented, and null for a hidden thread to everyone but moderators.++ */++ async forRef(v, ref, q = {}) {++ await checkCommunityRef(ref, v);++ const t = await store.getThreadByRef(db, ref);++ if (!t || (t.visibility === 'hidden' && !moderator(v))) return null;++ return await this.get(v, t.access_id, q);++ },++/*** GET /threads/:id — the thread and one page of top-level comments (replies nested, up to* REPLY_PAGE each). ?after=<last comment id>&sort=old|new&limit=; ?parent=<id> pages one@@@ -132,4 +132,15 @@ async function listReplies(db, parentId, { after = null, limit = 50 } = {}) {return { rows, hasMore };}--module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, editComment, softDeleteComment, listTopLevel, listReplies };++/**++ * Top-level comments newest first by offset, each with all its replies (oldest first); deleted ones left++ * out, with their replies. The paste API's list (pastes/service.js keeps its old response shape).++ */++async function listNewest(db, threadId, { limit = 50, offset = 0 } = {}) {++ const top = await db.prepare('SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?').all(threadId, limit, offset);++ const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC');++ for (const c of top) { c.replies = await replies.all(c.id); c.reply_count = c.replies.length; }++ return top;++}++++module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, editComment, softDeleteComment, listTopLevel, listReplies, listNewest };@@@ -85,8 +85,11 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {}// Pastes: gone, unless someone else commented.if (await hasTable(db, 'pastes')) {-- for (const p of await db.prepare(`SELECT id FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {-- const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);++ for (const p of await db.prepare(`SELECT id, slug FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {++ // Comments are on the paste's typed thread; paste_comments still holds the rows from before migration 0004.++ const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects)++ || (await hasTable(db, 'comments') && await db.prepare(`SELECT 1 FROM comments c JOIN comment_threads t ON t.id = c.thread_id++ WHERE t.ref_service = 'community' AND t.ref_type = 'paste' AND t.ref_id = ? AND (c.author_subject IS NULL OR c.author_subject NOT IN ${S}) LIMIT 1`).get(p.slug, ...subjects));if (others) {await db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,ai_summary = NULL, ai_tags = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);@@@ -18,6 +18,8 @@const crypto = require('crypto');const events = require('../events');const store = require('./store');++const commentStore = require('../comments/store');++const { discussionModerator } = require('../identity/capabilities');const { stripImageMetadata } = require('../media/strip-metadata');const { capabilities } = require('openvibe-contracts');const blocks = require('../identity/blocks');@@@ -91,6 +93,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limconst limited = (v) => !!(v && v.subject);const hasCap = (v, cap) => !!(v && v.kind === 'service' && capabilities.check(v.claims, cap).allowed);const isService = (v) => !!(v && v.kind === 'service');++ const pasteRef = (p) => ({ service: 'community', type: 'paste', id: p.slug });const needIdentity = (v) => { if (!v || (!v.subject && !v.staff)) fail(401, 'Authentication required'); };/**@@@ -178,14 +181,15 @@ function createPasteService({ db, network = null, media = null, config = {}, lim}async function shapeOne(row, v) { return (await shapeMany([row], v))[0]; }-- function shapeComment(c, projections) {++ /** A typed comment (comments/store.js) in the paste API's old shape: the paste's id, is_deleted, user_id = the author's subject. */++ function shapeComment(c, p, projections) {const out = {-- id: c.id, paste_id: c.paste_id, user_id: c.author_subject || null, author_subject: c.author_subject || null,-- parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.is_deleted,++ id: c.id, paste_id: p.id, user_id: c.author_subject || null, author_subject: c.author_subject || null,++ parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.deleted_at ? 1 : 0,created_at: c.created_at, updated_at: c.updated_at,-- ...authorFields(c.author_subject, 'user', projections),++ ...authorFields(c.author_subject, c.origin, projections),};-- if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, projections)); out.reply_count = c.reply_count; }++ if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, p, projections)); out.reply_count = c.reply_count; }return out;}@@@ -542,58 +546,73 @@ function createPasteService({ db, network = null, media = null, config = {}, limreturn { copies: await store.incrementCopies(db, p.id) };},-- /** GET /api/pastes/:slug/comments */++ /**++ * Paste comments live on the paste's typed comment thread (ref community/paste/<slug>, comments/store.js),++ * the one /p/:slug, /c/:accessId and /api/v1/comments show; these routes keep their old shapes and rules.++ * A hidden thread lists nothing and, like a locked one, takes comments from moderators only.++ *++ * GET /api/pastes/:slug/comments++ */async comments(v, slug, q = {}) {const p = await visible(v, slug);const limit = Math.min(parseInt(q.limit || '50', 10) || 50, 100);const offset = Math.max(parseInt(q.offset || '0', 10) || 0, 0);-- const list = await store.listComments(db, p.id, limit, offset);++ const t = await commentStore.getThreadByRef(db, pasteRef(p));++ if (!t || (t.visibility === 'hidden' && !discussionModerator(v))) return { comments: [], total: 0 };++ const list = await commentStore.listNewest(db, t.id, { limit, offset });const subjects = [];for (const c of list) { subjects.push(c.author_subject); for (const r of c.replies) subjects.push(r.author_subject); }const projections = await projectionsFor(subjects);-- return { comments: list.map((c) => shapeComment(c, projections)), total: await store.countComments(db, p.id) };++ return { comments: list.map((c) => shapeComment(c, p, projections)), total: Number(t.comment_count) };},/** POST /api/pastes/:slug/comments — anonymous comments allowed (with a name). */async addComment(v, slug, body = {}, ctx = {}) {const p = await visible(v, slug);-- const author = v.subject || null;-- if (!author && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');++ const origin = v.origin === 'ai' ? 'ai' : 'user';++ const author = origin === 'ai' ? null : (v.subject || null);++ if (!author && origin !== 'ai' && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');const message = String(body.message || '').trim();if (!message) fail(400, 'Comment cannot be empty');if (message.length > L.commentMaxLength) fail(400, `Comment must be under ${L.commentMaxLength} characters`);let anonName = null;-- if (!author) {++ if (!author && origin !== 'ai') {anonName = String(body.anon_name || '').trim().substring(0, 32) || 'Anonymous';anonName = anonName.replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';}const rateKey = author ? `s:${author}` : `ip:${ctx.ip || 'unknown'}`;if (limited(v)) commentRateCheck(rateKey, message);++ let t = await commentStore.getThreadByRef(db, pasteRef(p));++ if (t && t.visibility !== 'public' && !discussionModerator(v)) fail(403, 'This comment thread is locked', { code: 'thread.locked' });const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;let parent = null;if (parentId) {-- parent = await store.getComment(db, parentId);-- if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');++ parent = Number.isSafeInteger(parentId) ? await commentStore.getComment(db, parentId) : null;++ if (!parent || !t || parent.thread_id !== t.id || parent.deleted_at) fail(400, 'Invalid parent comment');if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');}// Platform blocks: no comment on a paste whose owner blocked you, no reply to a comment whose author did.if (author && await blocks.hasBlocked(db, p.owner_subject, author)) fail(403, 'You cannot comment on this paste: its owner blocked you', { code: 'community.blocked' });if (author && parent && await blocks.hasBlocked(db, parent.author_subject, author)) fail(403, 'You cannot reply to this comment: its author blocked you', { code: 'community.blocked' });-- const c = await store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });++ if (!t) t = (await commentStore.resolveThread(db, pasteRef(p), { createdBy: author || (isService(v) ? v.service : null) })).thread;++ const c = await commentStore.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });if (limited(v)) commentRecorded(rateKey, message);-- return { comment: shapeComment(c, await projectionsFor([author])) };++ return { comment: shapeComment(c, p, await projectionsFor([author])) };},/** DELETE /api/pastes/:slug/comments/:id — the author, the paste's owner, or staff. */async deleteComment(v, slug, commentId) {needIdentity(v);const p = await visible(v, slug);-- const c = await store.getComment(db, parseInt(commentId, 10));-- if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');++ const c = /^\d{1,15}$/.test(String(commentId)) ? await commentStore.getComment(db, Number(commentId)) : null;++ const t = c ? await commentStore.getThread(db, c.thread_id) : null;++ const ref = pasteRef(p);++ if (!t || t.ref_service !== ref.service || t.ref_type !== ref.type || t.ref_id !== ref.id) fail(404, 'Comment not found');const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');++ if (c.deleted_at) return { message: 'Comment deleted' }; // deleting again is a no-op, as beforeawait db.tx(async () => {-- await store.softDeleteComment(db, c.id);++ await commentStore.softDeleteComment(db, c.id, v.subject || v.service || null);if (!isAuthor && !isOwner(v, p)) await events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });});return { message: 'Comment deleted' };@@@ -353,35 +353,6 @@ async function deleteAllForks(db) {});}--// ── Comments ─────────────────────────────────────────────────----async function createComment(db, { paste_id, author_subject, anon_name, parent_id, message }) {-- const info = await db.prepare('INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message) VALUES (?, ?, ?, ?, ?) RETURNING id')-- .run(paste_id, author_subject || null, anon_name || null, parent_id || null, message);-- return await getComment(db, info.lastInsertRowid);--}----async function getComment(db, id) {-- return await db.prepare('SELECT * FROM paste_comments WHERE id = ?').get(id) || null;--}----/** Top-level comments (newest first) with their replies (oldest first); deleted ones hidden. */--async function listComments(db, pasteId, limit = 50, offset = 0) {-- const top = await db.prepare(`SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL-- ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(pasteId, limit, offset);-- const replies = db.prepare('SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC');-- for (const c of top) { c.replies = await replies.all(c.id); c.reply_count = c.replies.length; }-- return top;--}----async function countComments(db, pasteId) {-- return (await db.prepare('SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0').get(pasteId)).c;--}----async function softDeleteComment(db, id) {-- return (await db.prepare('UPDATE paste_comments SET is_deleted = 1, updated_at = ov_now() WHERE id = ?').run(id)).changes;--}--// ── Subject projection (display cache — never authority) ─────async function getProjections(db, subjectIds) {@@@ -433,7 +404,6 @@ module.exports = {getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,setAi, setScreenshot, setVisibility, stats, listForks, deleteAllForks,-- createComment, getComment, listComments, countComments, softDeleteComment,getProjections, upsertProjection, subjectsByUsername,mapGet, mapSet,SLUG_ADJECTIVES, SLUG_NOUNS,@@@ -50,32 +50,63 @@ function repliesHtml(c) {}/**-- * page: the comment service's answer for this viewer ({ thread, comments, next_cursor, viewer }).++ * The comment list and the no-JS form, shared by /c/:accessId and the paste page. page: the comment++ * service's answer for this viewer ({ thread, comments, next_cursor, viewer }), or null while nobody++ * has commented (the first comment opens the thread). base: the page's own address (paging and++ * sorting links, where sign-in returns), action: where the form posts. sort: 'new' or 'old' when the++ * page offers both orders (null: newest first, no sort links). note: HTML after "Commenting as …".*/--function threadPage({ accessId, page, user, after = null, error = null, draft = '' }) {-- const { thread, comments, next_cursor: next, viewer } = page;-- const ref = thread.ref;-- const what = refName(ref);-- const product = PRODUCTS[ref.service] || ref.service;-- const label = ref.label || `a ${what} on ${product}`;-- const src = sourceUrl(ref);-- const base = `/c/${accessId}`;++function commentPanel({ page, user, base, action = base, after = null, sort = null, error = null, draft = '', note = '' }) {++ const thread = page ? page.thread : null;++ const comments = page ? page.comments : [];++ const next = page ? page.next_cursor : null;++ const viewer = page ? page.viewer : { can_comment: !!user, can_moderate: false };++ const href = (o = {}) => {++ const q = new URLSearchParams();++ if (o.sort === 'old') q.set('sort', 'old');++ if (o.after) q.set('after', o.after);++ const qs = q.toString();++ return qs ? `${base}?${qs}` : base;++ };let form = '';-- if (thread.visibility === 'locked' && !viewer.can_moderate) form = '<p class="alert">This comment thread is locked.</p>';++ if (thread && thread.visibility === 'locked' && !viewer.can_moderate) form = '<p class="alert">This comment thread is locked.</p>';else if (!user) form = `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to comment.</p>`;else if (viewer.can_comment) {-- form = `<form class="paste-form reply-form" method="post" action="${esc(base)}" id="comment">++ form = `<form class="paste-form reply-form" method="post" action="${esc(action)}" id="comment">${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}<label class="field"><span>Comment</span><textarea name="message" rows="4" maxlength="2000" required placeholder="Add a comment…">${esc(draft)}</textarea></label>-- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-comment" aria-hidden="true"></i> Comment</button><span class="muted small">Commenting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. It shows on ${esc(product)} too.</span></div>++ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-comment" aria-hidden="true"></i> Comment</button><span class="muted small">Commenting as <strong>${esc(user.display_name || user.username || 'you')}</strong>.${note ? ` ${note}` : ''}</span></div></form>`;}++ const tabs = sort && thread && thread.comment_count > 1++ ? `<nav class="tabs sort-tabs" aria-label="Sort comments">${[['new', 'Newest'], ['old', 'Oldest']].map(([id, label]) => `<a class="tab${id === sort ? ' active' : ''}" href="${esc(href({ sort: id }))}"${id === sort ? ' aria-current="page"' : ''}>${label}</a>`).join('')}</nav>`++ : '';const list = comments.length? `<div class="posts">${comments.map(commentHtml).join('\n')}</div>`: `<p class="empty">${after ? 'No older comments.' : 'No comments yet.'}</p>`;-- const older = next ? `<nav class="pager" aria-label="Pages"><span></span><a rel="next" href="${esc(`${base}?after=${encodeURIComponent(next)}`)}">Older comments <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a></nav>` : '';++ const older = next ? `<nav class="pager" aria-label="Pages"><span></span><a rel="next" href="${esc(href({ sort, after: next }))}">${sort === 'old' ? 'More' : 'Older'} comments <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a></nav>` : '';++++ return `${after ? '' : form}++ ${after ? `<p><a href="${esc(href({ sort }))}">${sort === 'old' ? 'First' : 'Newest'} comments</a></p>` : ''}++ ${tabs}++ ${list}++ ${older}`;++}++++/**++ * page: the comment service's answer for this viewer ({ thread, comments, next_cursor, viewer }).++ */++function threadPage({ accessId, page, user, after = null, error = null, draft = '' }) {++ const { thread } = page;++ const ref = thread.ref;++ const what = refName(ref);++ const product = PRODUCTS[ref.service] || ref.service;++ const label = ref.label || `a ${what} on ${product}`;++ const src = sourceUrl(ref);++ const base = `/c/${accessId}`;++ const panel = commentPanel({ page, user, base, after, error, draft, note: `It shows on ${esc(product)} too.` });const body = `<article class="thread comment-thread">@@@ -84,10 +115,7 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =<h1>Comments on ${esc(label)}</h1><p class="muted">${num(thread.comment_count)} ${thread.comment_count === 1 ? 'comment' : 'comments'} · one thread, shown here and on ${esc(product)}${src ? ` · <a href="${esc(src)}">Open the ${esc(what)} on ${esc(product)}</a>` : ''}</p></header>-- ${after ? '' : form}-- ${after ? `<p><a href="${esc(base)}">Newest comments</a></p>` : ''}-- ${list}-- ${older}++ ${panel}</article>`;return renderPage({title: `Comments on ${label}`,@@@ -101,4 +129,4 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =});}--module.exports = { threadPage, sourceUrl };++module.exports = { threadPage, commentPanel, sourceUrl };@@@ -256,7 +256,11 @@ function shareLinks(p) {</div>`;}--function pastePage({ paste: p, related, user }) {++/**++ * comments: the paste's typed thread as the comment service shows it to this viewer (null while nobody has++ * commented); commentSort 'new' or 'old', commentsAfter the paging cursor. No comments on burn-after-read pastes.++ */++function pastePage({ paste: p, related, user, comments = null, commentSort = 'new', commentsAfter = null }) {const isShot = p.type === 'screenshot';const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');const description = pasteDescription(p);@@@ -315,6 +319,10 @@ function pastePage({ paste: p, related, user }) {<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p></footer></article>++${p.burn_after_read ? '' : `<section class="section" id="comments">++ <div class="section-head"><h2>Comments${comments && comments.thread.comment_count ? ` <span class="muted small">${num(comments.thread.comment_count)}</span>` : ''}</h2></div>++ ${require('./comments').commentPanel({ page: comments, user, base: `/p/${p.slug}`, action: `/p/${p.slug}/comments`, after: commentsAfter, sort: commentSort })}++</section>`}${related && related.length ? `<section class="section" id="related"><div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>${cardGrid(related)}nnew file mode 100644@@@ -0,0 +1,200 @@++'use strict';++/**++ * Paste comments live on the paste's typed comment thread (community/paste/<slug>): the /p/:slug form,++ * POST /api/pastes/:slug/comments (and a service writing as a person, Live's adapter) and++ * /api/v1/comments all see one thread; private, burned and later-private pastes keep their comments++ * from people who cannot see the paste; migration 0004 copies paste_comments rows onto the threads++ * exactly once, however often it runs.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/app');++++const MIGRATION = path.join(__dirname, '..', 'migrations', '0004_paste_comments_to_threads.sql');++++(async () => {++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 }, appOpts: { commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } } } });++ const net = t.network;++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });++ const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim' });++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });++++ const call = (p, { method = 'GET', token, cookie, headers = {}, json, form } = {}) => {++ const h = { ...headers };++ if (token) h.authorization = `Bearer ${token}`;++ let body;++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }++ if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }++ return t.get(p, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });++ };++ const create = async (json, cookie = alexJwt) => {++ const r = await call('/api/pastes', { method: 'POST', cookie, json });++ assert.strictEqual(r.status, 201, r.text);++ const out = r.json();++ return out.slug || out.paste.slug;++ };++ const resolve = (slug, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref: { service: 'community', type: 'paste', id: slug } }, ...who });++ const legacyRows = async () => Number((await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n);++++ const pub = await create({ content: 'public paste', title: 'Pub' });++++ await check('the /p/<slug> form and POST /api/pastes/:slug/comments write the typed thread; /api/v1/comments reads both back', async () => {++ const viaForm = await call(`/p/${pub}/comments`, { method: 'POST', cookie: samJwt, form: { message: 'from the page form' } });++ assert.strictEqual(viaForm.status, 303, viaForm.text);++ const m = /^\/p\/([^#]+)#comment-(\d+)$/.exec(viaForm.headers.get('location'));++ assert.ok(m && m[1] === pub, viaForm.headers.get('location'));++ const viaApi = await call(`/api/pastes/${pub}/comments`, { method: 'POST', cookie: alexJwt, json: { message: 'from the paste API' } });++ assert.strictEqual(viaApi.status, 201, viaApi.text);++ assert.strictEqual(viaApi.json().comment.user_id, alex.subject_id);++ // Live's adapter: a service with community.paste.write, writing as a person.++ const viaService = await call(`/api/pastes/${pub}/comments`, { method: 'POST', token: net.signService({ cap: ['community.paste.write'] }), headers: { 'x-ov-subject': kim.subject_id }, json: { message: 'from Live', parent_id: Number(m[2]) } });++ assert.strictEqual(viaService.status, 201, viaService.text);++ assert.strictEqual(viaService.json().comment.parent_id, Number(m[2]));++++ const th = (await resolve(pub)).json().thread;++ const got = await call(`/api/v1/comments/threads/${th.id}`);++ assert.strictEqual(got.status, 200, got.text);++ const page = got.json();++ assert.strictEqual(page.thread.comment_count, 3);++ const byMessage = new Map();++ for (const c of page.comments) { byMessage.set(c.message, c); for (const r of c.replies || []) byMessage.set(r.message, r); }++ assert.strictEqual(byMessage.get('from the page form').id, Number(m[2]));++ assert.strictEqual(byMessage.get('from the page form').author.subject, sam.subject_id);++ assert.strictEqual(byMessage.get('from the paste API').id, viaApi.json().comment.id);++ assert.strictEqual(byMessage.get('from Live').parent_id, Number(m[2]));++++ const legacy = (await call(`/api/pastes/${pub}/comments`)).json();++ assert.strictEqual(legacy.total, 3);++ assert.deepStrictEqual(legacy.comments.map((c) => c.message), ['from the paste API', 'from the page form'], 'newest top-level first');++ assert.strictEqual(legacy.comments[1].replies[0].message, 'from Live');++ assert.strictEqual(await legacyRows(), 0, 'nothing is written to paste_comments');++++ // The paste page shows the same thread: comments with their anchors, sorting, the no-JS form.++ const pageHtml = await call(`/p/${pub}`, { cookie: samJwt });++ assert.strictEqual(pageHtml.status, 200);++ for (const want of ['from the page form', 'from the paste API', 'from Live', `id="comment-${m[2]}"`, `id="comment-${viaApi.json().comment.id}"`,++ `action="/p/${pub}/comments"`, `href="/p/${pub}?sort=old"`]) assert.ok(pageHtml.text.includes(want), want);++ assert.ok(pageHtml.text.indexOf('from the paste API') < pageHtml.text.indexOf('from the page form'), 'newest first');++ const oldest = await call(`/p/${pub}?sort=old`);++ assert.ok(oldest.text.indexOf('from the page form') < oldest.text.indexOf('from the paste API'), '?sort=old: oldest first');++ assert.ok(oldest.text.includes(`/auth/login?next=${encodeURIComponent(`/p/${pub}`)}`) && !oldest.text.includes(`action="/p/${pub}/comments"`), 'signed out: a sign-in link, no form');++ const older = await call(`/p/${pub}?sort=new&after=${viaApi.json().comment.id}`);++ assert.ok(older.text.includes('from the page form') && !older.text.includes('from the paste API'), 'paged with ?after=');++++ // Deleting a comment twice succeeds both times, as before.++ const del = await call(`/api/pastes/${pub}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'short-lived' } });++ for (let i = 0; i < 2; i++) assert.strictEqual((await call(`/api/pastes/${pub}/comments/${del.json().comment.id}`, { method: 'DELETE', cookie: samJwt })).status, 200);++ assert.ok(!(await call(`/p/${pub}`)).text.includes('short-lived'));++ });++++ await check('the form: signed in only, never from another site', async () => {++ const anon = await call(`/p/${pub}/comments`, { method: 'POST', form: { message: 'hi' } });++ assert.strictEqual(anon.status, 303);++ assert.strictEqual(anon.headers.get('location'), `/auth/login?next=${encodeURIComponent(`/p/${pub}`)}`);++ const foreign = await call(`/p/${pub}/comments`, { method: 'POST', cookie: samJwt, headers: { origin: 'https://evil.example' }, form: { message: 'hi' } });++ assert.strictEqual(foreign.status, 403);++ const empty = await call(`/p/${pub}/comments`, { method: 'POST', cookie: samJwt, form: { message: ' ' } });++ assert.strictEqual(empty.status, 400);++ assert.strictEqual((await call('/p/no-such-paste-here/comments', { method: 'POST', cookie: samJwt, form: { message: 'hi' } })).status, 404);++ });++++ await check('a private paste\'s comments stay with its owner: the paste API, resolve, the thread\'s access id and the form', async () => {++ const priv = await create({ content: 'private paste', visibility: 'private' });++ assert.strictEqual((await call(`/api/pastes/${priv}/comments`, { method: 'POST', cookie: alexJwt, json: { message: 'owner only note' } })).status, 201);++ const own = (await resolve(priv, { cookie: alexJwt })).json().thread;++ assert.ok(own && /^cth_/.test(own.id));++ assert.ok((await call(`/api/v1/comments/threads/${own.id}`, { cookie: alexJwt })).text.includes('owner only note'));++ for (const who of [{ cookie: samJwt }, {}]) {++ const list = await call(`/api/pastes/${priv}/comments`, who);++ assert.strictEqual(list.status, 404);++ assert.ok(!list.text.includes('owner only note'));++ assert.strictEqual((await resolve(priv, who)).status, 404);++ const leaked = await call(`/api/v1/comments/threads/${own.id}`, who);++ assert.strictEqual(leaked.status, 404, 'even with the access id');++ assert.ok(!leaked.text.includes('owner only note'));++ assert.strictEqual((await call(`/c/${own.id}`, who)).status, 404);++ }++ assert.ok((await call(`/p/${priv}`, { cookie: alexJwt })).text.includes('owner only note'), 'the owner sees them on the paste page');++ for (const who of [{ cookie: samJwt }, {}]) assert.ok(!(await call(`/p/${priv}`, who)).text.includes('owner only note'));++ assert.strictEqual((await call(`/p/${priv}/comments`, { method: 'POST', cookie: samJwt, form: { message: 'let me in' } })).status, 404);++ assert.strictEqual((await call(`/api/pastes/${priv}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'let me in' } })).status, 404);++ });++++ await check('an unlisted paste made private later, and a burned paste, take their thread with them', async () => {++ const unl = await create({ content: 'unlisted paste', visibility: 'unlisted' });++ const th = (await resolve(unl, { cookie: samJwt })).json().thread;++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'seen by link' } })).status, 201);++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: samJwt })).status, 200);++ assert.strictEqual((await call(`/api/pastes/${unl}`, { method: 'PUT', cookie: alexJwt, json: { visibility: 'private' } })).status, 200);++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: samJwt })).status, 404);++ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: alexJwt })).status, 200, 'the owner still sees it');++++ const burn = await create({ content: 'read me once', burn_after_read: true });++ const bth = (await resolve(burn, { cookie: samJwt })).json().thread;++ assert.strictEqual((await call(`/api/v1/comments/threads/${bth.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'before the burn' } })).status, 201);++ assert.strictEqual((await call(`/api/pastes/${burn}`, { cookie: samJwt })).status, 200);++ assert.strictEqual((await call(`/api/pastes/${burn}`, { cookie: samJwt })).status, 410, 'burned');++ for (const who of [{ cookie: samJwt }, { cookie: alexJwt }, {}]) {++ const r = await call(`/api/v1/comments/threads/${bth.id}`, who);++ assert.strictEqual(r.status, 404);++ assert.ok(!r.text.includes('before the burn'));++ }++ });++++ await check('migration 0004 copies paste_comments onto the thread once, however often it runs', async () => {++ const slug = await create({ content: 'a paste from before' });++ const pid = (await t.db.prepare('SELECT id FROM pastes WHERE slug = ?').get(slug)).id;++ // Rows as the old paste API left them: a comment, a reply to it, an anonymous one, a deleted one.++ await t.db.prepare(`INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message, is_deleted, created_at, updated_at) VALUES++ (?, ?, NULL, NULL, 'old comment', 0, '2025-01-02 03:04:05', '2025-01-02 03:04:05')`).run(pid, sam.subject_id);++ const top = (await t.db.prepare("SELECT id FROM paste_comments WHERE message = 'old comment'").get()).id;++ await t.db.prepare(`INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message, is_deleted, created_at, updated_at) VALUES++ (?, ?, NULL, ?, 'old reply', 0, '2025-01-02 04:00:00', '2025-01-02 04:00:00'),++ (?, NULL, 'Visitor', NULL, 'old anonymous', 0, '2025-01-03 00:00:00', '2025-01-03 00:00:00'),++ (?, ?, NULL, NULL, 'old deleted', 1, '2025-01-04 00:00:00', '2025-01-05 00:00:00')`).run(pid, alex.subject_id, top, pid, pid, sam.subject_id);++ const legacy = await t.db.prepare('SELECT * FROM paste_comments WHERE paste_id = ? ORDER BY id').all(pid);++ assert.strictEqual(legacy.length, 4);++++ const sql = fs.readFileSync(MIGRATION, 'utf8');++ await t.db.exec(sql);++ await t.db.exec(sql);++++ const mapped = await t.db.prepare("SELECT source_id, target_id FROM legacy_id_map WHERE source_system = 'community' AND source_type = 'paste_comment'").all();++ assert.strictEqual(mapped.length, Number((await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n), 'one map row per paste_comments row');++ const newId = new Map(mapped.map((r) => [Number(r.source_id), Number(r.target_id)]));++ const threads = await t.db.prepare("SELECT * FROM comment_threads WHERE ref_service = 'community' AND ref_type = 'paste' AND ref_id = ?").all(slug);++ assert.strictEqual(threads.length, 1);++ assert.match(threads[0].access_id, /^cth_[A-Za-z0-9_-]{22}$/);++ const copied = await t.db.prepare('SELECT * FROM comments WHERE thread_id = ? ORDER BY id').all(threads[0].id);++ assert.strictEqual(copied.length, 4, 'each row copied exactly once');++ for (const old of legacy) {++ const c = copied.find((x) => Number(x.id) === newId.get(Number(old.id)));++ assert.ok(c, `row ${old.id} copied`);++ assert.strictEqual(c.author_subject, old.author_subject);++ assert.strictEqual(c.anon_name, old.anon_name);++ assert.strictEqual(c.created_at, old.created_at);++ assert.strictEqual(c.origin, 'user');++ assert.strictEqual(c.parent_id == null ? null : Number(c.parent_id), old.parent_id == null ? null : newId.get(Number(old.parent_id)));++ if (Number(old.is_deleted)) { assert.strictEqual(c.deleted_at, old.updated_at); assert.strictEqual(c.message, ''); } else { assert.strictEqual(c.deleted_at, null); assert.strictEqual(c.message, old.message); }++ }++ assert.strictEqual(Number(threads[0].comment_count), 3);++ assert.strictEqual(Number(copied.find((x) => Number(x.id) === newId.get(Number(top))).reply_count), 1);++++ // Served from the thread, by the paste API and the comments API alike; new comments get new ids.++ const list = (await call(`/api/pastes/${slug}/comments`)).json();++ assert.strictEqual(list.total, 3);++ assert.deepStrictEqual(list.comments.map((c) => c.message), ['old anonymous', 'old comment']);++ assert.strictEqual(list.comments[1].replies[0].message, 'old reply');++ const fresh = await call(`/api/pastes/${slug}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'after the move', parent_id: newId.get(Number(top)) } });++ assert.strictEqual(fresh.status, 201, fresh.text);++ assert.ok(fresh.json().comment.id > Math.max(...newId.values()));++ const page = (await call(`/api/v1/comments/threads/${threads[0].access_id}`)).json();++ assert.strictEqual(page.thread.comment_count, 4);++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -132,13 +132,13 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR}}// Read-like writes: like, copy, fork, comment, delete a comment — 404 like a missing paste, nothing written.-- const before = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n;++ const before = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM 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(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n;++ const after = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM 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) {
@@ -119,6 +119,7 @@ Pages (server-rendered HTML):
@@ -119,6 +119,7 @@ Pages (server-rendered HTML):
| `GET /` | Home: hero, latest pastes, most viewed, "start a paste" CTA, what is coming |
| `GET /` | Home: hero, latest pastes, most viewed, "start a paste" CTA, what is coming |
| `GET /pastes` | Browse — `?q=` search, `?sort=new\|views`, `?lang=`, `?page=` |
| `GET /pastes` | Browse — `?q=` search, `?sort=new\|views`, `?lang=`, `?page=` |
| `GET /p/:slug` | Paste page — highlighted body (server-side, highlight.js), raw/download/copy/fork/share, screenshot, related |
| `GET /p/:slug` | Paste page — highlighted body (server-side, highlight.js), raw/download/copy/fork/share, screenshot, related |
+| `POST /p/:slug/comments` | Comment on the paste (signed in; the no-JS form) — its typed comment thread, the one `/c/:accessId` and the APIs show |
| `GET /p/:slug/raw` | `live`: 302 → `https://openvibe.media/p/:slug/raw`; `community`: the text itself |
| `GET /p/:slug/raw` | `live`: 302 → `https://openvibe.media/p/:slug/raw`; `community`: the text itself |
| `GET /p/:slug/screenshot` | `live`: 302 → Media's `/p/:slug/screenshot`; `community`: 302 → the stored image URL |
| `GET /p/:slug/screenshot` | `live`: 302 → Media's `/p/:slug/screenshot`; `community`: 302 → the stored image URL |
| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
@@ -220,7 +221,17 @@ Who may do what:
@@ -220,7 +221,17 @@ Who may do what:
given.
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** are the paste's typed thread (`community`/`paste`/`<slug>`): the paste page
+ (`/p/:slug` shows it newest first, `?sort=old` and `?after=<last comment id>` as links, `#comment-<id>`
+ anchors; `POST /p/:slug/comments`, signed in, the no-JS form; none on burn-after-read pastes), `/api/pastes/:slug/comments` (GET, POST and
+ DELETE keep their old shapes and rules: anonymous comments with a name, the author, the paste's
+ owner or staff delete; Live's adapter calls them as the person) and `/api/v1/comments` all read and
+ write that one thread. A paste thread follows the paste: private ones are their owner's and staff's
+ only, deleted and burned ones nobody's (404 on every call, by access id too, so a paste made private
+ later takes its comments with it); unlisted ones are open to whoever has the link. Migration
+ `0004_paste_comments_to_threads.sql` copied the old `paste_comments` rows onto the threads (ledger
+ `legacy_id_map`, `community`/`paste_comment` → `comment`); nothing writes `paste_comments` any more and
+ it stays, read-only, for one release.
- Comments carry `edited_at` (null until the author edits) and `can_edit` / `can_delete` for the
- Comments carry `edited_at` (null until the author edits) and `can_edit` / `can_delete` for the
viewer.
viewer.
@@ -735,7 +746,7 @@ server/
@@ -735,7 +746,7 @@ server/
pastes/proxy.js (removed: the /api/pastes proxy to Live is gone)
pastes/proxy.js (removed: the /api/pastes proxy to Live is gone)
pastes/api.js native /api/pastes/*
pastes/api.js native /api/pastes/*
pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes
pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes
- pastes/store.js pure SQL over pastes / versions / likes / comments / projections
+ pastes/store.js pure SQL over pastes / versions / likes / projections (comments: comments/store.js)
pastes/source.js where pages read pastes from (the store)
pastes/source.js where pages read pastes from (the store)
pastes/catalog.js recent public pastes: trending, related, language filter
pastes/catalog.js recent public pastes: trending, related, language filter
comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
@@ -771,8 +782,8 @@ docs/discord-relay.md the Discord relay: how it works, owner steps, staff API,
@@ -771,8 +782,8 @@ docs/discord-relay.md the Discord relay: how it works, owner steps, staff API,
Spaces per streamer, game and project (with membership), the Discord relay's production round
Spaces per streamer, game and project (with membership), the Discord relay's production round
trip (owner steps in [docs/discord-relay.md](docs/discord-relay.md)), visibility changes for
trip (owner steps in [docs/discord-relay.md](docs/discord-relay.md)), visibility changes for
-comment threads arriving through Events, and moving paste comments onto the typed comment
-threads. [Submissions](#submissions) are here: send yours at [/submissions](https://openvibe.community/submissions).
+comment threads arriving through Events, and dropping the read-only `paste_comments` table a
+release after paste comments moved onto the typed comment threads. [Submissions](#submissions) are here: send yours at [/submissions](https://openvibe.community/submissions).
## Related services
## Related services
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+-- phase: migrate
+-- T10 lane E job 5: paste comments move onto the typed comment threads (server/comments). From this release
+-- the paste API, the paste page and /api/v1/comments all read and write the thread of ref
+-- community/paste/<slug>; this copies what paste_comments holds into it so there is a single source.
+--
+-- Each paste_comments row becomes one comments row on its paste's thread (opened here when the paste has
+-- none): the author subject (a pre-subject Network user id goes through legacy_id_map network/user), the
+-- anonymous name, the message, created_at and updated_at; a deleted row stays deleted (deleted_at =
+-- its updated_at) with its message scrubbed, as the comment store deletes. Replies keep their parent.
+-- legacy_id_map records every copy (community/paste_comment/<old id> → comment/<new id>): that is what
+-- makes this safe to run again (copied rows are skipped) and how a reply finds its parent's new id.
+--
+-- paste_comments is not dropped: nothing writes it any more, and it stays read-only for one release.
+-- Owner check, before and after (the two numbers match once this has run):
+-- SELECT (SELECT COUNT(*) FROM paste_comments) AS paste_comments,
+-- (SELECT COUNT(*) FROM legacy_id_map WHERE source_system = 'community' AND source_type = 'paste_comment') AS migrated;
+
+-- A thread for every paste with comments not copied yet. Its access id is cth_ + 22 base64url characters
+-- (16 random bytes, as server/db.js makes them).
+INSERT INTO comment_threads (ref_service, ref_type, ref_id, created_at, updated_at, access_id)
+SELECT 'community', 'paste', p.slug, MIN(pc.created_at), MAX(pc.updated_at),
+ 'cth_' || rtrim(translate(encode(uuid_send(gen_random_uuid()), 'base64'), '+/', '-_'), '=')
+FROM paste_comments pc
+JOIN pastes p ON p.id = pc.paste_id
+WHERE NOT EXISTS (SELECT 1 FROM legacy_id_map m WHERE m.source_system = 'community' AND m.source_type = 'paste_comment' AND m.source_id = pc.id::text)
+GROUP BY p.slug
+ON CONFLICT (ref_service, ref_type, ref_id) DO NOTHING;
+
+-- The rows not copied yet, each with its new comment id taken from the comments sequence in the old id
+-- order; the map rows and the comments go in together, in one statement. A reply's parent is either
+-- copied in this same run (todo) or in an earlier one (legacy_id_map as it was before this statement: a
+-- data-modifying WITH runs once, to completion, and the rest of the statement does not see its rows).
+WITH todo AS (
+ SELECT s.*, nextval(pg_get_serial_sequence('comments', 'id')) AS new_id
+ FROM (SELECT pc.id, pc.parent_id, pc.author_subject, pc.anon_name, pc.message, pc.is_deleted, pc.created_at, pc.updated_at, p.slug
+ FROM paste_comments pc
+ JOIN pastes p ON p.id = pc.paste_id
+ WHERE NOT EXISTS (SELECT 1 FROM legacy_id_map m WHERE m.source_system = 'community' AND m.source_type = 'paste_comment' AND m.source_id = pc.id::text)
+ ORDER BY pc.id) s
+), mapped AS (
+ INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id)
+ SELECT 'community', 'paste_comment', todo.id::text, 'comment', todo.new_id::text FROM todo
+)
+INSERT INTO comments (id, thread_id, parent_id, author_subject, anon_name, origin, message, created_at, updated_at, deleted_at)
+OVERRIDING SYSTEM VALUE
+SELECT todo.new_id, th.id, COALESCE(tp.new_id, mp.target_id::bigint),
+ COALESCE(um.target_id, todo.author_subject), todo.anon_name, 'user',
+ CASE WHEN todo.is_deleted <> 0 THEN '' ELSE todo.message END,
+ todo.created_at, todo.updated_at,
+ CASE WHEN todo.is_deleted <> 0 THEN todo.updated_at END
+FROM todo
+JOIN comment_threads th ON th.ref_service = 'community' AND th.ref_type = 'paste' AND th.ref_id = todo.slug
+LEFT JOIN todo tp ON tp.id = todo.parent_id
+LEFT JOIN legacy_id_map mp ON mp.source_system = 'community' AND mp.source_type = 'paste_comment' AND mp.source_id = todo.parent_id::text
+LEFT JOIN legacy_id_map um ON um.source_system = 'network' AND um.source_type = 'user' AND um.target_type = 'subject'
+ AND um.source_id = todo.author_subject AND todo.author_subject ~ '^[0-9]+$';
+
+-- Counters from the rows, as the comment store keeps them (deleted comments not counted).
+UPDATE comments c SET reply_count = (SELECT COUNT(*) FROM comments r WHERE r.parent_id = c.id AND r.deleted_at IS NULL)
+WHERE c.parent_id IS NULL AND c.thread_id IN (SELECT id FROM comment_threads WHERE ref_service = 'community' AND ref_type = 'paste');
+UPDATE comment_threads t SET comment_count = (SELECT COUNT(*) FROM comments c WHERE c.thread_id = t.id AND c.deleted_at IS NULL)
+WHERE t.ref_service = 'community' AND t.ref_type = 'paste';
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
* GET / home ALL /api/pastes/* the native paste API (pastes/api.js)
* GET / home ALL /api/pastes/* the native paste API (pastes/api.js)
* GET /pastes browse /api/v1/comments/* typed comment threads (comments/api.js)
* GET /pastes browse /api/v1/comments/* typed comment threads (comments/api.js)
* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)
* GET /p/:slug paste /api/v1/spaces/*, /api/v1/posts/* forum (forum/api.js)
+ * POST /p/:slug/comments comment on it (its typed thread)
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/raw raw text /api/v1/pulse/* Pulse (pulse/api.js)
* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
* GET /p/:slug/screenshot → image /api/v1/relay/* Discord relay admin (relay/api.js)
* GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)
* GET /p/:slug/download GET /api/health, /api/ready, /release.json, /metrics (loopback)
@@ -65,6 +66,7 @@ const { createIndexNow } = require('openvibe-shared/indexnow');
@@ -65,6 +66,7 @@ const { createIndexNow } = require('openvibe-shared/indexnow');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', 'public');
const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
const SLUG_RE = /^[A-Za-z0-9_-]{1,80}$/;
+const pasteRef = (slug) => ({ service: 'community', type: 'paste', id: String(slug) });
const VERSION = require('../package.json').version;
const VERSION = require('../package.json').version;
async function createApp(opts = {}) {
async function createApp(opts = {}) {
@@ -314,7 +316,36 @@ async function createApp(opts = {}) {
@@ -314,7 +316,36 @@ async function createApp(opts = {}) {
throw err;
throw err;
}
}
const related = await catalog.related(paste, 6);
const related = await catalog.related(paste, 6);
- html(res, pages.pastePage({ paste, related, user: req.user }));
+ // Its comments are the paste's typed thread (community/paste/<slug>), the one /c/:accessId and the
+ // comments API show, sorted (?sort=old|new) and paged (?after=<last comment id>) as there; null while
+ // nobody has commented. The comment service applies the paste's visibility again.
+ // A burn-after-read paste is gone once shown, so it has no comment panel.
+ const after = /^\d{1,15}$/.test(String(req.query.after || '')) ? String(req.query.after) : null;
+ const sort = req.query.sort === 'old' ? 'old' : 'new';
+ const thread = paste.burn_after_read ? null : await comments.forRef(req.viewer, pasteRef(paste.slug), { sort, after, limit: 30 });
+ html(res, pages.pastePage({ paste, related, user: req.user, comments: thread, commentSort: sort, commentsAfter: after }));
+ }));
+
+ // No-JS comment form: signed in, the cookie (SameSite=Lax) and no Origin from another site, as on /c/:accessId.
+ // The thread opens on the first comment; the comment service decides the rest (visibility, lock, blocks, limits).
+ app.post('/p/:slug/comments', withUser, express.urlencoded({ extended: false, limit: '64kb' }), wrap(async (req, res, next) => {
+ const { slug } = req.params;
+ const origin = req.get('origin');
+ if (origin && origin !== 'null' && origin !== config.baseUrl) return html(res, pages.errorPage({ status: 403, title: 'Not allowed', message: 'That form was sent from another site.' }), 403);
+ const back = `/p/${encodeURIComponent(slug)}`;
+ try {
+ if (!SLUG_RE.test(slug)) throw new v1.ApiError(404, 'ref.not_found', 'No such paste');
+ if (!req.viewer || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(back)}`);
+ const { thread } = await comments.resolve(req.viewer, { ref: pasteRef(slug) });
+ const b = req.body || {};
+ const out = await comments.add(req.viewer, thread.id, { message: String(b.message || '').slice(0, 5000), parent_id: b.parent_id || null });
+ return res.redirect(303, `${back}#comment-${out.comment.id}`);
+ } catch (err) {
+ if (!(err instanceof v1.ApiError)) return next(err);
+ if (err.status === 401) return res.redirect(303, `/auth/login?next=${encodeURIComponent(back)}`);
+ if (err.status === 404) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }), 404);
+ return html(res, pages.errorPage({ status: err.status, title: err.status === 429 ? 'Slow down' : 'That did not work', message: err.message }), err.status);
+ }
}));
}));
// Raw text is served here, the screenshot link is the stored Media URL.
// Raw text is served here, the screenshot link is the stored Media URL.
@@ -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). Browsers
+ * - read: anyone, except hidden threads (moderators only; everyone else gets 404) and threads of
+ * Community pastes the viewer may not see (pastes/service.js's rule: a private paste is its
+ * owner's and staff's only, a deleted or burned one nobody's — 404, checked on every call, so a
+ * paste made private later takes its thread with it). Browsers
* address a thread only by its unguessable access_id (cth_…, what resolve hands them as
* 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
* `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.
* the refs (unlisted paste slugs, private pages) and comments of entities they were not given.
@@ -128,24 +131,31 @@ function createCommentService({ db, network = null, limits = {} } = {}) {
@@ -128,24 +131,31 @@ function createCommentService({ db, network = null, limits = {} } = {}) {
}
}
/** 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 visible(v, t) {
- if (!t || (t.visibility === 'hidden' && !moderator(v))) fail(404, 'thread.not_found', 'Comment thread not found');
+ async function visible(v, t) {
+ if (!t || (t.visibility === 'hidden' && !moderator(v)) || !await entityVisible(v, t)) fail(404, 'thread.not_found', 'Comment thread not found');
return t;
return t;
}
}
- const visibleThread = async (v, id) => visible(v, await threadById(v, id));
+ const visibleThread = async (v, id) => await visible(v, await threadById(v, id));
async function visibleComment(v, id) {
async function visibleComment(v, id) {
const c = /^\d{1,15}$/.test(String(id)) ? await store.getComment(db, Number(id)) : null;
const c = /^\d{1,15}$/.test(String(id)) ? await 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: visible(v, await store.getThread(db, c.thread_id)) };
+ return { comment: c, thread: await visible(v, await store.getThread(db, c.thread_id)) };
}
}
- /** Community's own entities must exist (and be visible) before anyone opens a thread on them. */
- async function checkCommunityRef(ref) {
+ /** The paste service's rule: a paste that is gone (deleted, burned) is nobody's; a private one its owner's and staff's. */
+ async function pasteVisibleTo(v, slug) {
+ const p = await pasteStore.getBySlug(db, String(slug));
+ return !!p && (p.visibility !== 'private' || !!(v && (v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject))));
+ }
+ /** Whether the viewer may see the entity a thread belongs to (only Community's pastes can be checked here). */
+ const entityVisible = async (v, t) => !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);
+
+ /** Community's own entities must exist (and be visible to the viewer) before anyone opens a thread on them. */
+ async function checkCommunityRef(ref, v) {
if (ref.service !== 'community') return;
if (ref.service !== 'community') return;
if (ref.type === 'paste') {
if (ref.type === 'paste') {
- const p = await pasteStore.getBySlug(db, ref.id);
- if (!p || p.visibility === 'private') fail(404, 'ref.not_found', 'No such paste');
+ if (!await pasteVisibleTo(v, ref.id)) fail(404, 'ref.not_found', 'No such paste');
} else if (ref.type === 'post') {
} else if (ref.type === 'post') {
const ok = /^\d{1,15}$/.test(ref.id) && await db.prepare(`SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id
const ok = /^\d{1,15}$/.test(ref.id) && await db.prepare(`SELECT 1 FROM posts p JOIN threads t ON t.id = p.thread_id JOIN spaces s ON s.id = t.space_id
WHERE p.id = ? AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'`).get(Number(ref.id));
WHERE p.id = ? AND p.deleted_at IS NULL AND t.deleted_at IS NULL AND s.visibility = 'public'`).get(Number(ref.id));
@@ -189,13 +199,25 @@ function createCommentService({ db, network = null, limits = {} } = {}) {
@@ -189,13 +199,25 @@ function createCommentService({ db, network = null, limits = {} } = {}) {
const types = BROWSER_REF_TYPES[ref.service];
const types = BROWSER_REF_TYPES[ref.service];
if (!types || !types.includes(ref.type)) fail(403, 'ref.type_not_allowed', `Comments on ${ref.service}/${ref.type} can only be opened by that service`);
if (!types || !types.includes(ref.type)) fail(403, 'ref.type_not_allowed', `Comments on ${ref.service}/${ref.type} can only be opened by that service`);
}
}
- await checkCommunityRef(ref);
+ await checkCommunityRef(ref, v);
// Labels are display caches; only a service's word is taken for them.
// Labels are display caches; only a service's word is taken for them.
const label = v.kind === 'service' && ref.label ? String(ref.label).slice(0, 200) : null;
const label = v.kind === 'service' && ref.label ? String(ref.label).slice(0, 200) : null;
const { thread, created } = await store.resolveThread(db, ref, { label, createdBy: v.subject || (v.kind === 'service' ? v.service : null) });
const { thread, created } = await store.resolveThread(db, ref, { label, createdBy: v.subject || (v.kind === 'service' ? v.service : null) });
return { thread: shapeThread(thread, v), created };
return { thread: shapeThread(thread, v), created };
},
},
+ /**
+ * The thread of an entity as the entity's own page shows it (Community's paste page): get() found by
+ * ref, after the same checks as resolve; reading never opens a thread, so null while nobody has
+ * commented, and null for a hidden thread to everyone but moderators.
+ */
+ async forRef(v, ref, q = {}) {
+ await checkCommunityRef(ref, v);
+ const t = await store.getThreadByRef(db, ref);
+ if (!t || (t.visibility === 'hidden' && !moderator(v))) return null;
+ return await this.get(v, t.access_id, q);
+ },
+
/**
/**
* GET /threads/:id — the thread and one page of top-level comments (replies nested, up to
* GET /threads/:id — the thread and one page of top-level comments (replies nested, up to
* REPLY_PAGE each). ?after=<last comment id>&sort=old|new&limit=; ?parent=<id> pages one
* REPLY_PAGE each). ?after=<last comment id>&sort=old|new&limit=; ?parent=<id> pages one
@@ -132,4 +132,15 @@ async function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
@@ -132,4 +132,15 @@ async function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
return { rows, hasMore };
return { rows, hasMore };
}
}
-module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, editComment, softDeleteComment, listTopLevel, listReplies };
+/**
+ * Top-level comments newest first by offset, each with all its replies (oldest first); deleted ones left
+ * out, with their replies. The paste API's list (pastes/service.js keeps its old response shape).
+ */
+async function listNewest(db, threadId, { limit = 50, offset = 0 } = {}) {
+ const top = await db.prepare('SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?').all(threadId, limit, offset);
+ const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC');
+ for (const c of top) { c.replies = await replies.all(c.id); c.reply_count = c.replies.length; }
+ return top;
+}
+
+module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, editComment, softDeleteComment, listTopLevel, listReplies, listNewest };
@@ -85,8 +85,11 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -85,8 +85,11 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
}
}
// Pastes: gone, unless someone else commented.
// Pastes: gone, unless someone else commented.
if (await hasTable(db, 'pastes')) {
if (await hasTable(db, 'pastes')) {
- for (const p of await db.prepare(`SELECT id FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {
- const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects);
+ for (const p of await db.prepare(`SELECT id, slug FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {
+ // Comments are on the paste's typed thread; paste_comments still holds the rows from before migration 0004.
+ const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects)
+ || (await hasTable(db, 'comments') && await db.prepare(`SELECT 1 FROM comments c JOIN comment_threads t ON t.id = c.thread_id
+ WHERE t.ref_service = 'community' AND t.ref_type = 'paste' AND t.ref_id = ? AND (c.author_subject IS NULL OR c.author_subject NOT IN ${S}) LIMIT 1`).get(p.slug, ...subjects));
if (others) {
if (others) {
await db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,
await db.prepare(`UPDATE pastes SET owner_subject = NULL, title = ?, content = '', screenshot_url = NULL, media_ref = NULL, stream_ref = NULL, metadata = NULL,
ai_summary = NULL, ai_tags = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
ai_summary = NULL, ai_tags = NULL, deleted_at = COALESCE(deleted_at, ?), updated_at = ? WHERE id = ?`).run(TOMBSTONE, now, now, p.id);
@@ -18,6 +18,8 @@
@@ -18,6 +18,8 @@
const crypto = require('crypto');
const crypto = require('crypto');
const events = require('../events');
const events = require('../events');
const store = require('./store');
const store = require('./store');
+const commentStore = require('../comments/store');
+const { discussionModerator } = require('../identity/capabilities');
const { stripImageMetadata } = require('../media/strip-metadata');
const { stripImageMetadata } = require('../media/strip-metadata');
const { capabilities } = require('openvibe-contracts');
const { capabilities } = require('openvibe-contracts');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
@@ -91,6 +93,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -91,6 +93,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const limited = (v) => !!(v && v.subject);
const limited = (v) => !!(v && v.subject);
const hasCap = (v, cap) => !!(v && v.kind === 'service' && capabilities.check(v.claims, cap).allowed);
const hasCap = (v, cap) => !!(v && v.kind === 'service' && capabilities.check(v.claims, cap).allowed);
const isService = (v) => !!(v && v.kind === 'service');
const isService = (v) => !!(v && v.kind === 'service');
+ const pasteRef = (p) => ({ service: 'community', type: 'paste', id: p.slug });
const needIdentity = (v) => { if (!v || (!v.subject && !v.staff)) fail(401, 'Authentication required'); };
const needIdentity = (v) => { if (!v || (!v.subject && !v.staff)) fail(401, 'Authentication required'); };
/**
/**
@@ -178,14 +181,15 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -178,14 +181,15 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
}
}
async function shapeOne(row, v) { return (await shapeMany([row], v))[0]; }
async function shapeOne(row, v) { return (await shapeMany([row], v))[0]; }
- function shapeComment(c, projections) {
+ /** A typed comment (comments/store.js) in the paste API's old shape: the paste's id, is_deleted, user_id = the author's subject. */
+ function shapeComment(c, p, projections) {
const out = {
const out = {
- id: c.id, paste_id: c.paste_id, user_id: c.author_subject || null, author_subject: c.author_subject || null,
- parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.is_deleted,
+ id: c.id, paste_id: p.id, user_id: c.author_subject || null, author_subject: c.author_subject || null,
+ parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.deleted_at ? 1 : 0,
created_at: c.created_at, updated_at: c.updated_at,
created_at: c.created_at, updated_at: c.updated_at,
- ...authorFields(c.author_subject, 'user', projections),
+ ...authorFields(c.author_subject, c.origin, projections),
};
};
- if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, projections)); out.reply_count = c.reply_count; }
+ if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, p, projections)); out.reply_count = c.reply_count; }
return out;
return out;
}
}
@@ -542,58 +546,73 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -542,58 +546,73 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { copies: await store.incrementCopies(db, p.id) };
return { copies: await store.incrementCopies(db, p.id) };
},
},
- /** GET /api/pastes/:slug/comments */
+ /**
+ * Paste comments live on the paste's typed comment thread (ref community/paste/<slug>, comments/store.js),
+ * the one /p/:slug, /c/:accessId and /api/v1/comments show; these routes keep their old shapes and rules.
+ * A hidden thread lists nothing and, like a locked one, takes comments from moderators only.
+ *
+ * GET /api/pastes/:slug/comments
+ */
async comments(v, slug, q = {}) {
async comments(v, slug, q = {}) {
const p = await visible(v, slug);
const p = await visible(v, slug);
const limit = Math.min(parseInt(q.limit || '50', 10) || 50, 100);
const limit = Math.min(parseInt(q.limit || '50', 10) || 50, 100);
const offset = Math.max(parseInt(q.offset || '0', 10) || 0, 0);
const offset = Math.max(parseInt(q.offset || '0', 10) || 0, 0);
- const list = await store.listComments(db, p.id, limit, offset);
+ const t = await commentStore.getThreadByRef(db, pasteRef(p));
+ if (!t || (t.visibility === 'hidden' && !discussionModerator(v))) return { comments: [], total: 0 };
+ const list = await commentStore.listNewest(db, t.id, { limit, offset });
const subjects = [];
const subjects = [];
for (const c of list) { subjects.push(c.author_subject); for (const r of c.replies) subjects.push(r.author_subject); }
for (const c of list) { subjects.push(c.author_subject); for (const r of c.replies) subjects.push(r.author_subject); }
const projections = await projectionsFor(subjects);
const projections = await projectionsFor(subjects);
- return { comments: list.map((c) => shapeComment(c, projections)), total: await store.countComments(db, p.id) };
+ return { comments: list.map((c) => shapeComment(c, p, projections)), total: Number(t.comment_count) };
},
},
/** POST /api/pastes/:slug/comments — anonymous comments allowed (with a name). */
/** POST /api/pastes/:slug/comments — anonymous comments allowed (with a name). */
async addComment(v, slug, body = {}, ctx = {}) {
async addComment(v, slug, body = {}, ctx = {}) {
const p = await visible(v, slug);
const p = await visible(v, slug);
- const author = v.subject || null;
- if (!author && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');
+ const origin = v.origin === 'ai' ? 'ai' : 'user';
+ const author = origin === 'ai' ? null : (v.subject || null);
+ if (!author && origin !== 'ai' && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');
const message = String(body.message || '').trim();
const message = String(body.message || '').trim();
if (!message) fail(400, 'Comment cannot be empty');
if (!message) fail(400, 'Comment cannot be empty');
if (message.length > L.commentMaxLength) fail(400, `Comment must be under ${L.commentMaxLength} characters`);
if (message.length > L.commentMaxLength) fail(400, `Comment must be under ${L.commentMaxLength} characters`);
let anonName = null;
let anonName = null;
- if (!author) {
+ if (!author && origin !== 'ai') {
anonName = String(body.anon_name || '').trim().substring(0, 32) || 'Anonymous';
anonName = String(body.anon_name || '').trim().substring(0, 32) || 'Anonymous';
anonName = anonName.replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';
anonName = anonName.replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';
}
}
const rateKey = author ? `s:${author}` : `ip:${ctx.ip || 'unknown'}`;
const rateKey = author ? `s:${author}` : `ip:${ctx.ip || 'unknown'}`;
if (limited(v)) commentRateCheck(rateKey, message);
if (limited(v)) commentRateCheck(rateKey, message);
+ let t = await commentStore.getThreadByRef(db, pasteRef(p));
+ if (t && t.visibility !== 'public' && !discussionModerator(v)) fail(403, 'This comment thread is locked', { code: 'thread.locked' });
const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;
const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;
let parent = null;
let parent = null;
if (parentId) {
if (parentId) {
- parent = await store.getComment(db, parentId);
- if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');
+ parent = Number.isSafeInteger(parentId) ? await commentStore.getComment(db, parentId) : null;
+ if (!parent || !t || parent.thread_id !== t.id || parent.deleted_at) fail(400, 'Invalid parent comment');
if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');
if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');
}
}
// Platform blocks: no comment on a paste whose owner blocked you, no reply to a comment whose author did.
// Platform blocks: no comment on a paste whose owner blocked you, no reply to a comment whose author did.
if (author && await blocks.hasBlocked(db, p.owner_subject, author)) fail(403, 'You cannot comment on this paste: its owner blocked you', { code: 'community.blocked' });
if (author && await blocks.hasBlocked(db, p.owner_subject, author)) fail(403, 'You cannot comment on this paste: its owner blocked you', { code: 'community.blocked' });
if (author && parent && await blocks.hasBlocked(db, parent.author_subject, author)) fail(403, 'You cannot reply to this comment: its author blocked you', { code: 'community.blocked' });
if (author && parent && await blocks.hasBlocked(db, parent.author_subject, author)) fail(403, 'You cannot reply to this comment: its author blocked you', { code: 'community.blocked' });
- const c = await store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });
+ if (!t) t = (await commentStore.resolveThread(db, pasteRef(p), { createdBy: author || (isService(v) ? v.service : null) })).thread;
+ const c = await commentStore.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });
if (limited(v)) commentRecorded(rateKey, message);
if (limited(v)) commentRecorded(rateKey, message);
- return { comment: shapeComment(c, await projectionsFor([author])) };
+ return { comment: shapeComment(c, p, await projectionsFor([author])) };
},
},
/** DELETE /api/pastes/:slug/comments/:id — the author, the paste's owner, or staff. */
/** DELETE /api/pastes/:slug/comments/:id — the author, the paste's owner, or staff. */
async deleteComment(v, slug, commentId) {
async deleteComment(v, slug, commentId) {
needIdentity(v);
needIdentity(v);
const p = await visible(v, slug);
const p = await visible(v, slug);
- const c = await store.getComment(db, parseInt(commentId, 10));
- if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');
+ const c = /^\d{1,15}$/.test(String(commentId)) ? await commentStore.getComment(db, Number(commentId)) : null;
+ const t = c ? await commentStore.getThread(db, c.thread_id) : null;
+ const ref = pasteRef(p);
+ if (!t || t.ref_service !== ref.service || t.ref_type !== ref.type || t.ref_id !== ref.id) fail(404, 'Comment not found');
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');
if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');
+ if (c.deleted_at) return { message: 'Comment deleted' }; // deleting again is a no-op, as before
await db.tx(async () => {
await db.tx(async () => {
- await store.softDeleteComment(db, c.id);
+ await commentStore.softDeleteComment(db, c.id, v.subject || v.service || null);
if (!isAuthor && !isOwner(v, p)) await events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });
if (!isAuthor && !isOwner(v, p)) await events.moderationAction(v, 'comment.deleted', { type: 'paste_comment', id: String(c.id), owner_subject: c.author_subject || null }, { details: { paste: p.slug } });
});
});
return { message: 'Comment deleted' };
return { message: 'Comment deleted' };
@@ -353,35 +353,6 @@ async function deleteAllForks(db) {
@@ -353,35 +353,6 @@ async function deleteAllForks(db) {
});
});
}
}
-// ── Comments ─────────────────────────────────────────────────
-
-async function createComment(db, { paste_id, author_subject, anon_name, parent_id, message }) {
- const info = await db.prepare('INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message) VALUES (?, ?, ?, ?, ?) RETURNING id')
- .run(paste_id, author_subject || null, anon_name || null, parent_id || null, message);
- return await getComment(db, info.lastInsertRowid);
-}
-
-async function getComment(db, id) {
- return await db.prepare('SELECT * FROM paste_comments WHERE id = ?').get(id) || null;
-}
-
-/** Top-level comments (newest first) with their replies (oldest first); deleted ones hidden. */
-async function listComments(db, pasteId, limit = 50, offset = 0) {
- const top = await db.prepare(`SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL
- ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(pasteId, limit, offset);
- const replies = db.prepare('SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC');
- for (const c of top) { c.replies = await replies.all(c.id); c.reply_count = c.replies.length; }
- return top;
-}
-
-async function countComments(db, pasteId) {
- return (await db.prepare('SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0').get(pasteId)).c;
-}
-
-async function softDeleteComment(db, id) {
- return (await db.prepare('UPDATE paste_comments SET is_deleted = 1, updated_at = ov_now() WHERE id = ?').run(id)).changes;
-}
-
// ── Subject projection (display cache — never authority) ─────
// ── Subject projection (display cache — never authority) ─────
async function getProjections(db, subjectIds) {
async function getProjections(db, subjectIds) {
@@ -433,7 +404,6 @@ module.exports = {
@@ -433,7 +404,6 @@ module.exports = {
getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
setAi, setScreenshot, setVisibility, stats, listForks, deleteAllForks,
setAi, setScreenshot, setVisibility, stats, listForks, deleteAllForks,
- createComment, getComment, listComments, countComments, softDeleteComment,
getProjections, upsertProjection, subjectsByUsername,
getProjections, upsertProjection, subjectsByUsername,
mapGet, mapSet,
mapGet, mapSet,
SLUG_ADJECTIVES, SLUG_NOUNS,
SLUG_ADJECTIVES, SLUG_NOUNS,
@@ -50,32 +50,63 @@ function repliesHtml(c) {
@@ -50,32 +50,63 @@ function repliesHtml(c) {
}
}
/**
/**
- * page: the comment service's answer for this viewer ({ thread, comments, next_cursor, viewer }).
+ * The comment list and the no-JS form, shared by /c/:accessId and the paste page. page: the comment
+ * service's answer for this viewer ({ thread, comments, next_cursor, viewer }), or null while nobody
+ * has commented (the first comment opens the thread). base: the page's own address (paging and
+ * sorting links, where sign-in returns), action: where the form posts. sort: 'new' or 'old' when the
+ * page offers both orders (null: newest first, no sort links). note: HTML after "Commenting as …".
*/
*/
-function threadPage({ accessId, page, user, after = null, error = null, draft = '' }) {
- const { thread, comments, next_cursor: next, viewer } = page;
- const ref = thread.ref;
- const what = refName(ref);
- const product = PRODUCTS[ref.service] || ref.service;
- const label = ref.label || `a ${what} on ${product}`;
- const src = sourceUrl(ref);
- const base = `/c/${accessId}`;
+function commentPanel({ page, user, base, action = base, after = null, sort = null, error = null, draft = '', note = '' }) {
+ const thread = page ? page.thread : null;
+ const comments = page ? page.comments : [];
+ const next = page ? page.next_cursor : null;
+ const viewer = page ? page.viewer : { can_comment: !!user, can_moderate: false };
+ const href = (o = {}) => {
+ const q = new URLSearchParams();
+ if (o.sort === 'old') q.set('sort', 'old');
+ if (o.after) q.set('after', o.after);
+ const qs = q.toString();
+ return qs ? `${base}?${qs}` : base;
+ };
let form = '';
let form = '';
- if (thread.visibility === 'locked' && !viewer.can_moderate) form = '<p class="alert">This comment thread is locked.</p>';
+ if (thread && thread.visibility === 'locked' && !viewer.can_moderate) form = '<p class="alert">This comment thread is locked.</p>';
else if (!user) form = `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to comment.</p>`;
else if (!user) form = `<p class="alert"><a href="/auth/login?next=${encodeURIComponent(base)}">Sign in with your OpenVibe account</a> to comment.</p>`;
else if (viewer.can_comment) {
else if (viewer.can_comment) {
- form = `<form class="paste-form reply-form" method="post" action="${esc(base)}" id="comment">
+ form = `<form class="paste-form reply-form" method="post" action="${esc(action)}" id="comment">
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span>Comment</span><textarea name="message" rows="4" maxlength="2000" required placeholder="Add a comment…">${esc(draft)}</textarea></label>
<label class="field"><span>Comment</span><textarea name="message" rows="4" maxlength="2000" required placeholder="Add a comment…">${esc(draft)}</textarea></label>
- <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-comment" aria-hidden="true"></i> Comment</button><span class="muted small">Commenting as <strong>${esc(user.display_name || user.username || 'you')}</strong>. It shows on ${esc(product)} too.</span></div>
+ <div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-comment" aria-hidden="true"></i> Comment</button><span class="muted small">Commenting as <strong>${esc(user.display_name || user.username || 'you')}</strong>.${note ? ` ${note}` : ''}</span></div>
</form>`;
</form>`;
}
}
+ const tabs = sort && thread && thread.comment_count > 1
+ ? `<nav class="tabs sort-tabs" aria-label="Sort comments">${[['new', 'Newest'], ['old', 'Oldest']].map(([id, label]) => `<a class="tab${id === sort ? ' active' : ''}" href="${esc(href({ sort: id }))}"${id === sort ? ' aria-current="page"' : ''}>${label}</a>`).join('')}</nav>`
+ : '';
const list = comments.length
const list = comments.length
? `<div class="posts">${comments.map(commentHtml).join('\n')}</div>`
? `<div class="posts">${comments.map(commentHtml).join('\n')}</div>`
: `<p class="empty">${after ? 'No older comments.' : 'No comments yet.'}</p>`;
: `<p class="empty">${after ? 'No older comments.' : 'No comments yet.'}</p>`;
- const older = next ? `<nav class="pager" aria-label="Pages"><span></span><a rel="next" href="${esc(`${base}?after=${encodeURIComponent(next)}`)}">Older comments <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a></nav>` : '';
+ const older = next ? `<nav class="pager" aria-label="Pages"><span></span><a rel="next" href="${esc(href({ sort, after: next }))}">${sort === 'old' ? 'More' : 'Older'} comments <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a></nav>` : '';
+
+ return `${after ? '' : form}
+ ${after ? `<p><a href="${esc(href({ sort }))}">${sort === 'old' ? 'First' : 'Newest'} comments</a></p>` : ''}
+ ${tabs}
+ ${list}
+ ${older}`;
+}
+
+/**
+ * page: the comment service's answer for this viewer ({ thread, comments, next_cursor, viewer }).
+ */
+function threadPage({ accessId, page, user, after = null, error = null, draft = '' }) {
+ const { thread } = page;
+ const ref = thread.ref;
+ const what = refName(ref);
+ const product = PRODUCTS[ref.service] || ref.service;
+ const label = ref.label || `a ${what} on ${product}`;
+ const src = sourceUrl(ref);
+ const base = `/c/${accessId}`;
+ const panel = commentPanel({ page, user, base, after, error, draft, note: `It shows on ${esc(product)} too.` });
const body = `
const body = `
<article class="thread comment-thread">
<article class="thread comment-thread">
@@ -84,10 +115,7 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =
@@ -84,10 +115,7 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =
<h1>Comments on ${esc(label)}</h1>
<h1>Comments on ${esc(label)}</h1>
<p class="muted">${num(thread.comment_count)} ${thread.comment_count === 1 ? 'comment' : 'comments'} · one thread, shown here and on ${esc(product)}${src ? ` · <a href="${esc(src)}">Open the ${esc(what)} on ${esc(product)}</a>` : ''}</p>
<p class="muted">${num(thread.comment_count)} ${thread.comment_count === 1 ? 'comment' : 'comments'} · one thread, shown here and on ${esc(product)}${src ? ` · <a href="${esc(src)}">Open the ${esc(what)} on ${esc(product)}</a>` : ''}</p>
</header>
</header>
- ${after ? '' : form}
- ${after ? `<p><a href="${esc(base)}">Newest comments</a></p>` : ''}
- ${list}
- ${older}
+ ${panel}
</article>`;
</article>`;
return renderPage({
return renderPage({
title: `Comments on ${label}`,
title: `Comments on ${label}`,
@@ -101,4 +129,4 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =
@@ -101,4 +129,4 @@ function threadPage({ accessId, page, user, after = null, error = null, draft =
});
});
}
}
-module.exports = { threadPage, sourceUrl };
+module.exports = { threadPage, commentPanel, sourceUrl };
@@ -256,7 +256,11 @@ function shareLinks(p) {
@@ -256,7 +256,11 @@ function shareLinks(p) {
</div>`;
</div>`;
}
}
-function pastePage({ paste: p, related, user }) {
+/**
+ * comments: the paste's typed thread as the comment service shows it to this viewer (null while nobody has
+ * commented); commentSort 'new' or 'old', commentsAfter the paging cursor. No comments on burn-after-read pastes.
+ */
+function pastePage({ paste: p, related, user, comments = null, commentSort = 'new', commentsAfter = null }) {
const isShot = p.type === 'screenshot';
const isShot = p.type === 'screenshot';
const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');
const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');
const description = pasteDescription(p);
const description = pasteDescription(p);
@@ -315,6 +319,10 @@ function pastePage({ paste: p, related, user }) {
@@ -315,6 +319,10 @@ function pastePage({ paste: p, related, user }) {
<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p>
<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p>
</footer>
</footer>
</article>
</article>
+${p.burn_after_read ? '' : `<section class="section" id="comments">
+ <div class="section-head"><h2>Comments${comments && comments.thread.comment_count ? ` <span class="muted small">${num(comments.thread.comment_count)}</span>` : ''}</h2></div>
+ ${require('./comments').commentPanel({ page: comments, user, base: `/p/${p.slug}`, action: `/p/${p.slug}/comments`, after: commentsAfter, sort: commentSort })}
+</section>`}
${related && related.length ? `<section class="section" id="related">
${related && related.length ? `<section class="section" id="related">
<div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
<div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
${cardGrid(related)}
${cardGrid(related)}
new file mode 100644
new file mode 100644
@@ -0,0 +1,200 @@
@@ -0,0 +1,200 @@
+'use strict';
+/**
+ * Paste comments live on the paste's typed comment thread (community/paste/<slug>): the /p/:slug form,
+ * POST /api/pastes/:slug/comments (and a service writing as a person, Live's adapter) and
+ * /api/v1/comments all see one thread; private, burned and later-private pastes keep their comments
+ * from people who cannot see the paste; migration 0004 copies paste_comments rows onto the threads
+ * exactly once, however often it runs.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/app');
+
+const MIGRATION = path.join(__dirname, '..', 'migrations', '0004_paste_comments_to_threads.sql');
+
+(async () => {
+ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 }, appOpts: { commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } } } });
+ const net = t.network;
+ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
+ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
+ const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim' });
+ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
+ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
+
+ const call = (p, { method = 'GET', token, cookie, headers = {}, json, form } = {}) => {
+ const h = { ...headers };
+ if (token) h.authorization = `Bearer ${token}`;
+ let body;
+ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
+ if (form !== undefined) { h['content-type'] = 'application/x-www-form-urlencoded'; body = new URLSearchParams(form).toString(); }
+ return t.get(p, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
+ };
+ const create = async (json, cookie = alexJwt) => {
+ const r = await call('/api/pastes', { method: 'POST', cookie, json });
+ assert.strictEqual(r.status, 201, r.text);
+ const out = r.json();
+ return out.slug || out.paste.slug;
+ };
+ const resolve = (slug, who = {}) => call('/api/v1/comments/threads/resolve', { method: 'POST', json: { ref: { service: 'community', type: 'paste', id: slug } }, ...who });
+ const legacyRows = async () => Number((await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n);
+
+ const pub = await create({ content: 'public paste', title: 'Pub' });
+
+ await check('the /p/<slug> form and POST /api/pastes/:slug/comments write the typed thread; /api/v1/comments reads both back', async () => {
+ const viaForm = await call(`/p/${pub}/comments`, { method: 'POST', cookie: samJwt, form: { message: 'from the page form' } });
+ assert.strictEqual(viaForm.status, 303, viaForm.text);
+ const m = /^\/p\/([^#]+)#comment-(\d+)$/.exec(viaForm.headers.get('location'));
+ assert.ok(m && m[1] === pub, viaForm.headers.get('location'));
+ const viaApi = await call(`/api/pastes/${pub}/comments`, { method: 'POST', cookie: alexJwt, json: { message: 'from the paste API' } });
+ assert.strictEqual(viaApi.status, 201, viaApi.text);
+ assert.strictEqual(viaApi.json().comment.user_id, alex.subject_id);
+ // Live's adapter: a service with community.paste.write, writing as a person.
+ const viaService = await call(`/api/pastes/${pub}/comments`, { method: 'POST', token: net.signService({ cap: ['community.paste.write'] }), headers: { 'x-ov-subject': kim.subject_id }, json: { message: 'from Live', parent_id: Number(m[2]) } });
+ assert.strictEqual(viaService.status, 201, viaService.text);
+ assert.strictEqual(viaService.json().comment.parent_id, Number(m[2]));
+
+ const th = (await resolve(pub)).json().thread;
+ const got = await call(`/api/v1/comments/threads/${th.id}`);
+ assert.strictEqual(got.status, 200, got.text);
+ const page = got.json();
+ assert.strictEqual(page.thread.comment_count, 3);
+ const byMessage = new Map();
+ for (const c of page.comments) { byMessage.set(c.message, c); for (const r of c.replies || []) byMessage.set(r.message, r); }
+ assert.strictEqual(byMessage.get('from the page form').id, Number(m[2]));
+ assert.strictEqual(byMessage.get('from the page form').author.subject, sam.subject_id);
+ assert.strictEqual(byMessage.get('from the paste API').id, viaApi.json().comment.id);
+ assert.strictEqual(byMessage.get('from Live').parent_id, Number(m[2]));
+
+ const legacy = (await call(`/api/pastes/${pub}/comments`)).json();
+ assert.strictEqual(legacy.total, 3);
+ assert.deepStrictEqual(legacy.comments.map((c) => c.message), ['from the paste API', 'from the page form'], 'newest top-level first');
+ assert.strictEqual(legacy.comments[1].replies[0].message, 'from Live');
+ assert.strictEqual(await legacyRows(), 0, 'nothing is written to paste_comments');
+
+ // The paste page shows the same thread: comments with their anchors, sorting, the no-JS form.
+ const pageHtml = await call(`/p/${pub}`, { cookie: samJwt });
+ assert.strictEqual(pageHtml.status, 200);
+ for (const want of ['from the page form', 'from the paste API', 'from Live', `id="comment-${m[2]}"`, `id="comment-${viaApi.json().comment.id}"`,
+ `action="/p/${pub}/comments"`, `href="/p/${pub}?sort=old"`]) assert.ok(pageHtml.text.includes(want), want);
+ assert.ok(pageHtml.text.indexOf('from the paste API') < pageHtml.text.indexOf('from the page form'), 'newest first');
+ const oldest = await call(`/p/${pub}?sort=old`);
+ assert.ok(oldest.text.indexOf('from the page form') < oldest.text.indexOf('from the paste API'), '?sort=old: oldest first');
+ assert.ok(oldest.text.includes(`/auth/login?next=${encodeURIComponent(`/p/${pub}`)}`) && !oldest.text.includes(`action="/p/${pub}/comments"`), 'signed out: a sign-in link, no form');
+ const older = await call(`/p/${pub}?sort=new&after=${viaApi.json().comment.id}`);
+ assert.ok(older.text.includes('from the page form') && !older.text.includes('from the paste API'), 'paged with ?after=');
+
+ // Deleting a comment twice succeeds both times, as before.
+ const del = await call(`/api/pastes/${pub}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'short-lived' } });
+ for (let i = 0; i < 2; i++) assert.strictEqual((await call(`/api/pastes/${pub}/comments/${del.json().comment.id}`, { method: 'DELETE', cookie: samJwt })).status, 200);
+ assert.ok(!(await call(`/p/${pub}`)).text.includes('short-lived'));
+ });
+
+ await check('the form: signed in only, never from another site', async () => {
+ const anon = await call(`/p/${pub}/comments`, { method: 'POST', form: { message: 'hi' } });
+ assert.strictEqual(anon.status, 303);
+ assert.strictEqual(anon.headers.get('location'), `/auth/login?next=${encodeURIComponent(`/p/${pub}`)}`);
+ const foreign = await call(`/p/${pub}/comments`, { method: 'POST', cookie: samJwt, headers: { origin: 'https://evil.example' }, form: { message: 'hi' } });
+ assert.strictEqual(foreign.status, 403);
+ const empty = await call(`/p/${pub}/comments`, { method: 'POST', cookie: samJwt, form: { message: ' ' } });
+ assert.strictEqual(empty.status, 400);
+ assert.strictEqual((await call('/p/no-such-paste-here/comments', { method: 'POST', cookie: samJwt, form: { message: 'hi' } })).status, 404);
+ });
+
+ await check('a private paste\'s comments stay with its owner: the paste API, resolve, the thread\'s access id and the form', async () => {
+ const priv = await create({ content: 'private paste', visibility: 'private' });
+ assert.strictEqual((await call(`/api/pastes/${priv}/comments`, { method: 'POST', cookie: alexJwt, json: { message: 'owner only note' } })).status, 201);
+ const own = (await resolve(priv, { cookie: alexJwt })).json().thread;
+ assert.ok(own && /^cth_/.test(own.id));
+ assert.ok((await call(`/api/v1/comments/threads/${own.id}`, { cookie: alexJwt })).text.includes('owner only note'));
+ for (const who of [{ cookie: samJwt }, {}]) {
+ const list = await call(`/api/pastes/${priv}/comments`, who);
+ assert.strictEqual(list.status, 404);
+ assert.ok(!list.text.includes('owner only note'));
+ assert.strictEqual((await resolve(priv, who)).status, 404);
+ const leaked = await call(`/api/v1/comments/threads/${own.id}`, who);
+ assert.strictEqual(leaked.status, 404, 'even with the access id');
+ assert.ok(!leaked.text.includes('owner only note'));
+ assert.strictEqual((await call(`/c/${own.id}`, who)).status, 404);
+ }
+ assert.ok((await call(`/p/${priv}`, { cookie: alexJwt })).text.includes('owner only note'), 'the owner sees them on the paste page');
+ for (const who of [{ cookie: samJwt }, {}]) assert.ok(!(await call(`/p/${priv}`, who)).text.includes('owner only note'));
+ assert.strictEqual((await call(`/p/${priv}/comments`, { method: 'POST', cookie: samJwt, form: { message: 'let me in' } })).status, 404);
+ assert.strictEqual((await call(`/api/pastes/${priv}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'let me in' } })).status, 404);
+ });
+
+ await check('an unlisted paste made private later, and a burned paste, take their thread with them', async () => {
+ const unl = await create({ content: 'unlisted paste', visibility: 'unlisted' });
+ const th = (await resolve(unl, { cookie: samJwt })).json().thread;
+ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'seen by link' } })).status, 201);
+ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: samJwt })).status, 200);
+ assert.strictEqual((await call(`/api/pastes/${unl}`, { method: 'PUT', cookie: alexJwt, json: { visibility: 'private' } })).status, 200);
+ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: samJwt })).status, 404);
+ assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}`, { cookie: alexJwt })).status, 200, 'the owner still sees it');
+
+ const burn = await create({ content: 'read me once', burn_after_read: true });
+ const bth = (await resolve(burn, { cookie: samJwt })).json().thread;
+ assert.strictEqual((await call(`/api/v1/comments/threads/${bth.id}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'before the burn' } })).status, 201);
+ assert.strictEqual((await call(`/api/pastes/${burn}`, { cookie: samJwt })).status, 200);
+ assert.strictEqual((await call(`/api/pastes/${burn}`, { cookie: samJwt })).status, 410, 'burned');
+ for (const who of [{ cookie: samJwt }, { cookie: alexJwt }, {}]) {
+ const r = await call(`/api/v1/comments/threads/${bth.id}`, who);
+ assert.strictEqual(r.status, 404);
+ assert.ok(!r.text.includes('before the burn'));
+ }
+ });
+
+ await check('migration 0004 copies paste_comments onto the thread once, however often it runs', async () => {
+ const slug = await create({ content: 'a paste from before' });
+ const pid = (await t.db.prepare('SELECT id FROM pastes WHERE slug = ?').get(slug)).id;
+ // Rows as the old paste API left them: a comment, a reply to it, an anonymous one, a deleted one.
+ await t.db.prepare(`INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message, is_deleted, created_at, updated_at) VALUES
+ (?, ?, NULL, NULL, 'old comment', 0, '2025-01-02 03:04:05', '2025-01-02 03:04:05')`).run(pid, sam.subject_id);
+ const top = (await t.db.prepare("SELECT id FROM paste_comments WHERE message = 'old comment'").get()).id;
+ await t.db.prepare(`INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message, is_deleted, created_at, updated_at) VALUES
+ (?, ?, NULL, ?, 'old reply', 0, '2025-01-02 04:00:00', '2025-01-02 04:00:00'),
+ (?, NULL, 'Visitor', NULL, 'old anonymous', 0, '2025-01-03 00:00:00', '2025-01-03 00:00:00'),
+ (?, ?, NULL, NULL, 'old deleted', 1, '2025-01-04 00:00:00', '2025-01-05 00:00:00')`).run(pid, alex.subject_id, top, pid, pid, sam.subject_id);
+ const legacy = await t.db.prepare('SELECT * FROM paste_comments WHERE paste_id = ? ORDER BY id').all(pid);
+ assert.strictEqual(legacy.length, 4);
+
+ const sql = fs.readFileSync(MIGRATION, 'utf8');
+ await t.db.exec(sql);
+ await t.db.exec(sql);
+
+ const mapped = await t.db.prepare("SELECT source_id, target_id FROM legacy_id_map WHERE source_system = 'community' AND source_type = 'paste_comment'").all();
+ assert.strictEqual(mapped.length, Number((await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n), 'one map row per paste_comments row');
+ const newId = new Map(mapped.map((r) => [Number(r.source_id), Number(r.target_id)]));
+ const threads = await t.db.prepare("SELECT * FROM comment_threads WHERE ref_service = 'community' AND ref_type = 'paste' AND ref_id = ?").all(slug);
+ assert.strictEqual(threads.length, 1);
+ assert.match(threads[0].access_id, /^cth_[A-Za-z0-9_-]{22}$/);
+ const copied = await t.db.prepare('SELECT * FROM comments WHERE thread_id = ? ORDER BY id').all(threads[0].id);
+ assert.strictEqual(copied.length, 4, 'each row copied exactly once');
+ for (const old of legacy) {
+ const c = copied.find((x) => Number(x.id) === newId.get(Number(old.id)));
+ assert.ok(c, `row ${old.id} copied`);
+ assert.strictEqual(c.author_subject, old.author_subject);
+ assert.strictEqual(c.anon_name, old.anon_name);
+ assert.strictEqual(c.created_at, old.created_at);
+ assert.strictEqual(c.origin, 'user');
+ assert.strictEqual(c.parent_id == null ? null : Number(c.parent_id), old.parent_id == null ? null : newId.get(Number(old.parent_id)));
+ if (Number(old.is_deleted)) { assert.strictEqual(c.deleted_at, old.updated_at); assert.strictEqual(c.message, ''); } else { assert.strictEqual(c.deleted_at, null); assert.strictEqual(c.message, old.message); }
+ }
+ assert.strictEqual(Number(threads[0].comment_count), 3);
+ assert.strictEqual(Number(copied.find((x) => Number(x.id) === newId.get(Number(top))).reply_count), 1);
+
+ // Served from the thread, by the paste API and the comments API alike; new comments get new ids.
+ const list = (await call(`/api/pastes/${slug}/comments`)).json();
+ assert.strictEqual(list.total, 3);
+ assert.deepStrictEqual(list.comments.map((c) => c.message), ['old anonymous', 'old comment']);
+ assert.strictEqual(list.comments[1].replies[0].message, 'old reply');
+ const fresh = await call(`/api/pastes/${slug}/comments`, { method: 'POST', cookie: samJwt, json: { message: 'after the move', parent_id: newId.get(Number(top)) } });
+ assert.strictEqual(fresh.status, 201, fresh.text);
+ assert.ok(fresh.json().comment.id > Math.max(...newId.values()));
+ const page = (await call(`/api/v1/comments/threads/${threads[0].access_id}`)).json();
+ assert.strictEqual(page.thread.comment_count, 4);
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -132,13 +132,13 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
@@ -132,13 +132,13 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
}
}
}
}
// Read-like writes: like, copy, fork, comment, delete a comment — 404 like a missing paste, nothing written.
// Read-like writes: like, copy, fork, comment, delete a comment — 404 like a missing paste, nothing written.
- const before = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n;
+ const before = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM 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}`]]) {
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 });
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.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`);
assert.ok(!allMarkers.some((m) => a.text.includes(m)), `${method} ${suffix} as ${name} shows a marker`);
}
}
- const after = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM paste_comments').get()).n;
+ const after = JSON.stringify(await t.db.prepare('SELECT id, likes, copies, revision FROM pastes ORDER BY id').all()) + (await t.db.prepare('SELECT COUNT(*) AS n FROM comments').get()).n;
assert.strictEqual(after, before, `a refused write changed something (${name})`);
assert.strictEqual(after, before, `a refused write changed something (${name})`);
// A browser cannot open a comment thread on it either (it looks missing).
// A browser cannot open a comment thread on it either (it looks missing).
if (p.who) {
if (p.who) {