openvibers/openvibe.community Commits
Connect public media comments to item forum boards (#372)
Changes
nnew file mode 100644@@@ -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; });@@@ -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 });@@@ -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 };@@@ -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] };},@@@ -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.createdreturn comment;@@@ -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, viconst 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, viconst 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, viif (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, vimoderators: 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),};},@@@ -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_idFROM posts p JOIN threads t ON t.id = p.thread_idWHERE 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, spaceSreturn 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_idWHERE 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 NULLAND 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_labelsORDER BY replies DESC, last_at DESC, t.id DESC LIMIT ?`).all(since, limit);}@@@ -69,7 +69,8 @@ function createSearchDocuments({ db }) {s.members_only_owner AS space_members_only, c.slug AS category_slugFROM threads t JOIN spaces s ON s.id = t.space_id LEFT JOIN categories c ON c.id = t.category_idWHERE 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 };nnew file mode 100644@@@ -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); });@@@ -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'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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; });
@@ -190,7 +190,7 @@ async function createApp(opts = {}) {
@@ -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
// (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.
// 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 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
// 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
// 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
// service principal. A private object is reachable only with Community's token, so every read goes
@@ -262,7 +262,7 @@ async function createApp(opts = {}) {
@@ -262,7 +262,7 @@ async function createApp(opts = {}) {
// Sharing (openvibe-sdk/share): the one access check for a paste, its members, transfers and the
// 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.
// 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, fetchImpl: opts.fetchImpl });
forum.setCommentService(comments);
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 });
@@ -3,6 +3,32 @@
@@ -3,6 +3,32 @@
// A forum post is a view of a comment. The comment remains the source of its text,
// 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.
// 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 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) {
async function eligiblePaste(db, thread) {
if (thread.ref_service !== 'community' || thread.ref_type !== 'paste') return null;
if (thread.ref_service !== 'community' || thread.ref_type !== 'paste') return null;
@@ -10,33 +36,38 @@ async function eligiblePaste(db, thread) {
@@ -10,33 +36,38 @@ async function eligiblePaste(db, thread) {
.get(thread.ref_id) || null;
.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;
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;
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 slug = `item-${thread.id}`;
const info = await db.prepare(`INSERT INTO threads (space_id, slug, title, author_subject, origin, external_key)
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`)
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);
const row = await db.prepare('SELECT id FROM threads WHERE space_id = ? AND external_key = ?').get(space.id, key);
if (info.changes) {
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, ?)")
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);
await db.prepare('UPDATE comment_threads SET forum_thread_id = ? WHERE id = ?').run(row.id, thread.id);
return row.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);
const thread = await db.prepare('SELECT * FROM comment_threads WHERE id = ?').get(comment.thread_id);
if (!thread) return;
if (!thread) return;
- const forumId = await ensureForumThread(db, thread);
+ const forumId = await ensureForumThread(db, thread, external);
if (!forumId) return;
if (!forumId) return;
const existing = await db.prepare('SELECT id FROM posts WHERE comment_id = ?').get(comment.id);
const existing = await db.prepare('SELECT id FROM posts WHERE comment_id = ?').get(comment.id);
if (existing) return existing.id;
if (existing) return existing.id;
@@ -66,4 +97,4 @@ async function hidePasteThread(db, slug, visible) {
@@ -66,4 +97,4 @@ async function hidePasteThread(db, slug, visible) {
.run(visible ? 1 : 0, `community:paste:${slug}`);
.run(visible ? 1 : 0, `community:paste:${slug}`);
}
}
-module.exports = { ensureForumThread, syncComment, updatePost, hidePasteThread };
+module.exports = { BOARDS, publicExternalItem, ensureForumThread, syncComment, updatePost, hidePasteThread };
@@ -45,6 +45,7 @@ const { createPersonLimiter } = require('../limits');
@@ -45,6 +45,7 @@ const { createPersonLimiter } = require('../limits');
const { discussionModerator: moderator } = require('../identity/capabilities');
const { discussionModerator: moderator } = require('../identity/capabilities');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
const commentAttachments = require('./attachments');
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).
* 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 =
@@ -90,7 +91,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
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,
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) };
created_at: isoTime(t.created_at), updated_at: isoTime(t.updated_at) };
}
}
@@ -352,7 +353,8 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -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');
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);
commentLimiter.record(key, message);
return { comment: (await shapeComments([c], v, t))[0] };
return { comment: (await shapeComments([c], v, t))[0] };
},
},
@@ -99,7 +99,7 @@ async function refIsPublic(db, t) {
@@ -99,7 +99,7 @@ async function refIsPublic(db, t) {
}
}
/** Insert a comment and keep the thread's and parent's counters in step. */
/** Insert a comment and keep the thread's and parent's counters in step. */
-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 () => {
return await db.tx(async () => {
const thread = await getThread(db, thread_id);
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 });
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 =
@@ -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);
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);
+ await forumLinks.syncComment(db, comment, forum_item);
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;
@@ -54,6 +54,7 @@ const reactions = require('./reactions');
@@ -54,6 +54,7 @@ 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 commentStore = require('../comments/store');
+const forumLinks = require('../comments/forum-links');
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,7 +98,7 @@ const THREADS_PER_DAY = 20;
@@ -97,7 +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, fetchImpl = globalThis.fetch } = {}) {
let commentService = 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 || {}) });
@@ -213,14 +214,22 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -213,14 +214,22 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const space = await spaceFor(v, spaceSlug);
const space = await spaceFor(v, spaceSlug);
const thread = await store.getThreadBySlug(db, space.id, threadSlug);
const thread = await store.getThreadBySlug(db, space.id, threadSlug);
if (!thread) fail(404, 'thread.not_found', 'No such thread');
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 };
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) {
async function postFor(v, postId) {
const post = /^\d{1,15}$/.test(String(postId)) ? await store.getPost(db, Number(postId)) : null;
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 thread = post ? await store.getThread(db, post.thread_id) : null;
const space = thread ? await store.getSpaceById(db, thread.space_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 (!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).
// 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; }
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 };
return { post, thread, space };
@@ -609,6 +618,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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 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 shaped = await Promise.all(rows.map(async (r) => {
const sp = await shapeSpace(r, await membersOnly(r.members_only_owner, projections));
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);
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;
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;
return sp;
@@ -729,7 +743,12 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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');
if (q.category && !category) fail(404, 'category.not_found', 'No such category in this forum');
const statuses = STATUSES[space.thread_kind] || [];
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');
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 index = await this.listSpaces(v);
const self = index.spaces.find((x) => x.slug === space.slug) || {};
const self = index.spaces.find((x) => x.slug === space.slug) || {};
const mod = await moderates(v, space);
const mod = await moderates(v, space);
@@ -741,7 +760,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -741,7 +760,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
moderators: mod ? await moderatorList(space) : undefined,
moderators: mod ? await moderatorList(space) : undefined,
children: index.spaces.filter((c) => c.parent && c.parent.slug === space.slug),
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,
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),
};
};
},
},
@@ -34,6 +34,7 @@ async function lastPosts(db, spaceIds) {
@@ -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
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
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
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`);
ORDER BY p.id DESC LIMIT 1`);
for (const id of spaceIds) { const r = await q.get(id); if (r) out.set(id, r); }
for (const id of spaceIds) { const r = await q.get(id); if (r) out.set(id, r); }
return out;
return out;
@@ -209,6 +210,7 @@ async function recentThreads(db, { visibilities = ['public'], limit = 50, spaceS
@@ -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
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 = ?)
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 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);
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 } = {}) {
@@ -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 >= ?
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
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 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
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);
ORDER BY replies DESC, last_at DESC, t.id DESC LIMIT ?`).all(since, limit);
}
}
@@ -69,7 +69,8 @@ function createSearchDocuments({ db }) {
@@ -69,7 +69,8 @@ function createSearchDocuments({ db }) {
s.members_only_owner AS space_members_only, c.slug AS category_slug
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
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);
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 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 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 };
const facets = { space: t.space_slug, kind: t.kind || 'discussion', replies: Number(t.reply_count) || 0, score: Number(t.score) || 0 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -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); });
@@ -32,6 +32,7 @@ const INVENTORY = {
@@ -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/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/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/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/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/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'],
'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'],