openvibers/openvibe.community Commits

Show and take paste comments on the paste's typed comment thread (#5)

OP OpenVibers committed 2 Oct 2026 · d7686f25a9 · 12 files, +452 −87

checks run by GitHub CI (test) · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Community/pull/5

Changes

ddiff --git a/README.md b/README.md
iindex f532c80..1a1454c 100644
---- a/README.md
++++ b/README.md
@@@ -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 the
viewer.
@@@ -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 filter
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,
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
--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
ddiff --git a/migrations/0004_paste_comments_to_threads.sql b/migrations/0004_paste_comments_to_threads.sql
nnew file mode 100644
iindex 0000000..97eba38
---- /dev/null
++++ b/migrations/0004_paste_comments_to_threads.sql
@@@ -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';
ddiff --git a/server/app.js b/server/app.js
iindex 023baa4..795cf7b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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.
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 4ce165f..ccdb9da 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -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_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 = {} } = {}) {
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
ddiff --git a/server/comments/store.js b/server/comments/store.js
iindex 937bf31..c0cd8a3 100644
---- a/server/comments/store.js
++++ b/server/comments/store.js
@@@ -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 };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex b2d71b4..6b0afee 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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);
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex aed9030..72c0238 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -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 = {}, lim
const 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 = {}, lim
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 = {}) {
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 before
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 } });
});
return { message: 'Comment deleted' };
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex 5d60210..4c5438e 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -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,
ddiff --git a/server/render/comments.js b/server/render/comments.js
iindex 52cb15f..57a3054 100644
---- a/server/render/comments.js
++++ b/server/render/comments.js
@@@ -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 };
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex ccfc575..27a4bae 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -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)}
ddiff --git a/test/paste-comments.test.js b/test/paste-comments.test.js
nnew file mode 100644
iindex 0000000..8db086f
---- /dev/null
++++ b/test/paste-comments.test.js
@@@ -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); });
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex 83118bb..ae3a221 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -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) {