openvibers/openvibe.community Commits

Connect public media comments to item forum boards (#372)

OP OpenVibers committed 2 days ago · 5591593dfd · 10 files, +195 −23

* Link public media comments with official forum boards

* Classify item forum visibility lookups

Changes

ddiff --git a/scripts/backfill-item-forums.js b/scripts/backfill-item-forums.js
nnew file mode 100644
iindex 0000000..002acfa
---- /dev/null
++++ b/scripts/backfill-item-forums.js
@@@ -0,0 +1,30 @@
++#!/usr/bin/env node
++'use strict';
++// Dry run by default. The explicit --apply mode creates mapped forum views only
++// for items the owning service currently confirms are public.
++const config = require('../server/config');
++const { createDb } = require('openvibe-sdk/db');
++const links = require('../server/comments/forum-links');
++
++(async () => {
++ const apply = process.argv.includes('--apply');
++ if (!config.db.url) throw new Error('DATABASE_URL is required');
++ const db = createDb({ url: config.db.url, service: 'community-item-forum-backfill', max: 1 });
++ const counts = { checked: 0, eligible_threads: 0, eligible_comments: 0, excluded_threads: 0, moved_threads: 0, moved_comments: 0 };
++ const threads = await db.prepare(`SELECT * FROM comment_threads WHERE ref_service IN ('live', 'pics')
++ AND ref_type IN ('vod', 'clip', 'picture') AND comment_count > 0 AND forum_thread_id IS NULL ORDER BY id`).all();
++ for (const thread of threads) {
++ counts.checked++;
++ const item = await links.publicExternalItem(thread);
++ if (!item) { counts.excluded_threads++; continue; }
++ const comments = await db.prepare('SELECT * FROM comments WHERE thread_id = ? ORDER BY id').all(thread.id);
++ counts.eligible_threads++;
++ counts.eligible_comments += comments.length;
++ if (apply) await db.tx(async () => {
++ for (const comment of comments) if (await links.syncComment(db, comment, item)) counts.moved_comments++;
++ counts.moved_threads++;
++ });
++ }
++ console.log(JSON.stringify({ mode: apply ? 'apply' : 'dry-run', ...counts }));
++ await db.close();
++})().catch((err) => { console.error(`item forum backfill failed: ${err.message}`); process.exitCode = 1; });
ddiff --git a/server/app.js b/server/app.js
iindex bc7ba2f..f22983d 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -190,7 +190,7 @@ async function createApp(opts = {}) {
// (nothing mounted, nothing sent). The key file is served at /<key>.txt and the paste and forum
// services ping the engines when a public, indexable page appears, changes or goes away.
const indexnow = opts.indexnow || createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) });
-- const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow });
++ const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow, fetchImpl: opts.fetchImpl });
// Screenshot bytes: OpenVibe.Media's Object API v2 (a private med_ object owned by the person when
// signed in), or the v1 community file store (legacy:community:file:<key>) without Community's
// service principal. A private object is reachable only with Community's token, so every read goes
@@@ -262,7 +262,7 @@ async function createApp(opts = {}) {
// Sharing (openvibe-sdk/share): the one access check for a paste, its members, transfers and the
// 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 });
++ const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p), media: mediaObjects, fetchImpl: opts.fetchImpl });
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 });
ddiff --git a/server/comments/forum-links.js b/server/comments/forum-links.js
iindex dcb7ef0..deb7323 100644
---- a/server/comments/forum-links.js
++++ b/server/comments/forum-links.js
@@@ -3,6 +3,32 @@
// 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";
++const BOARDS = { 'live/vod': 'videos', 'live/clip': 'clips', 'pics/picture': 'pictures' };
++
++async function publicExternalItem(ref, fetchImpl = globalThis.fetch) {
++ const board = BOARDS[`${ref.ref_service || ref.service}/${ref.ref_type || ref.type}`];
++ const id = String(ref.ref_id || ref.id || '');
++ if (!board || (board !== 'pictures' && !/^\d{1,15}$/.test(id)) || (board === 'pictures' && !/^pic_[0-9A-HJKMNP-TV-Z]{26}$/.test(id))) return null;
++ const endpoint = board === 'pictures' ? `https://openvibe.pics/api/v1/pictures/${encodeURIComponent(id)}`
++ : `https://openvibe.live/api/${board === 'videos' ? 'vods' : 'clips'}/${encodeURIComponent(id)}`;
++ try {
++ const response = await fetchImpl(endpoint, { redirect: 'error', signal: AbortSignal.timeout(3000), headers: { Accept: 'application/json' } });
++ if (!response.ok) return null;
++ const data = await response.json();
++ const item = board === 'videos' ? data.vod : board === 'clips' ? data.clip : data;
++ if (!item || String(item.id) !== id || (board === 'pictures' ? item.listed !== true || item.state !== 'active'
++ : !(item.visibility === 'public' || (!item.visibility && !!item.is_public)))) return null;
++ const rawImage = item.thumbnail_url || item.url || null;
++ let image = null;
++ if (rawImage) {
++ try { const u = new URL(rawImage); if (u.protocol === 'https:' && ['openvibe.media', 'openvibe.live', 'openvibe.pics'].includes(u.hostname)) image = u.href; } catch { /* no image */ }
++ }
++ const url = board === 'pictures' ? `https://openvibe.pics/i/${encodeURIComponent(item.slug || id)}`
++ : `https://openvibe.video/${board === 'videos' ? 'v' : 'c'}/${id}`;
++ return { board, id, title: String(item.title || item.alt || ref.ref_label || ref.label || (board === 'pictures' ? 'Picture' : board === 'videos' ? 'Video' : 'Clip')).slice(0, 200),
++ maker: String(item.username || item.creator_username || data.streamer?.username || '').slice(0, 80), image, url };
++ } catch { return null; }
++}
async function eligiblePaste(db, thread) {
if (thread.ref_service !== 'community' || thread.ref_type !== 'paste') return null;
@@@ -10,33 +36,38 @@ async function eligiblePaste(db, thread) {
.get(thread.ref_id) || null;
}
--async function ensureForumThread(db, thread) {
-- const paste = await eligiblePaste(db, thread);
-- if (!paste) return null;
++async function ensureForumThread(db, thread, external = null) {
++ // An already mapped discussion keeps its comments in step while the item is
++ // private; the forum read gate decides whether that view is public today.
if (thread.forum_thread_id) return thread.forum_thread_id;
-- const space = await db.prepare("SELECT id FROM spaces WHERE slug = 'pastes'").get();
++ const paste = await eligiblePaste(db, thread);
++ if (!paste && !external) return null;
++ const board = paste ? 'pastes' : external.board;
++ const space = await db.prepare('SELECT id FROM spaces WHERE slug = ?').get(board);
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 key = `${thread.ref_service}:${thread.ref_type}:${thread.ref_id}`;
++ const title = paste ? String(paste.title || (paste.type === 'screenshot' ? 'Screenshot' : 'Untitled paste')).slice(0, 200) : external.title;
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);
++ .run(space.id, slug, title, paste ? paste.owner_subject : null, 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)` : '';
++ const link = paste ? `/p/${encodeURIComponent(paste.slug)}` : external.url;
++ const image = paste ? (paste.type === 'screenshot' ? `\n\n![${title.replace(/[\[\]]/g, '')}](${link}/screenshot)` : '')
++ : external.image ? `\n\n![${title.replace(/[\[\]]/g, '')}](${external.image})` : '';
++ const maker = !paste && external.maker ? `\n\nShared by ${external.maker.replace(/[\[\]]/g, '')}` : '';
await db.prepare("INSERT INTO posts (thread_id, origin, is_opening, body_markdown) VALUES (?, 'system', 1, ?)")
-- .run(row.id, `[${title.replace(/[\[\]]/g, '')}](${link})${image}`);
++ .run(row.id, `[${title.replace(/[\[\]]/g, '')}](${link})${image}${maker}`);
}
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) {
++async function syncComment(db, comment, external = null) {
const thread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(comment.thread_id);
if (!thread) return;
-- const forumId = await ensureForumThread(db, thread);
++ const forumId = await ensureForumThread(db, thread, external);
if (!forumId) return;
const existing = await db.prepare('SELECT id FROM posts WHERE comment_id = ?').get(comment.id);
if (existing) return existing.id;
@@@ -66,4 +97,4 @@ async function hidePasteThread(db, slug, visible) {
.run(visible ? 1 : 0, `community:paste:${slug}`);
}
--module.exports = { ensureForumThread, syncComment, updatePost, hidePasteThread };
++module.exports = { BOARDS, publicExternalItem, ensureForumThread, syncComment, updatePost, hidePasteThread };
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 2d9726d..019c99a 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -45,6 +45,7 @@ const { createPersonLimiter } = require('../limits');
const { discussionModerator: moderator } = require('../identity/capabilities');
const blocks = require('../identity/blocks');
const commentAttachments = require('./attachments');
++const forumLinks = require('./forum-links');
/**
* The entity types a browser may open a thread for (services with community.comment.write: any).
@@@ -90,7 +91,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
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,
-- forum_url: t.forum_thread_id && t.ref_service === 'community' && t.ref_type === 'paste' ? `/forum/pastes/item-${t.id}` : null,
++ forum_url: t.forum_thread_id ? `/forum/${t.ref_service === 'community' ? 'pastes' : forumLinks.BOARDS[`${t.ref_service}/${t.ref_type}`]}/item-${t.id}` : null,
created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
}
@@@ -352,7 +353,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
if (!object || !object.owner || object.owner.subject !== author || object.lifecycle_status !== 'ready') fail(400, 'comment.attachment_invalid', 'One of the attached files is not yours');
}
}
-- const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message, attachment_ids: attachmentIds });
++ const forumItem = forumLinks.BOARDS[`${t.ref_service}/${t.ref_type}`] ? await forumLinks.publicExternalItem(t, fetchImpl) : null;
++ const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message, attachment_ids: attachmentIds, forum_item: forumItem });
commentLimiter.record(key, message);
return { comment: (await shapeComments([c], v, t))[0] };
},
ddiff --git a/server/comments/store.js b/server/comments/store.js
iindex 1ff6958..0d87f8a 100644
---- a/server/comments/store.js
++++ b/server/comments/store.js
@@@ -99,7 +99,7 @@ async function refIsPublic(db, t) {
}
/** Insert a comment and keep the thread's and parent's counters in step. */
--async function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message, attachment_ids = [] }) {
++async function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message, attachment_ids = [], forum_item = null }) {
return await db.tx(async () => {
const thread = await getThread(db, thread_id);
const staged = await stagedAttachments(db, attachment_ids, author_subject, thread && { service: thread.ref_service, type: thread.ref_type, id: thread.ref_id });
@@@ -113,7 +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);
++ await forumLinks.syncComment(db, comment, forum_item);
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
return comment;
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex e159c3c..d6a1ca4 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -54,6 +54,7 @@ const reactions = require('./reactions');
const moderation = require('./moderation');
const blocks = require('../identity/blocks');
const commentStore = require('../comments/store');
++const forumLinks = require('../comments/forum-links');
const STYLES = ['feed', 'forum'];
const SPACE_SLUG = /^[a-z0-9][a-z0-9-]{1,39}$/;
@@@ -97,7 +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 } = {}) {
++function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, chatRooms = null, limits = {}, config = {}, indexnow = null, fetchImpl = globalThis.fetch } = {}) {
let commentService = null;
const authors = createAuthors({ db, network });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
@@@ -213,14 +214,22 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const space = await spaceFor(v, spaceSlug);
const thread = await store.getThreadBySlug(db, space.id, threadSlug);
if (!thread) fail(404, 'thread.not_found', 'No such thread');
++ if (!await mappedItemPublic(thread)) fail(404, 'thread.not_found', 'No such thread');
return { space, thread };
}
++ async function mappedItemPublic(thread) {
++ if (!thread || !thread.external_key || !/^(live:(vod|clip)|pics:picture):/.test(thread.external_key)) return true;
++ const [service, type, ...rest] = thread.external_key.split(':');
++ return !!await forumLinks.publicExternalItem({ service, type, id: rest.join(':') }, fetchImpl);
++ }
++
async function postFor(v, postId) {
const post = /^\d{1,15}$/.test(String(postId)) ? await store.getPost(db, Number(postId)) : null;
const thread = post ? await store.getThread(db, post.thread_id) : null;
const space = thread ? await store.getSpaceById(db, thread.space_id) : null;
if (!post || !thread || !space || post.deleted_at) fail(404, 'post.not_found', 'No such post');
++ if (!await mappedItemPublic(thread)) fail(404, 'post.not_found', 'No such post');
// A post in a staff space looks exactly like a missing one (not space.not_found, which would confirm it).
try { await spaceFor(v, space.slug); } catch (err) { if (err.status === 404) fail(404, 'post.not_found', 'No such post'); throw err; }
return { post, thread, space };
@@@ -609,6 +618,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const projections = await authors.projectionsFor([...rows.map((r) => r.members_only_owner), ...[...last.values()].map((l) => l.author_subject)]);
const shaped = await Promise.all(rows.map(async (r) => {
const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));
++ if (['videos', 'clips', 'pictures'].includes(r.slug)) {
++ // The database cannot know a remote item's current audience. No stale
++ // count or activity hint may disclose a newly private item here.
++ sp.thread_count = null; sp.post_count = null; sp.last_activity_at = null;
++ }
const l = last.get(r.id);
sp.last_post = l ? { id: l.post_id, thread: { slug: l.thread_slug, title: l.thread_title, url: threadPath(r.slug, l.thread_slug) }, author: authors.author(l.author_subject, l.origin, projections), created_at: isoTime(l.created_at) } : null;
return sp;
@@@ -729,7 +743,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
if (q.category && !category) fail(404, 'category.not_found', 'No such category in this forum');
const statuses = STATUSES[space.thread_kind] || [];
if (q.status && !statuses.includes(q.status)) fail(400, 'thread.invalid_status', statuses.length ? `status is one of ${statuses.join(', ')}` : 'Threads in this forum have no status');
-- const { rows, total } = await store.listThreads(db, space.id, { sort, limit: perPage, offset: (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
++ const externalBoard = ['videos', 'clips', 'pictures'].includes(space.slug);
++ const { rows, total: storedTotal } = await store.listThreads(db, space.id, { sort, limit: externalBoard ? 1000 : perPage,
++ offset: externalBoard ? 0 : (page - 1) * perPage, now: q.now || new Date(), categoryId: category ? category.id : null, status: q.status || null });
++ const visibleRows = externalBoard ? (await Promise.all(rows.map(async (row) => await mappedItemPublic(row) ? row : null))).filter(Boolean) : rows;
++ const total = externalBoard ? visibleRows.length : storedTotal;
++ const publicRows = externalBoard ? visibleRows.slice((page - 1) * perPage, page * perPage) : visibleRows;
const index = await this.listSpaces(v);
const self = index.spaces.find((x) => x.slug === space.slug) || {};
const mod = await moderates(v, space);
@@@ -741,7 +760,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
moderators: mod ? await moderatorList(space) : undefined,
children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),
groups: moderator(v) ? (await store.listGroups(db)).map((g) => ({ slug: g.slug, name: g.name })) : undefined,
-- threads: await shapeThreads(rows, () => space, v),
++ threads: await shapeThreads(publicRows, () => space, v),
};
},
ddiff --git a/server/forum/store.js b/server/forum/store.js
iindex 0bf7e57..3655c07 100644
---- a/server/forum/store.js
++++ b/server/forum/store.js
@@@ -34,6 +34,7 @@ async function lastPosts(db, spaceIds) {
const q = db.prepare(`SELECT t.slug AS thread_slug, t.title AS thread_title, p.author_subject, p.origin, p.created_at, p.id AS post_id
FROM posts p JOIN threads t ON t.id = p.thread_id
WHERE t.space_id = ? AND t.deleted_at IS NULL AND p.deleted_at IS NULL AND t.members_only_owner IS NULL
++ AND (t.external_key IS NULL OR (t.external_key NOT LIKE 'live:%' AND t.external_key NOT LIKE 'pics:%'))
ORDER BY p.id DESC LIMIT 1`);
for (const id of spaceIds) { const r = await q.get(id); if (r) out.set(id, r); }
return out;
@@@ -209,6 +210,7 @@ async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceS
return await db.prepare(`SELECT t.*, s.slug AS space_slug, s.name AS space_name FROM threads t JOIN spaces s ON s.id = t.space_id
WHERE t.deleted_at IS NULL AND s.visibility IN (${visibilities.map(() => '?').join(', ')}) AND (?::text IS NULL OR s.slug = ?)
AND s.members_only_owner IS NULL AND t.members_only_owner IS NULL
++ AND (t.external_key IS NULL OR (t.external_key NOT LIKE 'live:%' AND t.external_key NOT LIKE 'pics:%'))
ORDER BY t.created_at DESC, t.id DESC LIMIT ?`).all(...visibilities, spaceSlug, spaceSlug, limit);
}
@@@ -219,6 +221,7 @@ async function hotThisWeek(db, { since, limit = 6 } = {}) {
JOIN posts p ON p.thread_id = t.id AND p.is_opening = 0 AND p.deleted_at IS NULL AND p.created_at >= ?
WHERE t.deleted_at IS NULL AND t.members_only_owner IS NULL
AND s.visibility = 'public' AND s.members_only_owner IS NULL
++ AND (t.external_key IS NULL OR (t.external_key NOT LIKE 'live:%' AND t.external_key NOT LIKE 'pics:%'))
GROUP BY t.id, t.slug, t.title, s.slug, t.content_labels
ORDER BY replies DESC, last_at DESC, t.id DESC LIMIT ?`).all(since, limit);
}
ddiff --git a/server/search/documents.js b/server/search/documents.js
iindex 44e5e52..d7c57aa 100644
---- a/server/search/documents.js
++++ b/server/search/documents.js
@@@ -69,7 +69,8 @@ function createSearchDocuments({ db }) {
s.members_only_owner AS space_members_only, c.slug AS category_slug
FROM threads t JOIN spaces s ON s.id = t.space_id LEFT JOIN categories c ON c.id = t.category_id
WHERE t.id = ?`).get(threadId);
-- if (!t || t.deleted_at || t.space_visibility !== 'public' || t.space_members_only || t.members_only_owner) return { deleted: true };
++ if (!t || t.deleted_at || t.space_visibility !== 'public' || t.space_members_only || t.members_only_owner
++ || /^(live|pics):/.test(t.external_key || '')) return { deleted: true };
const posts = await db.prepare('SELECT body_markdown, is_opening FROM posts WHERE thread_id = ? AND deleted_at IS NULL ORDER BY is_opening DESC, id ASC').all(t.id);
const opening = posts.find((p) => p.is_opening);
const facets = { space: t.space_slug, kind: t.kind || 'discussion', replies: Number(t.reply_count) || 0, score: Number(t.score) || 0 };
ddiff --git a/test/item-forum-links.test.js b/test/item-forum-links.test.js
nnew file mode 100644
iindex 0000000..f1482db
---- /dev/null
++++ b/test/item-forum-links.test.js
@@@ -0,0 +1,85 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ let visibility = 'public';
++ let listed = true;
++ const pictureId = 'pic_01ARZ3NDEKTSV4RRFFQ69G5FAV';
++ const fetchImpl = async (url, options) => {
++ const text = String(url);
++ if (text === 'https://openvibe.live/api/vods/555') return new Response(JSON.stringify({ vod: {
++ id: 555, title: 'A public recording', visibility, thumbnail_url: 'https://openvibe.media/o/med_example', username: 'maker',
++ } }), { status: 200, headers: { 'content-type': 'application/json' } });
++ if (text === 'https://openvibe.live/api/clips/556') return new Response(JSON.stringify({ clip: {
++ id: 556, title: 'An unlisted clip', visibility: 'unlisted',
++ } }), { status: 200, headers: { 'content-type': 'application/json' } });
++ if (text === `https://openvibe.pics/api/v1/pictures/${pictureId}`) return new Response(JSON.stringify({
++ id: pictureId, slug: 'listed-picture', title: 'Listed picture', listed, state: 'active', views: 0,
++ url: 'https://openvibe.pics/i/listed-picture/full.jpg',
++ }), { status: 200, headers: { 'content-type': 'application/json' } });
++ return fetch(url, options);
++ };
++ const t = await boot({ authority: 'community', appOpts: { fetchImpl, commentLimits: { comments: { cooldownSec: 0, perMinute: 1000 } } } });
++ const person = t.network.addUser({ network_user_id: 81, username: 'viewer', display_name: 'Viewer' });
++ const service = t.network.signService({ cap: ['community.comment.write'] });
++ const call = (url, method = 'GET', json = null, headers = {}) => t.get(url, {
++ method, headers: { ...(json == null ? {} : { 'content-type': 'application/json' }), ...headers },
++ body: json == null ? undefined : JSON.stringify(json),
++ });
++ const resolve = (type, id) => call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'live', type, id } }, { authorization: `Bearer ${service}` });
++
++ await check('a public recording opens a Videos thread and its forum view follows visibility', async () => {
++ const resolved = await resolve('vod', '555');
++ assert.strictEqual(resolved.status, 201, resolved.text);
++ const access = resolved.json().thread.id;
++ const before = await call('/api/v1/spaces/videos/threads');
++ assert.deepStrictEqual(before.json().threads, []);
++ const added = await call(`/api/v1/comments/threads/${access}/comments`, 'POST', { message: 'Recorded moment' },
++ { authorization: `Bearer ${service}`, 'x-ov-subject': person.subject_id });
++ assert.strictEqual(added.status, 201, added.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 forum = await call(`/api/v1/spaces/videos/threads/item-${row.id}`);
++ assert.strictEqual(forum.status, 200, forum.text);
++ assert.strictEqual(forum.json().posts[1].body_markdown, 'Recorded moment');
++ assert.ok(forum.json().posts[0].body_markdown.includes('openvibe.video/v/555'));
++ assert.strictEqual((await call('/api/v1/spaces/videos/threads')).json().threads.length, 1);
++ visibility = 'private';
++ assert.deepStrictEqual((await call('/api/v1/spaces/videos/threads')).json().threads, []);
++ assert.strictEqual((await call(`/api/v1/spaces/videos/threads/item-${row.id}`)).status, 404);
++ const whilePrivate = await call(`/api/v1/comments/threads/${access}/comments`, 'POST', { message: 'Written while private' },
++ { authorization: `Bearer ${service}`, 'x-ov-subject': person.subject_id });
++ assert.strictEqual(whilePrivate.status, 201, whilePrivate.text);
++ visibility = 'public';
++ const restored = await call(`/api/v1/spaces/videos/threads/item-${row.id}`);
++ assert.strictEqual(restored.status, 200);
++ assert.ok(restored.text.includes('Written while private'));
++ });
++
++ await check('an unlisted clip can have comments but enters no public board', async () => {
++ const resolved = await resolve('clip', '556');
++ const added = await call(`/api/v1/comments/threads/${resolved.json().thread.id}/comments`, 'POST', { message: 'Link-only moment' },
++ { authorization: `Bearer ${service}`, 'x-ov-subject': person.subject_id });
++ assert.strictEqual(added.status, 201, added.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);
++ assert.deepStrictEqual((await call('/api/v1/spaces/clips/threads')).json().threads, []);
++ });
++ await check('a listed picture enters Pictures and disappears when unlisted', async () => {
++ const resolved = await call('/api/v1/comments/threads/resolve', 'POST', { ref: { service: 'pics', type: 'picture', id: pictureId } },
++ { authorization: `Bearer ${service}` });
++ assert.strictEqual(resolved.status, 201, resolved.text);
++ const added = await call(`/api/v1/comments/threads/${resolved.json().thread.id}/comments`, 'POST', { message: 'Picture discussion' },
++ { authorization: `Bearer ${service}`, 'x-ov-subject': person.subject_id });
++ assert.strictEqual(added.status, 201, added.text);
++ const row = await t.db.prepare('SELECT id FROM comment_threads WHERE access_id = ?').get(resolved.json().thread.id);
++ const url = `/api/v1/spaces/pictures/threads/item-${row.id}`;
++ assert.strictEqual((await call(url)).status, 200);
++ assert.strictEqual((await call('/api/v1/spaces/pictures/threads')).json().threads.length, 1);
++ listed = false;
++ assert.strictEqual((await call(url)).status, 404);
++ assert.deepStrictEqual((await call('/api/v1/spaces/pictures/threads')).json().threads, []);
++ });
++ await t.close();
++ done();
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 26ee9e5..9b7415b 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -32,6 +32,7 @@ const INVENTORY = {
'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],
'server/identity/profile-module.js': [3, 'Network modules API (community.profile) with a service token'],
'server/comments/service.js': [1, 'OpenVibe.Pics GET /api/v1/pictures/<id> at the fixed https://openvibe.pics origin, no redirect, to ask whether a picture is public; the id must match the picture-ID shape and is encoded'],
++ 'server/comments/forum-links.js': [1, 'Public item lookup at fixed OpenVibe.Live or OpenVibe.Pics origins; the validated item id is encoded in a fixed API path and redirects are rejected'],
'server/media/files.js': [2, 'OV_MEDIA_INTERNAL_URL v1 file store, service token'],
'server/media/objects.js': [3, 'OV_MEDIA_INTERNAL_URL Object API v2 and normalize-image paths, service token; fixed Media base, never a user URL'],
'server/pastes/review.js': [2, 'OpenVibe.Vision /api/v1/review (OV_VISION_URL, fixed path) with a service token; only the paste\'s Media URL is sent'],