openvibers/openvibe.community Commits
Connect public paste comments to Pastes forum threads (#368)
Changes
nnew file mode 100644@@@ -0,0 +1,44 @@++-- phase: expand++-- Existing public paste discussions enter their official board. A comment keeps its id,++-- timestamp and parent; posts are a forum view linked back to those same comment rows.++INSERT INTO threads (space_id, slug, title, author_subject, origin, external_key, created_at, last_activity_at)++SELECT s.id, 'item-' || ct.id, LEFT(COALESCE(NULLIF(p.title, ''), CASE WHEN p.type = 'screenshot' THEN 'Screenshot' ELSE 'Untitled paste' END), 200),++ p.owner_subject, 'system', 'community:paste:' || p.slug, MIN(c.created_at), MAX(c.created_at)++FROM comment_threads ct++JOIN pastes p ON p.slug = ct.ref_id AND p.deleted_at IS NULL AND p.visibility = 'public' AND p.burn_after_read = 0++JOIN comments c ON c.thread_id = ct.id++JOIN spaces s ON s.slug = 'pastes'++WHERE ct.ref_service = 'community' AND ct.ref_type = 'paste' AND ct.forum_thread_id IS NULL++GROUP BY s.id, ct.id, p.slug, p.title, p.type, p.owner_subject++ON CONFLICT (space_id, external_key) WHERE external_key IS NOT NULL DO NOTHING;++++UPDATE comment_threads ct SET forum_thread_id = t.id++FROM threads t JOIN spaces s ON s.id = t.space_id AND s.slug = 'pastes'++JOIN pastes p ON t.external_key = 'community:paste:' || p.slug++WHERE ct.ref_service = 'community' AND ct.ref_type = 'paste' AND ct.ref_id = p.slug++ AND p.deleted_at IS NULL AND p.visibility = 'public' AND p.burn_after_read = 0 AND ct.forum_thread_id IS NULL;++++INSERT INTO posts (thread_id, origin, is_opening, body_markdown, created_at)++SELECT t.id, 'system', 1,++ '[' || REPLACE(REPLACE(t.title, '[', ''), ']', '') || '](/p/' || p.slug || ')'++ || CASE WHEN p.type = 'screenshot' THEN E'\n\n![' || REPLACE(REPLACE(t.title, '[', ''), ']', '') || '](/p/' || p.slug || '/screenshot)' ELSE '' END,++ t.created_at++FROM comment_threads ct++JOIN threads t ON t.id = ct.forum_thread_id++JOIN pastes p ON p.slug = ct.ref_id++WHERE ct.ref_service = 'community' AND ct.ref_type = 'paste'++ AND NOT EXISTS (SELECT 1 FROM posts opening WHERE opening.thread_id = t.id AND opening.is_opening = 1);++++INSERT INTO posts (thread_id, author_subject, origin, body_markdown, created_at, updated_at, deleted_at, comment_id)++SELECT ct.forum_thread_id, c.author_subject, c.origin, c.message, c.created_at, c.updated_at, c.deleted_at, c.id++FROM comments c JOIN comment_threads ct ON ct.id = c.thread_id++WHERE ct.forum_thread_id IS NOT NULL AND c.forum_post_id IS NULL++ORDER BY ct.forum_thread_id, c.id++ON CONFLICT (comment_id) WHERE comment_id IS NOT NULL DO NOTHING;++++UPDATE comments c SET forum_post_id = p.id FROM posts p++WHERE p.comment_id = c.id AND c.forum_post_id IS NULL;++++UPDATE threads t SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id AND p.is_opening = 0 AND p.deleted_at IS NULL),++ last_activity_at = (SELECT COALESCE(MAX(p.created_at), t.created_at) FROM posts p WHERE p.thread_id = t.id AND p.deleted_at IS NULL)++WHERE t.external_key LIKE 'community:paste:%';@@@ -263,6 +263,7 @@ async function createApp(opts = {}) {// gate. Created before comments so a thread on a paste nobody may see is never readable.const sharing = opts.sharing || createSharing({ db, media: pasteMedia, fetchImpl: opts.fetchImpl });const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p), media: mediaObjects });++ forum.setCommentService(comments);// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });discovery.useForum(forum);nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++++// A forum post is a view of a comment. The comment remains the source of its text,++// author, order and deletion state; the mapped post gives the forum its usual URLs.++const PUBLIC_PASTE = "p.deleted_at IS NULL AND p.visibility = 'public' AND p.burn_after_read = 0";++++async function eligiblePaste(db, thread) {++ if (thread.ref_service !== 'community' || thread.ref_type !== 'paste') return null;++ return await db.prepare(`SELECT p.slug, p.title, p.type, p.owner_subject FROM pastes p WHERE p.slug = ? AND ${PUBLIC_PASTE}`)++ .get(thread.ref_id) || null;++}++++async function ensureForumThread(db, thread) {++ const paste = await eligiblePaste(db, thread);++ if (!paste) return null;++ if (thread.forum_thread_id) return thread.forum_thread_id;++ const space = await db.prepare("SELECT id FROM spaces WHERE slug = 'pastes'").get();++ if (!space) return null;++ const key = `community:paste:${paste.slug}`;++ const title = String(paste.title || (paste.type === 'screenshot' ? 'Screenshot' : 'Untitled paste')).slice(0, 200);++ const slug = `item-${thread.id}`;++ const info = await db.prepare(`INSERT INTO threads (space_id, slug, title, author_subject, origin, external_key)++ VALUES (?, ?, ?, ?, 'system', ?) ON CONFLICT (space_id, external_key) WHERE external_key IS NOT NULL DO NOTHING RETURNING id`)++ .run(space.id, slug, title, paste.owner_subject, key);++ const row = await db.prepare('SELECT id FROM threads WHERE space_id = ? AND external_key = ?').get(space.id, key);++ if (info.changes) {++ const link = `/p/${encodeURIComponent(paste.slug)}`;++ const image = paste.type === 'screenshot' ? `\n\n![${title.replace(/[\[\]]/g, '')}](${link}/screenshot)` : '';++ await db.prepare("INSERT INTO posts (thread_id, origin, is_opening, body_markdown) VALUES (?, 'system', 1, ?)")++ .run(row.id, `[${title.replace(/[\[\]]/g, '')}](${link})${image}`);++ }++ await db.prepare('UPDATE comment_threads SET forum_thread_id = ? WHERE id = ?').run(row.id, thread.id);++ return row.id;++}++++async function syncComment(db, comment) {++ const thread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(comment.thread_id);++ if (!thread) return;++ const forumId = await ensureForumThread(db, thread);++ if (!forumId) return;++ const existing = await db.prepare('SELECT id FROM posts WHERE comment_id = ?').get(comment.id);++ if (existing) return existing.id;++ const info = await db.prepare(`INSERT INTO posts (thread_id, author_subject, origin, body_markdown, created_at, updated_at, deleted_at, comment_id)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (comment_id) WHERE comment_id IS NOT NULL DO NOTHING RETURNING id`)++ .run(forumId, comment.author_subject, comment.origin, comment.message, comment.created_at, comment.updated_at, comment.deleted_at, comment.id);++ const post = info.changes ? { id: info.lastInsertRowid } : await db.prepare('SELECT id FROM posts WHERE comment_id = ?').get(comment.id);++ await db.prepare('UPDATE comments SET forum_post_id = ? WHERE id = ?').run(post.id, comment.id);++ await db.prepare(`UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL),++ last_activity_at = (SELECT COALESCE(MAX(created_at), threads.created_at) FROM posts WHERE thread_id = ? AND deleted_at IS NULL) WHERE id = ?`)++ .run(forumId, forumId, forumId);++ return post.id;++}++++async function updatePost(db, comment) {++ if (!comment.forum_post_id) return;++ await db.prepare('UPDATE posts SET body_markdown = ?, updated_at = ?, deleted_at = ? WHERE id = ?')++ .run(comment.message, comment.updated_at, comment.deleted_at, comment.forum_post_id);++ const post = await db.prepare('SELECT thread_id FROM posts WHERE id = ?').get(comment.forum_post_id);++ if (post) await db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?')++ .run(post.thread_id, post.thread_id);++}++++async function hidePasteThread(db, slug, visible) {++ await db.prepare(`UPDATE threads SET deleted_at = CASE WHEN ? THEN NULL ELSE ov_now() END++ WHERE external_key = ? AND space_id = (SELECT id FROM spaces WHERE slug = 'pastes')`)++ .run(visible ? 1 : 0, `community:paste:${slug}`);++}++++module.exports = { ensureForumThread, syncComment, updatePost, hidePasteThread };@@@ -89,7 +89,9 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =function shapeThread(t, v) {const id = threadIdFor(t, v);if (t.visibility === 'hidden' && !moderator(v)) return { id, access_id: t.access_id, ref: refOf(t), visibility: 'hidden', comment_count: null };-- return { id, access_id: t.access_id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };++ return { id, access_id: t.access_id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count,++ forum_url: t.forum_thread_id && t.ref_service === 'community' && t.ref_type === 'paste' ? `/forum/pastes/item-${t.id}` : null,++ created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };}function shapeComment(c, v, projections, votes, threadId) {@@@ -10,6 +10,7 @@* tombstone so the replies keep their context.*/const { newThreadAccessId } = require('../db');++const forumLinks = require('./forum-links');async function getThread(db, id) {return await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;@@@ -112,6 +113,7 @@ async function insertComment(db, { thread_id, parent_id = null, author_subject =await db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = ov_now() WHERE id = ?').run(thread_id);if (parent_id) await db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);const comment = await getComment(db, info.lastInsertRowid);++ await forumLinks.syncComment(db, comment);const cthread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);if (cthread) await require('../events').commentCreated(comment, cthread, { itemPublic: await refIsPublic(db, cthread) }); // community.comment.createdreturn comment;@@@ -121,7 +123,9 @@ async function insertComment(db, { thread_id, parent_id = null, author_subject =/** The author's new text; edited_at records that it changed. → the row */async function editComment(db, id, message) {await db.prepare('UPDATE comments SET message = ?, edited_at = ov_now(), updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(message, id);-- return await getComment(db, id);++ const comment = await getComment(db, id);++ if (comment) await forumLinks.updatePost(db, comment);++ return comment;}/** Soft delete; counters recomputed from the rows. → changes */@@@ -130,7 +134,9 @@ async function softDeleteComment(db, id, deletedBy = null) {const c = await getComment(db, id);if (!c || c.deleted_at) return 0;await db.prepare("UPDATE comments SET deleted_at = ov_now(), deleted_by = ?, message = '', updated_at = ov_now() WHERE id = ?").run(deletedBy, id);++ await forumLinks.updatePost(db, await getComment(db, id));await db.prepare('DELETE FROM comment_attachments WHERE comment_id = ?').run(id);++ if (c.forum_post_id) await db.prepare('DELETE FROM attachments WHERE post_id = ?').run(c.forum_post_id);await db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = ov_now() WHERE id = ?').run(c.thread_id, c.thread_id);if (c.parent_id) await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);return 1;@@@ -53,6 +53,7 @@ const { sniffImage } = require('../media/sniff-image');const reactions = require('./reactions');const moderation = require('./moderation');const blocks = require('../identity/blocks');++const commentStore = require('../comments/store');const STYLES = ['feed', 'forum'];const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;@@@ -97,6 +98,7 @@ const THREADS_PER_DAY = 20;const contentLabels = require('./labels');function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {}, config = {}, indexnow = null } = {}) {++ let commentService = null;const authors = createAuthors({ db, network });const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });@@@ -275,6 +277,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, viif (!postIds.length) return out;const rows = await db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }++ const linked = await db.prepare(`SELECT p.id AS post_id, a.media_id, a.filename, a.mime, a.size_bytes++ FROM posts p JOIN comment_attachments a ON a.comment_id = p.comment_id++ WHERE p.id IN (${postIds.map(() => '?').join(', ')}) ORDER BY p.id, a.created_at, a.media_id`).all(...postIds);++ for (const r of linked) {++ if (!out.has(r.post_id)) out.set(r.post_id, []);++ if (!out.get(r.post_id).some((a) => a.media_id === r.media_id)) out.get(r.post_id).push(shapeAttachment({ ...r, url: `${String(config.mediaUrl || '').replace(/\/$/, '')}/o/${r.media_id}` }));++ }return out;}@@@ -365,7 +374,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, viasync function attach(rows, postId) {const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');-- for (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);++ const linked = await db.prepare(`SELECT p.comment_id, ct.ref_service, ct.ref_type, ct.ref_id FROM posts p++ JOIN comments c ON c.id = p.comment_id JOIN comment_threads ct ON ct.id = c.thread_id WHERE p.id = ?`).get(postId);++ for (const [i, r] of rows.entries()) {++ await set.run(postId, i, r.media_id);++ if (linked) await db.prepare(`INSERT INTO comment_attachments++ (media_id, ref_service, ref_type, ref_id, comment_id, owner_subject, filename, mime, size_bytes)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (media_id) DO NOTHING`)++ .run(r.media_id, linked.ref_service, linked.ref_type, linked.ref_id, linked.comment_id, r.owner_subject,++ r.filename || 'image', r.mime, r.size_bytes);++ }}function shapePost(p, v, projections, attachments = null, pastes = null, mod = moderator(v)) {@@@ -521,6 +539,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi// ═════════════════════════════════════════════════════════return {++ setCommentService(service) { commentService = service; },SORTS: store.SORTS,THREADS_PER_PAGE,POSTS_PER_PAGE,@@@ -973,6 +992,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi*/async createThread(v, spaceSlug, body = {}) {const space = await spaceFor(v, spaceSlug);++ if (['pictures', 'videos', 'clips', 'pastes'].includes(space.slug)) fail(403, 'space.item_threads_only', 'Item discussions begin with a comment on the item');const w = writer(v);mayPostIn(v, space);await requireMembership(v, space);@@@ -1026,6 +1046,24 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi// Platform blocks: nobody replies in a thread whose author blocked them.await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');const text = cleanBody(body.body != null ? body.body : body.body_markdown);++ const linked = await db.prepare('SELECT access_id FROM comment_threads WHERE forum_thread_id = ?').get(thread.id);++ if (linked) {++ if (!commentService) fail(503, 'comments.unavailable', 'Comments are unavailable');++ const pictureRows = await preparePictures(w, body.pictures);++ const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);++ const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);++ postLimiter.check(w.key, text);++ const out = await commentService.add(v, linked.access_id, { message: text });++ postLimiter.record(w.key, text);++ const post = await store.getPost(db, (await commentStore.getComment(db, out.comment.id)).forum_post_id);++ await attach(images, post.id);++ const projections = await authors.projectionsFor([post.author_subject]);++ const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;++ const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);++ pingThread(space, thread);++ return { post: shapePost(post, v, projections, await attachmentsOf([post.id]), null), page,++ url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };++ }const pictureRows = await preparePictures(w, body.pictures);const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);@@@ -1053,6 +1091,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst mod = await moderates(v, space);if (!mine && !mod) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');if (thread.locked && !mod) fail(403, 'thread.locked', 'This thread is locked');++ if (post.comment_id) {++ if (!mine) fail(403, 'comment.not_yours', 'Only the author can edit a comment');++ const text = cleanBody(body.body != null ? body.body : body.body_markdown);++ await store.editPost(db, post.id, text, v.subject || v.service || null);++ await commentStore.editComment(db, post.comment_id, text);++ const next = await store.getPost(db, post.id);++ const projections = await authors.projectionsFor([next.author_subject]);++ pingThread(space, thread);++ return { post: shapePost(next, v, projections, null, null, mod) };++ }const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));const projections = await authors.projectionsFor([next.author_subject]);@@@ -1075,6 +1123,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, viconst mine = person(v) && post.author_subject === v.subject;if (!mine && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');if (post.is_opening) return await removeThread(v, thread, space, body);++ if (post.comment_id) {++ const refs = await db.prepare('SELECT media_id FROM comment_attachments WHERE comment_id = ?').all(post.comment_id);++ await commentStore.softDeleteComment(db, post.comment_id, v.subject || v.service || null);++ if (media && typeof media.deleteObject === 'function') for (const ref of refs) {++ try { await media.deleteObject(ref.media_id); } catch (err) { console.warn('[Forum] linked attachment cleanup failed:', err.message); }++ }++ pingThread(space, thread);++ return { ok: true, id: post.id };++ }const fields = moderation.fields(body);await db.tx(async () => {await store.softDeletePost(db, post.id);@@@ -219,6 +219,7 @@ async function updatePaste(db, id, patch, editedBy = null) {sets.push('updated_at = ov_now()');await db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);const next = await getById(db, id);++ if (patch.visibility !== undefined) await require('../comments/forum-links').hidePasteThread(db, next.slug, next.visibility === 'public' && !next.burn_after_read);if (textChanged) {await db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)').run(id, next.revision, next.title, next.content, next.language, editedBy);@@@ -239,6 +240,7 @@ async function softDelete(db, id) {const cur = await getById(db, id);const n = (await db.prepare(`UPDATE pastes SET deleted_at = ov_now(), content = NULL, screenshot_url = NULL, metadata = NULL,ai_summary = NULL, ai_tags = NULL, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL`).run(id)).changes;++ if (n && cur) await require('../comments/forum-links').hidePasteThread(db, cur.slug, false);if (n && cur) await require('../events').pasteDeleted(cur.slug); // community.paste.deletedreturn n;});@@@ -373,6 +375,7 @@ async function setVisibility(db, pasteId, visibility) {return await db.tx(async () => {const cur = await getById(db, pasteId);const r = await db.prepare('UPDATE pastes SET visibility = ?, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);++ if (r.changes && cur) await require('../comments/forum-links').hidePasteThread(db, cur.slug, visibility === 'public' && !cur.burn_after_read);if (r.changes && cur && cur.visibility !== visibility) await require('../events').pasteUpdated(await getById(db, pasteId), ['visibility']);return r.changes;});@@@ -511,7 +511,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne<a class="btn btn-sm" href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}<button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>-- ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/forum/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}++ ${!restricted && p.visibility === 'public' && !p.burn_after_read ? `<a class="btn btn-sm" href="${esc(comments && comments.thread.forum_url || '#comments')}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}</div>`;@@@ -135,6 +135,8 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c}}await tx.exec('UPDATE pastes SET visibility = $1, updated_at = ov_now() WHERE slug = $2 AND deleted_at IS NULL', [VISIBILITY[audience] || 'private', ref.id]);++ await tx.exec(`UPDATE threads SET deleted_at = CASE WHEN $1 = 'public' THEN NULL ELSE ov_now() END++ WHERE external_key = 'community:paste:' || $2 AND space_id = (SELECT id FROM spaces WHERE slug = 'pastes')`, [audience, ref.id]);tx.afterCommit(() => refreshSearch(ref.id));}nnew file mode 100644@@@ -0,0 +1,78 @@++'use strict';++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: {++ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },++ forumLimits: { posts: { cooldownSec: 0, perMinute: 1000 } },++ } });++ const person = t.network.addUser({ network_user_id: 71, username: 'threader', display_name: 'Threader' });++ const cookie = t.network.sign({ id: 71, subject_id: person.subject_id, username: 'threader', display_name: 'Threader', role: 'user' });++ const call = (url, method = 'GET', json = null) => t.get(url, {++ method, headers: json == null ? {} : { 'content-type': 'application/json' },++ body: json == null ? undefined : JSON.stringify(json), cookies: [`ov_token=${cookie}`],++ });++ const create = async (visibility) => {++ const response = await call('/api/pastes', 'POST', { title: 'A pictured paste', content: `hello ${visibility} ${Math.random()}`, visibility });++ assert.strictEqual(response.status, 201, response.text);++ return response.json().slug;++ };++++ await check('a public item enters its board on first comment and forum replies share the comment', async () => {++ const slug = await create('public');++ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'community', type: 'paste', id: slug } });++ const access = resolved.json().thread.id;++ const before = await call('/api/v1/spaces/pastes/threads');++ assert.strictEqual(before.json().threads.length, 0);++ const first = await call(`/api/v1/comments/threads/${access}/comments`, 'POST', { message: 'From the paste' });++ assert.strictEqual(first.status, 201, first.text);++ const row = await t.db.prepare('SELECT id, forum_thread_id FROM comment_threads WHERE access_id = ?').get(access);++ assert.ok(row.forum_thread_id);++ const url = `/api/v1/spaces/pastes/threads/item-${row.id}`;++ const forum = await call(url);++ assert.strictEqual(forum.status, 200, forum.text);++ assert.strictEqual(forum.json().posts[1].body_markdown, 'From the paste');++ const canonical = await require('../server/pastes/store').canonicalForRef(t.db, slug);++ assert.ok((await call(`/p/${canonical}`)).text.includes(`/forum/pastes/item-${row.id}`));++ const reply = await call(`${url}/posts`, 'POST', { body: 'From the forum' });++ assert.strictEqual(reply.status, 201, reply.text);++ const comments = await call(`/api/v1/comments/threads/${access}`);++ assert.deepStrictEqual(comments.json().comments.map((c) => c.message), ['From the paste', 'From the forum']);++ const postId = reply.json().post.id;++ const edited = await call(`/api/v1/posts/${postId}`, 'PUT', { body: 'Edited in the forum' });++ assert.strictEqual(edited.status, 200, edited.text);++ assert.ok((await call(`/api/v1/comments/threads/${access}`)).text.includes('Edited in the forum'));++ const privateResult = await call(`/api/pastes/${slug}`, 'PUT', { visibility: 'private' });++ assert.strictEqual(privateResult.status, 200, privateResult.text);++ assert.strictEqual((await call(url)).status, 404);++ assert.strictEqual((await call('/api/v1/spaces/pastes/threads')).json().threads.length, 0);++ });++++ await check('unlisted comments stay off the public board', async () => {++ const slug = await create('unlisted');++ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'community', type: 'paste', id: slug } });++ const created = await call(`/api/v1/comments/threads/${resolved.json().thread.id}/comments`, 'POST', { message: 'Link only' });++ assert.strictEqual(created.status, 201, created.text);++ assert.strictEqual((await t.db.prepare('SELECT forum_thread_id FROM comment_threads WHERE access_id = ?').get(resolved.json().thread.id)).forum_thread_id, null);++ });++++ await check('backfill preserves old comment ids and timestamps and is repeatable', async () => {++ const slug = await create('public');++ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'community', type: 'paste', id: slug } });++ const thread = await t.db.prepare('SELECT id FROM comment_threads WHERE access_id = ?').get(resolved.json().thread.id);++ const first = await t.db.prepare("INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, 'Older', '2025-01-01 01:00:00') RETURNING id").run(thread.id, person.subject_id);++ const second = await t.db.prepare("INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, 'Newer', '2025-01-01 02:00:00') RETURNING id").run(thread.id, person.subject_id);++ const sql = fs.readFileSync(path.join(__dirname, '../migrations/0034_comment_forum_backfill.sql'), 'utf8');++ await t.db.exec(sql);++ await t.db.exec(sql);++ const rows = await t.db.prepare('SELECT comment_id, created_at FROM posts WHERE comment_id IN (?, ?) ORDER BY id').all(first.lastInsertRowid, second.lastInsertRowid);++ assert.deepStrictEqual(rows.map((r) => r.comment_id), [first.lastInsertRowid, second.lastInsertRowid]);++ assert.deepStrictEqual(rows.map((r) => r.created_at), ['2025-01-01 01:00:00', '2025-01-01 02:00:00']);++ });++++ await t.close();++ done();++})().catch((err) => { console.error(err); process.exit(1); });@@@ -118,7 +118,7 @@ const { boot, check, done } = require('./helpers/app');await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {const pasteUrl = await require('../server/pastes/store').canonicalForRef(t.db, 'snip1');const paste = (await call(`/p/${pasteUrl}`)).text;-- assert.ok(paste.includes('/forum/discuss?paste=snip1'));++ assert.ok(paste.includes('href="#comments"'), 'an item with no comments opens its own composer');const picker = (await call('/forum/discuss?paste=snip1', { cookie: alexJwt })).text;assert.ok(picker.includes('/forum/general/new?paste=snip1') && !picker.includes('/forum/roadmap/new'));const form = (await call('/forum/general/new?paste=snip1', { cookie: alexJwt })).text;
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+-- phase: expand
+-- Existing public paste discussions enter their official board. A comment keeps its id,
+-- timestamp and parent; posts are a forum view linked back to those same comment rows.
+INSERT INTO threads (space_id, slug, title, author_subject, origin, external_key, created_at, last_activity_at)
+SELECT s.id, 'item-' || ct.id, LEFT(COALESCE(NULLIF(p.title, ''), CASE WHEN p.type = 'screenshot' THEN 'Screenshot' ELSE 'Untitled paste' END), 200),
+ p.owner_subject, 'system', 'community:paste:' || p.slug, MIN(c.created_at), MAX(c.created_at)
+FROM comment_threads ct
+JOIN pastes p ON p.slug = ct.ref_id AND p.deleted_at IS NULL AND p.visibility = 'public' AND p.burn_after_read = 0
+JOIN comments c ON c.thread_id = ct.id
+JOIN spaces s ON s.slug = 'pastes'
+WHERE ct.ref_service = 'community' AND ct.ref_type = 'paste' AND ct.forum_thread_id IS NULL
+GROUP BY s.id, ct.id, p.slug, p.title, p.type, p.owner_subject
+ON CONFLICT (space_id, external_key) WHERE external_key IS NOT NULL DO NOTHING;
+
+UPDATE comment_threads ct SET forum_thread_id = t.id
+FROM threads t JOIN spaces s ON s.id = t.space_id AND s.slug = 'pastes'
+JOIN pastes p ON t.external_key = 'community:paste:' || p.slug
+WHERE ct.ref_service = 'community' AND ct.ref_type = 'paste' AND ct.ref_id = p.slug
+ AND p.deleted_at IS NULL AND p.visibility = 'public' AND p.burn_after_read = 0 AND ct.forum_thread_id IS NULL;
+
+INSERT INTO posts (thread_id, origin, is_opening, body_markdown, created_at)
+SELECT t.id, 'system', 1,
+ '[' || REPLACE(REPLACE(t.title, '[', ''), ']', '') || '](/p/' || p.slug || ')'
+ || CASE WHEN p.type = 'screenshot' THEN E'\n\n![' || REPLACE(REPLACE(t.title, '[', ''), ']', '') || '](/p/' || p.slug || '/screenshot)' ELSE '' END,
+ t.created_at
+FROM comment_threads ct
+JOIN threads t ON t.id = ct.forum_thread_id
+JOIN pastes p ON p.slug = ct.ref_id
+WHERE ct.ref_service = 'community' AND ct.ref_type = 'paste'
+ AND NOT EXISTS (SELECT 1 FROM posts opening WHERE opening.thread_id = t.id AND opening.is_opening = 1);
+
+INSERT INTO posts (thread_id, author_subject, origin, body_markdown, created_at, updated_at, deleted_at, comment_id)
+SELECT ct.forum_thread_id, c.author_subject, c.origin, c.message, c.created_at, c.updated_at, c.deleted_at, c.id
+FROM comments c JOIN comment_threads ct ON ct.id = c.thread_id
+WHERE ct.forum_thread_id IS NOT NULL AND c.forum_post_id IS NULL
+ORDER BY ct.forum_thread_id, c.id
+ON CONFLICT (comment_id) WHERE comment_id IS NOT NULL DO NOTHING;
+
+UPDATE comments c SET forum_post_id = p.id FROM posts p
+WHERE p.comment_id = c.id AND c.forum_post_id IS NULL;
+
+UPDATE threads t SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id AND p.is_opening = 0 AND p.deleted_at IS NULL),
+ last_activity_at = (SELECT COALESCE(MAX(p.created_at), t.created_at) FROM posts p WHERE p.thread_id = t.id AND p.deleted_at IS NULL)
+WHERE t.external_key LIKE 'community:paste:%';
@@ -263,6 +263,7 @@ async function createApp(opts = {}) {
@@ -263,6 +263,7 @@ async function createApp(opts = {}) {
// gate. Created before comments so a thread on a paste nobody may see is never readable.
// gate. Created before comments so a thread on a paste nobody may see is never readable.
const sharing = opts.sharing || createSharing({ db, media: pasteMedia, fetchImpl: opts.fetchImpl });
const sharing = opts.sharing || createSharing({ db, media: pasteMedia, fetchImpl: opts.fetchImpl });
const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p), media: mediaObjects });
const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p), media: mediaObjects });
+ forum.setCommentService(comments);
// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.
// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.
const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });
const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });
discovery.useForum(forum);
discovery.useForum(forum);
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+
+// A forum post is a view of a comment. The comment remains the source of its text,
+// author, order and deletion state; the mapped post gives the forum its usual URLs.
+const PUBLIC_PASTE = "p.deleted_at IS NULL AND p.visibility = 'public' AND p.burn_after_read = 0";
+
+async function eligiblePaste(db, thread) {
+ if (thread.ref_service !== 'community' || thread.ref_type !== 'paste') return null;
+ return await db.prepare(`SELECT p.slug, p.title, p.type, p.owner_subject FROM pastes p WHERE p.slug = ? AND ${PUBLIC_PASTE}`)
+ .get(thread.ref_id) || null;
+}
+
+async function ensureForumThread(db, thread) {
+ const paste = await eligiblePaste(db, thread);
+ if (!paste) return null;
+ if (thread.forum_thread_id) return thread.forum_thread_id;
+ const space = await db.prepare("SELECT id FROM spaces WHERE slug = 'pastes'").get();
+ if (!space) return null;
+ const key = `community:paste:${paste.slug}`;
+ const title = String(paste.title || (paste.type === 'screenshot' ? 'Screenshot' : 'Untitled paste')).slice(0, 200);
+ const slug = `item-${thread.id}`;
+ const info = await db.prepare(`INSERT INTO threads (space_id, slug, title, author_subject, origin, external_key)
+ VALUES (?, ?, ?, ?, 'system', ?) ON CONFLICT (space_id, external_key) WHERE external_key IS NOT NULL DO NOTHING RETURNING id`)
+ .run(space.id, slug, title, paste.owner_subject, key);
+ const row = await db.prepare('SELECT id FROM threads WHERE space_id = ? AND external_key = ?').get(space.id, key);
+ if (info.changes) {
+ const link = `/p/${encodeURIComponent(paste.slug)}`;
+ const image = paste.type === 'screenshot' ? `\n\n![${title.replace(/[\[\]]/g, '')}](${link}/screenshot)` : '';
+ await db.prepare("INSERT INTO posts (thread_id, origin, is_opening, body_markdown) VALUES (?, 'system', 1, ?)")
+ .run(row.id, `[${title.replace(/[\[\]]/g, '')}](${link})${image}`);
+ }
+ await db.prepare('UPDATE comment_threads SET forum_thread_id = ? WHERE id = ?').run(row.id, thread.id);
+ return row.id;
+}
+
+async function syncComment(db, comment) {
+ const thread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(comment.thread_id);
+ if (!thread) return;
+ const forumId = await ensureForumThread(db, thread);
+ if (!forumId) return;
+ const existing = await db.prepare('SELECT id FROM posts WHERE comment_id = ?').get(comment.id);
+ if (existing) return existing.id;
+ const info = await db.prepare(`INSERT INTO posts (thread_id, author_subject, origin, body_markdown, created_at, updated_at, deleted_at, comment_id)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (comment_id) WHERE comment_id IS NOT NULL DO NOTHING RETURNING id`)
+ .run(forumId, comment.author_subject, comment.origin, comment.message, comment.created_at, comment.updated_at, comment.deleted_at, comment.id);
+ const post = info.changes ? { id: info.lastInsertRowid } : await db.prepare('SELECT id FROM posts WHERE comment_id = ?').get(comment.id);
+ await db.prepare('UPDATE comments SET forum_post_id = ? WHERE id = ?').run(post.id, comment.id);
+ await db.prepare(`UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL),
+ last_activity_at = (SELECT COALESCE(MAX(created_at), threads.created_at) FROM posts WHERE thread_id = ? AND deleted_at IS NULL) WHERE id = ?`)
+ .run(forumId, forumId, forumId);
+ return post.id;
+}
+
+async function updatePost(db, comment) {
+ if (!comment.forum_post_id) return;
+ await db.prepare('UPDATE posts SET body_markdown = ?, updated_at = ?, deleted_at = ? WHERE id = ?')
+ .run(comment.message, comment.updated_at, comment.deleted_at, comment.forum_post_id);
+ const post = await db.prepare('SELECT thread_id FROM posts WHERE id = ?').get(comment.forum_post_id);
+ if (post) await db.prepare('UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts WHERE thread_id = ? AND is_opening = 0 AND deleted_at IS NULL) WHERE id = ?')
+ .run(post.thread_id, post.thread_id);
+}
+
+async function hidePasteThread(db, slug, visible) {
+ await db.prepare(`UPDATE threads SET deleted_at = CASE WHEN ? THEN NULL ELSE ov_now() END
+ WHERE external_key = ? AND space_id = (SELECT id FROM spaces WHERE slug = 'pastes')`)
+ .run(visible ? 1 : 0, `community:paste:${slug}`);
+}
+
+module.exports = { ensureForumThread, syncComment, updatePost, hidePasteThread };
@@ -89,7 +89,9 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -89,7 +89,9 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
function shapeThread(t, v) {
function shapeThread(t, v) {
const id = threadIdFor(t, v);
const id = threadIdFor(t, v);
if (t.visibility === 'hidden' && !moderator(v)) return { id, access_id: t.access_id, ref: refOf(t), visibility: 'hidden', comment_count: null };
if (t.visibility === 'hidden' && !moderator(v)) return { id, access_id: t.access_id, ref: refOf(t), visibility: 'hidden', comment_count: null };
- return { id, access_id: t.access_id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count, created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
+ return { id, access_id: t.access_id, ref: refOf(t), visibility: t.visibility, comment_count: t.comment_count,
+ forum_url: t.forum_thread_id && t.ref_service === 'community' && t.ref_type === 'paste' ? `/forum/pastes/item-${t.id}` : null,
+ created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
}
}
function shapeComment(c, v, projections, votes, threadId) {
function shapeComment(c, v, projections, votes, threadId) {
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* tombstone so the replies keep their context.
* tombstone so the replies keep their context.
*/
*/
const { newThreadAccessId } = require('../db');
const { newThreadAccessId } = require('../db');
+const forumLinks = require('./forum-links');
async function getThread(db, id) {
async function getThread(db, id) {
return await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;
return await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(id) || null;
@@ -112,6 +113,7 @@ async function insertComment(db, { thread_id, parent_id = null, author_subject =
@@ -112,6 +113,7 @@ async function insertComment(db, { thread_id, parent_id = null, author_subject =
await db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = ov_now() WHERE id = ?').run(thread_id);
await db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = ov_now() WHERE id = ?').run(thread_id);
if (parent_id) await db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
if (parent_id) await db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
const comment = await getComment(db, info.lastInsertRowid);
const comment = await getComment(db, info.lastInsertRowid);
+ await forumLinks.syncComment(db, comment);
const cthread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);
const cthread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(thread_id);
if (cthread) await require('../events').commentCreated(comment, cthread, { itemPublic: await refIsPublic(db, cthread) }); // community.comment.created
if (cthread) await require('../events').commentCreated(comment, cthread, { itemPublic: await refIsPublic(db, cthread) }); // community.comment.created
return comment;
return comment;
@@ -121,7 +123,9 @@ async function insertComment(db, { thread_id, parent_id = null, author_subject =
@@ -121,7 +123,9 @@ async function insertComment(db, { thread_id, parent_id = null, author_subject =
/** The author's new text; edited_at records that it changed. → the row */
/** The author's new text; edited_at records that it changed. → the row */
async function editComment(db, id, message) {
async function editComment(db, id, message) {
await db.prepare('UPDATE comments SET message = ?, edited_at = ov_now(), updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(message, id);
await db.prepare('UPDATE comments SET message = ?, edited_at = ov_now(), updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(message, id);
- return await getComment(db, id);
+ const comment = await getComment(db, id);
+ if (comment) await forumLinks.updatePost(db, comment);
+ return comment;
}
}
/** Soft delete; counters recomputed from the rows. → changes */
/** Soft delete; counters recomputed from the rows. → changes */
@@ -130,7 +134,9 @@ async function softDeleteComment(db, id, deletedBy = null) {
@@ -130,7 +134,9 @@ async function softDeleteComment(db, id, deletedBy = null) {
const c = await getComment(db, id);
const c = await getComment(db, id);
if (!c || c.deleted_at) return 0;
if (!c || c.deleted_at) return 0;
await db.prepare("UPDATE comments SET deleted_at = ov_now(), deleted_by = ?, message = '', updated_at = ov_now() WHERE id = ?").run(deletedBy, id);
await db.prepare("UPDATE comments SET deleted_at = ov_now(), deleted_by = ?, message = '', updated_at = ov_now() WHERE id = ?").run(deletedBy, id);
+ await forumLinks.updatePost(db, await getComment(db, id));
await db.prepare('DELETE FROM comment_attachments WHERE comment_id = ?').run(id);
await db.prepare('DELETE FROM comment_attachments WHERE comment_id = ?').run(id);
+ if (c.forum_post_id) await db.prepare('DELETE FROM attachments WHERE post_id = ?').run(c.forum_post_id);
await db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = ov_now() WHERE id = ?').run(c.thread_id, c.thread_id);
await db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = ov_now() WHERE id = ?').run(c.thread_id, c.thread_id);
if (c.parent_id) await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);
if (c.parent_id) await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);
return 1;
return 1;
@@ -53,6 +53,7 @@ const { sniffImage } = require('../media/sniff-image');
@@ -53,6 +53,7 @@ const { sniffImage } = require('../media/sniff-image');
const reactions = require('./reactions');
const reactions = require('./reactions');
const moderation = require('./moderation');
const moderation = require('./moderation');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
+const commentStore = require('../comments/store');
const STYLES = ['feed', 'forum'];
const STYLES = ['feed', 'forum'];
const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
@@ -97,6 +98,7 @@ const THREADS_PER_DAY = 20;
@@ -97,6 +98,7 @@ const THREADS_PER_DAY = 20;
const contentLabels = require('./labels');
const contentLabels = require('./labels');
function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {}, config = {}, indexnow = null } = {}) {
function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {}, config = {}, indexnow = null } = {}) {
+ let commentService = null;
const authors = createAuthors({ db, network });
const authors = createAuthors({ db, network });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
@@ -275,6 +277,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -275,6 +277,13 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (!postIds.length) return out;
if (!postIds.length) return out;
const rows = await db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);
const rows = await db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);
for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }
for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }
+ const linked = await db.prepare(`SELECT p.id AS post_id, a.media_id, a.filename, a.mime, a.size_bytes
+ FROM posts p JOIN comment_attachments a ON a.comment_id = p.comment_id
+ WHERE p.id IN (${postIds.map(() => '?').join(', ')}) ORDER BY p.id, a.created_at, a.media_id`).all(...postIds);
+ for (const r of linked) {
+ if (!out.has(r.post_id)) out.set(r.post_id, []);
+ if (!out.get(r.post_id).some((a) => a.media_id === r.media_id)) out.get(r.post_id).push(shapeAttachment({ ...r, url: `${String(config.mediaUrl || '').replace(/\/$/, '')}/o/${r.media_id}` }));
+ }
return out;
return out;
}
}
@@ -365,7 +374,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -365,7 +374,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
async function attach(rows, postId) {
async function attach(rows, postId) {
const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');
const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');
- for (const [i, r] of rows.entries()) await set.run(postId, i, r.media_id);
+ const linked = await db.prepare(`SELECT p.comment_id, ct.ref_service, ct.ref_type, ct.ref_id FROM posts p
+ JOIN comments c ON c.id = p.comment_id JOIN comment_threads ct ON ct.id = c.thread_id WHERE p.id = ?`).get(postId);
+ for (const [i, r] of rows.entries()) {
+ await set.run(postId, i, r.media_id);
+ if (linked) await db.prepare(`INSERT INTO comment_attachments
+ (media_id, ref_service, ref_type, ref_id, comment_id, owner_subject, filename, mime, size_bytes)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (media_id) DO NOTHING`)
+ .run(r.media_id, linked.ref_service, linked.ref_type, linked.ref_id, linked.comment_id, r.owner_subject,
+ r.filename || 'image', r.mime, r.size_bytes);
+ }
}
}
function shapePost(p, v, projections, attachments = null, pastes = null, mod = moderator(v)) {
function shapePost(p, v, projections, attachments = null, pastes = null, mod = moderator(v)) {
@@ -521,6 +539,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -521,6 +539,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
// ═════════════════════════════════════════════════════════
// ═════════════════════════════════════════════════════════
return {
return {
+ setCommentService(service) { commentService = service; },
SORTS: store.SORTS,
SORTS: store.SORTS,
THREADS_PER_PAGE,
THREADS_PER_PAGE,
POSTS_PER_PAGE,
POSTS_PER_PAGE,
@@ -973,6 +992,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -973,6 +992,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
*/
*/
async createThread(v, spaceSlug, body = {}) {
async createThread(v, spaceSlug, body = {}) {
const space = await spaceFor(v, spaceSlug);
const space = await spaceFor(v, spaceSlug);
+ if (['pictures', 'videos', 'clips', 'pastes'].includes(space.slug)) fail(403, 'space.item_threads_only', 'Item discussions begin with a comment on the item');
const w = writer(v);
const w = writer(v);
mayPostIn(v, space);
mayPostIn(v, space);
await requireMembership(v, space);
await requireMembership(v, space);
@@ -1026,6 +1046,24 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -1026,6 +1046,24 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
// Platform blocks: nobody replies in a thread whose author blocked them.
// Platform blocks: nobody replies in a thread whose author blocked them.
await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
await blocks.refuseIfBlocked(db, [thread.author_subject], w.author, 'reply in this thread');
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
+ const linked = await db.prepare('SELECT access_id FROM comment_threads WHERE forum_thread_id = ?').get(thread.id);
+ if (linked) {
+ if (!commentService) fail(503, 'comments.unavailable', 'Comments are unavailable');
+ const pictureRows = await preparePictures(w, body.pictures);
+ const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
+ const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
+ postLimiter.check(w.key, text);
+ const out = await commentService.add(v, linked.access_id, { message: text });
+ postLimiter.record(w.key, text);
+ const post = await store.getPost(db, (await commentStore.getComment(db, out.comment.id)).forum_post_id);
+ await attach(images, post.id);
+ const projections = await authors.projectionsFor([post.author_subject]);
+ const position = (await db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id)).c;
+ const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
+ pingThread(space, thread);
+ return { post: shapePost(post, v, projections, await attachmentsOf([post.id]), null), page,
+ url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
+ }
const pictureRows = await preparePictures(w, body.pictures);
const pictureRows = await preparePictures(w, body.pictures);
const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
const listed = Array.isArray(body.attachments) ? body.attachments : (body.attachments == null ? [] : [body.attachments]);
const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
const images = await claimable(w, [...listed, ...pictureRows.map((x) => x.media_id)]);
@@ -1053,6 +1091,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -1053,6 +1091,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const mod = await moderates(v, space);
const mod = await moderates(v, space);
if (!mine && !mod) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
if (!mine && !mod) fail(403, 'post.not_yours', 'Only the author or a moderator edits a post');
if (thread.locked && !mod) fail(403, 'thread.locked', 'This thread is locked');
if (thread.locked && !mod) fail(403, 'thread.locked', 'This thread is locked');
+ if (post.comment_id) {
+ if (!mine) fail(403, 'comment.not_yours', 'Only the author can edit a comment');
+ const text = cleanBody(body.body != null ? body.body : body.body_markdown);
+ await store.editPost(db, post.id, text, v.subject || v.service || null);
+ await commentStore.editComment(db, post.comment_id, text);
+ const next = await store.getPost(db, post.id);
+ const projections = await authors.projectionsFor([next.author_subject]);
+ pingThread(space, thread);
+ return { post: shapePost(next, v, projections, null, null, mod) };
+ }
const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
const next = await store.editPost(db, post.id, cleanBody(body.body != null ? body.body : body.body_markdown), v.subject || v.service || null);
if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));
if (relay && next && next.revision !== post.revision) await hook(async () => await relay.enqueueEdit(next));
const projections = await authors.projectionsFor([next.author_subject]);
const projections = await authors.projectionsFor([next.author_subject]);
@@ -1075,6 +1123,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -1075,6 +1123,15 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const mine = person(v) && post.author_subject === v.subject;
const mine = person(v) && post.author_subject === v.subject;
if (!mine && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
if (!mine && !await moderates(v, space)) fail(403, 'post.not_yours', 'Only the author or a moderator deletes a post');
if (post.is_opening) return await removeThread(v, thread, space, body);
if (post.is_opening) return await removeThread(v, thread, space, body);
+ if (post.comment_id) {
+ const refs = await db.prepare('SELECT media_id FROM comment_attachments WHERE comment_id = ?').all(post.comment_id);
+ await commentStore.softDeleteComment(db, post.comment_id, v.subject || v.service || null);
+ if (media && typeof media.deleteObject === 'function') for (const ref of refs) {
+ try { await media.deleteObject(ref.media_id); } catch (err) { console.warn('[Forum] linked attachment cleanup failed:', err.message); }
+ }
+ pingThread(space, thread);
+ return { ok: true, id: post.id };
+ }
const fields = moderation.fields(body);
const fields = moderation.fields(body);
await db.tx(async () => {
await db.tx(async () => {
await store.softDeletePost(db, post.id);
await store.softDeletePost(db, post.id);
@@ -219,6 +219,7 @@ async function updatePaste(db, id, patch, editedBy = null) {
@@ -219,6 +219,7 @@ async function updatePaste(db, id, patch, editedBy = null) {
sets.push('updated_at = ov_now()');
sets.push('updated_at = ov_now()');
await db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
await db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
const next = await getById(db, id);
const next = await getById(db, id);
+ if (patch.visibility !== undefined) await require('../comments/forum-links').hidePasteThread(db, next.slug, next.visibility === 'public' && !next.burn_after_read);
if (textChanged) {
if (textChanged) {
await db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')
await db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')
.run(id, next.revision, next.title, next.content, next.language, editedBy);
.run(id, next.revision, next.title, next.content, next.language, editedBy);
@@ -239,6 +240,7 @@ async function softDelete(db, id) {
@@ -239,6 +240,7 @@ async function softDelete(db, id) {
const cur = await getById(db, id);
const cur = await getById(db, id);
const n = (await db.prepare(`UPDATE pastes SET deleted_at = ov_now(), content = NULL, screenshot_url = NULL, metadata = NULL,
const n = (await db.prepare(`UPDATE pastes SET deleted_at = ov_now(), content = NULL, screenshot_url = NULL, metadata = NULL,
ai_summary = NULL, ai_tags = NULL, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL`).run(id)).changes;
ai_summary = NULL, ai_tags = NULL, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL`).run(id)).changes;
+ if (n && cur) await require('../comments/forum-links').hidePasteThread(db, cur.slug, false);
if (n && cur) await require('../events').pasteDeleted(cur.slug); // community.paste.deleted
if (n && cur) await require('../events').pasteDeleted(cur.slug); // community.paste.deleted
return n;
return n;
});
});
@@ -373,6 +375,7 @@ async function setVisibility(db, pasteId, visibility) {
@@ -373,6 +375,7 @@ async function setVisibility(db, pasteId, visibility) {
return await db.tx(async () => {
return await db.tx(async () => {
const cur = await getById(db, pasteId);
const cur = await getById(db, pasteId);
const r = await db.prepare('UPDATE pastes SET visibility = ?, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);
const r = await db.prepare('UPDATE pastes SET visibility = ?, updated_at = ov_now() WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId);
+ if (r.changes && cur) await require('../comments/forum-links').hidePasteThread(db, cur.slug, visibility === 'public' && !cur.burn_after_read);
if (r.changes && cur && cur.visibility !== visibility) await require('../events').pasteUpdated(await getById(db, pasteId), ['visibility']);
if (r.changes && cur && cur.visibility !== visibility) await require('../events').pasteUpdated(await getById(db, pasteId), ['visibility']);
return r.changes;
return r.changes;
});
});
@@ -511,7 +511,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
@@ -511,7 +511,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
<a class="btn btn-sm" href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
<a class="btn btn-sm" href="/p/${esc(pageSlug)}/download" download="${esc(pageSlug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
<button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
<button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
- ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/forum/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
+ ${!restricted && p.visibility === 'public' && !p.burn_after_read ? `<a class="btn btn-sm" href="${esc(comments && comments.thread.forum_url || '#comments')}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
</div>`;
</div>`;
@@ -135,6 +135,8 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c
@@ -135,6 +135,8 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c
}
}
}
}
await tx.exec('UPDATE pastes SET visibility = $1, updated_at = ov_now() WHERE slug = $2 AND deleted_at IS NULL', [VISIBILITY[audience] || 'private', ref.id]);
await tx.exec('UPDATE pastes SET visibility = $1, updated_at = ov_now() WHERE slug = $2 AND deleted_at IS NULL', [VISIBILITY[audience] || 'private', ref.id]);
+ await tx.exec(`UPDATE threads SET deleted_at = CASE WHEN $1 = 'public' THEN NULL ELSE ov_now() END
+ WHERE external_key = 'community:paste:' || $2 AND space_id = (SELECT id FROM spaces WHERE slug = 'pastes')`, [audience, ref.id]);
tx.afterCommit(() => refreshSearch(ref.id));
tx.afterCommit(() => refreshSearch(ref.id));
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -0,0 +1,78 @@
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 }, appOpts: {
+ commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } },
+ forumLimits: { posts: { cooldownSec: 0, perMinute: 1000 } },
+ } });
+ const person = t.network.addUser({ network_user_id: 71, username: 'threader', display_name: 'Threader' });
+ const cookie = t.network.sign({ id: 71, subject_id: person.subject_id, username: 'threader', display_name: 'Threader', role: 'user' });
+ const call = (url, method = 'GET', json = null) => t.get(url, {
+ method, headers: json == null ? {} : { 'content-type': 'application/json' },
+ body: json == null ? undefined : JSON.stringify(json), cookies: [`ov_token=${cookie}`],
+ });
+ const create = async (visibility) => {
+ const response = await call('/api/pastes', 'POST', { title: 'A pictured paste', content: `hello ${visibility} ${Math.random()}`, visibility });
+ assert.strictEqual(response.status, 201, response.text);
+ return response.json().slug;
+ };
+
+ await check('a public item enters its board on first comment and forum replies share the comment', async () => {
+ const slug = await create('public');
+ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'community', type: 'paste', id: slug } });
+ const access = resolved.json().thread.id;
+ const before = await call('/api/v1/spaces/pastes/threads');
+ assert.strictEqual(before.json().threads.length, 0);
+ const first = await call(`/api/v1/comments/threads/${access}/comments`, 'POST', { message: 'From the paste' });
+ assert.strictEqual(first.status, 201, first.text);
+ const row = await t.db.prepare('SELECT id, forum_thread_id FROM comment_threads WHERE access_id = ?').get(access);
+ assert.ok(row.forum_thread_id);
+ const url = `/api/v1/spaces/pastes/threads/item-${row.id}`;
+ const forum = await call(url);
+ assert.strictEqual(forum.status, 200, forum.text);
+ assert.strictEqual(forum.json().posts[1].body_markdown, 'From the paste');
+ const canonical = await require('../server/pastes/store').canonicalForRef(t.db, slug);
+ assert.ok((await call(`/p/${canonical}`)).text.includes(`/forum/pastes/item-${row.id}`));
+ const reply = await call(`${url}/posts`, 'POST', { body: 'From the forum' });
+ assert.strictEqual(reply.status, 201, reply.text);
+ const comments = await call(`/api/v1/comments/threads/${access}`);
+ assert.deepStrictEqual(comments.json().comments.map((c) => c.message), ['From the paste', 'From the forum']);
+ const postId = reply.json().post.id;
+ const edited = await call(`/api/v1/posts/${postId}`, 'PUT', { body: 'Edited in the forum' });
+ assert.strictEqual(edited.status, 200, edited.text);
+ assert.ok((await call(`/api/v1/comments/threads/${access}`)).text.includes('Edited in the forum'));
+ const privateResult = await call(`/api/pastes/${slug}`, 'PUT', { visibility: 'private' });
+ assert.strictEqual(privateResult.status, 200, privateResult.text);
+ assert.strictEqual((await call(url)).status, 404);
+ assert.strictEqual((await call('/api/v1/spaces/pastes/threads')).json().threads.length, 0);
+ });
+
+ await check('unlisted comments stay off the public board', async () => {
+ const slug = await create('unlisted');
+ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'community', type: 'paste', id: slug } });
+ const created = await call(`/api/v1/comments/threads/${resolved.json().thread.id}/comments`, 'POST', { message: 'Link only' });
+ assert.strictEqual(created.status, 201, created.text);
+ assert.strictEqual((await t.db.prepare('SELECT forum_thread_id FROM comment_threads WHERE access_id = ?').get(resolved.json().thread.id)).forum_thread_id, null);
+ });
+
+ await check('backfill preserves old comment ids and timestamps and is repeatable', async () => {
+ const slug = await create('public');
+ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'community', type: 'paste', id: slug } });
+ const thread = await t.db.prepare('SELECT id FROM comment_threads WHERE access_id = ?').get(resolved.json().thread.id);
+ const first = await t.db.prepare("INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, 'Older', '2025-01-01 01:00:00') RETURNING id").run(thread.id, person.subject_id);
+ const second = await t.db.prepare("INSERT INTO comments (thread_id, author_subject, message, created_at) VALUES (?, ?, 'Newer', '2025-01-01 02:00:00') RETURNING id").run(thread.id, person.subject_id);
+ const sql = fs.readFileSync(path.join(__dirname, '../migrations/0034_comment_forum_backfill.sql'), 'utf8');
+ await t.db.exec(sql);
+ await t.db.exec(sql);
+ const rows = await t.db.prepare('SELECT comment_id, created_at FROM posts WHERE comment_id IN (?, ?) ORDER BY id').all(first.lastInsertRowid, second.lastInsertRowid);
+ assert.deepStrictEqual(rows.map((r) => r.comment_id), [first.lastInsertRowid, second.lastInsertRowid]);
+ assert.deepStrictEqual(rows.map((r) => r.created_at), ['2025-01-01 01:00:00', '2025-01-01 02:00:00']);
+ });
+
+ await t.close();
+ done();
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -118,7 +118,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -118,7 +118,7 @@ const { boot, check, done } = require('./helpers/app');
await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {
await check('"Discuss in a space" from a paste: the picker, then a new topic with the paste attached', async () => {
const pasteUrl = await require('../server/pastes/store').canonicalForRef(t.db, 'snip1');
const pasteUrl = await require('../server/pastes/store').canonicalForRef(t.db, 'snip1');
const paste = (await call(`/p/${pasteUrl}`)).text;
const paste = (await call(`/p/${pasteUrl}`)).text;
- assert.ok(paste.includes('/forum/discuss?paste=snip1'));
+ assert.ok(paste.includes('href="#comments"'), 'an item with no comments opens its own composer');
const picker = (await call('/forum/discuss?paste=snip1', { cookie: alexJwt })).text;
const picker = (await call('/forum/discuss?paste=snip1', { cookie: alexJwt })).text;
assert.ok(picker.includes('/forum/general/new?paste=snip1') && !picker.includes('/forum/roadmap/new'));
assert.ok(picker.includes('/forum/general/new?paste=snip1') && !picker.includes('/forum/roadmap/new'));
const form = (await call('/forum/general/new?paste=snip1', { cookie: alexJwt })).text;
const form = (await call('/forum/general/new?paste=snip1', { cookie: alexJwt })).text;