openvibers/openvibe.mediahub Commits
Put Community comments and composer on Video and Pics item pages (#406)
Changes
nnew file mode 100644@@@ -0,0 +1,15 @@++.ovc-thread { margin: 24px 0; }++.ovc-head, .ovc-order, .ovc-comment header, .ovc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }++.ovc-head { justify-content: space-between; }++.ovc-comment { padding: 12px 0; border-bottom: 1px solid var(--border, #5555); }++.ovc-comment header img { border-radius: 50%; object-fit: cover; }++.ovc-comment time, .ovc-community { font-size: .875em; opacity: .8; }++.ovc-replies { margin-left: 24px; }++.ovc-actions form { display: inline; }++.ovc-attach { display: inline-block; margin: 4px 8px 4px 0; }++.ovc-attach--file { border: 1px solid currentColor; border-radius: 999px; padding: 4px 12px; }++.ovc-attach img { max-width: 120px; max-height: 90px; object-fit: cover; }++.ovc-order { display: inline-flex; gap: 0; border: 1px solid currentColor; border-radius: 8px; overflow: hidden; }++.ovc-order a { border: 0; border-radius: 0; }++.ovc-order a + a { border-left: 1px solid currentColor; }++.ovc-order .is-active { font-weight: 700; background: color-mix(in srgb, currentColor 12%, transparent); }nnew file mode 100644@@@ -0,0 +1,81 @@++(function () {++ 'use strict';++ var thread = document.querySelector('[data-ovc-thread]');++ if (!thread) return;++ var allowed = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);++ function read(html) { return new DOMParser().parseFromString(html, 'text/html').querySelector('[data-ovc-thread]'); }++ function replace(next, url) {++ if (!next) return false;++ thread.replaceWith(next); thread = next;++ if (url) history.pushState(null, '', url);++ return true;++ }++ document.addEventListener('click', async function (event) {++ var reply = event.target.closest('[data-ovc-reply]');++ if (reply && thread.contains(reply)) {++ var form = thread.querySelector('[data-ovt-composer]');++ var item = reply.closest('.ovc-comment');++ if (!form || !item) return;++ event.preventDefault();++ var parent = form.querySelector('[name="parent_id"]');++ if (!parent) { parent = document.createElement('input'); parent.type = 'hidden'; parent.name = 'parent_id'; form.appendChild(parent); }++ parent.value = reply.dataset.ovcReply;++ var slot = item.querySelector(':scope > [data-ovc-composer-slot]');++ if (!slot) { slot = document.createElement('div'); slot.dataset.ovcComposerSlot = ''; item.insertBefore(slot, item.querySelector(':scope > .ovc-replies')); }++ slot.appendChild(form);++ form.querySelector('textarea').focus();++ history.replaceState(null, '', reply.href);++ return;++ }++ var more = event.target.closest('[data-ovc-more]');++ if (more && thread.contains(more)) {++ event.preventDefault();++ more.setAttribute('aria-busy', 'true');++ try {++ var response = await fetch(more.href, { credentials: 'same-origin' });++ if (!response.ok) throw new Error('Replies could not be loaded.');++ var next = read(await response.text());++ var currentReplies = more.closest('[data-ovc-replies]');++ var loadedReplies = next && next.querySelector('[data-ovc-replies="' + more.dataset.ovcMore + '"]');++ if (!loadedReplies) throw new Error('Replies could not be loaded.');++ currentReplies.replaceWith(loadedReplies);++ history.pushState(null, '', more.href);++ } catch (error) { more.textContent = error.message; more.removeAttribute('aria-busy'); }++ return;++ }++ var order = event.target.closest('[data-ovc-order]');++ if (order && thread.contains(order)) {++ event.preventDefault();++ order.setAttribute('aria-busy', 'true');++ try {++ var page = await fetch(order.href, { credentials: 'same-origin' });++ if (!page.ok || !replace(read(await page.text()), order.href)) throw new Error('Comments could not be refreshed.');++ } catch (error) { order.textContent = error.message; order.removeAttribute('aria-busy'); }++ }++ });++ document.addEventListener('submit', async function (event) {++ var form = event.target;++ if (!thread.contains(form) || (!form.matches('[data-ovt-composer]') && !form.closest('.ovc-actions'))) return;++ var files = form.querySelector('[data-ovt-files]');++ var note = form.querySelector('[data-ovt-note]');++ if (files && files.files.length) {++ var problem = files.files.length > 4 ? 'Attach up to four files.' : '';++ for (var file of files.files) {++ if (file.size > 16 * 1024 * 1024) problem = 'A file can be up to 16 MB.';++ if (!allowed.has(file.type)) problem = 'Attach JPEG, PNG, GIF, WebP, PDF or text files.';++ }++ if (problem) { event.preventDefault(); if (note) note.textContent = problem; return; }++ }++ event.preventDefault();++ var button = form.querySelector('[type="submit"]');++ if (button) button.disabled = true;++ if (note) note.textContent = 'Sending…';++ try {++ var body = form.matches('[data-ovt-composer]') ? new FormData(form) : new URLSearchParams(new FormData(form));++ var response = await fetch(form.action, { method: 'POST', body: body, credentials: 'same-origin' });++ var html = await response.text();++ var next = read(html);++ if (!response.ok || !replace(next, response.url)) throw new Error('The comment could not be saved. Try again.');++ } catch (error) { if (note) note.textContent = error.message; if (button) button.disabled = false; }++ });++})();nnew file mode 100644@@@ -0,0 +1,27 @@++'use strict';++++const express = require('express');++const { ID } = require('./inline');++++const ACCEPT = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);++const MAX_FILE = 16 * 1024 * 1024;++const multipart = express.raw({ type: 'multipart/form-data', limit: '65mb' });++++async function readCommentForm(req) {++ if (!Buffer.isBuffer(req.body)) throw new Error('The comment form was not received');++ const request = new Request('http://localhost/comment', { method: 'POST', headers: { 'content-type': req.get('content-type') }, body: req.body });++ const form = await request.formData();++ const message = String(form.get('message') || '').trim();++ if (!message || message.length > 2000) throw new Error('A comment is 1 to 2,000 characters');++ const parent = String(form.get('parent_id') || '');++ if (parent && !ID.test(parent)) throw new Error('That reply target is not valid');++ const files = form.getAll('files').filter((f) => f && typeof f.arrayBuffer === 'function' && f.size);++ if (files.length > 4) throw new Error('Attach up to four files');++ for (const file of files) {++ if (file.size > MAX_FILE) throw new Error('A file can be up to 16 MB');++ if (!ACCEPT.has(file.type)) throw new Error('Attach JPEG, PNG, GIF, WebP, PDF or text files');++ }++ return { message, parent_id: parent || null, files };++}++++module.exports = { multipart, readCommentForm };nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++++/** Plain, server-rendered Community thread for the Video and Pics item pages. */++const kit = require('openvibe-shared/kit');++const thread = require('openvibe-shared/thread');++const { esc } = require('../render/html');++++const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++const USERNAME = /^[A-Za-z0-9_]{1,32}$/;++const ID = /^\d{1,15}$/;++const safeHttps = (value) => { try { const url = new URL(String(value)); return url.protocol === 'https:' ? url.href : null; } catch { return null; } };++const author = (c) => c.author || c;++const username = (c) => author(c).username || c.username || '';++const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';++const date = (iso) => {++ const ms = Date.parse(String(iso || ''));++ if (!Number.isFinite(ms)) return { short: '', full: '' };++ const full = new Date(ms).toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }) + ' UTC';++ const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));++ const unit = seconds < 60 ? ['second', seconds] : seconds < 3600 ? ['minute', Math.floor(seconds / 60)]++ : seconds < 86400 ? ['hour', Math.floor(seconds / 3600)] : seconds < 2592000 ? ['day', Math.floor(seconds / 86400)]++ : seconds < 31536000 ? ['month', Math.floor(seconds / 2592000)] : ['year', Math.floor(seconds / 31536000)];++ return { short: seconds < 60 ? 'just now' : `${unit[1]} ${unit[0]}${unit[1] === 1 ? '' : 's'} ago`, full };++};++++function query(base, sort, extra = {}, anchor = 'comments') {++ const q = new URLSearchParams({ sort, ...extra });++ return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}#${anchor}`;++}++++function renderThread({ answer, base, post, remove, signedIn, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null }) {++ const comments = Array.isArray(answer && answer.comments) ? answer.comments : [];++ const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || '');++ const threadUrl = safeHttps(answer && answer.thread && answer.thread.url) || (/^cth_[A-Za-z0-9_-]{22}$/.test(accessId) ? `https://openvibe.community/c/${accessId}` : null);++ const validReply = ID.test(String(replyId || '')) ? String(replyId) : null;++ const validExpanded = ID.test(String(expandedId || '')) ? String(expandedId) : null;++ const signIn = `<p class="ovc-signin">Sign in to comment. <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`;++ const composer = (parent = null) => signedIn ? thread.composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', maxlength: 2000,++ sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {},++ attach: { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' },++ note: 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.',++ }) : signIn;++ const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {++ if (!MEDIA_ID.test(String(a.id || ''))) return '';++ const url = `https://openvibe.media/o/${a.id}`;++ const title = esc(a.name || 'Attached file');++ return `<a class="ovc-attach${String(a.media_type || '').startsWith('image/') ? ' ovc-attach--image' : ' ovc-attach--file'}" href="${url}" target="_blank" rel="noopener noreferrer">${String(a.media_type || '').startsWith('image/')++ ? `<img src="${url}" alt="${title}" loading="lazy" width="120">` : title}</a>`;++ }).join('');++ const comment = (c, root = null) => {++ const id = String(c.id);++ if (!ID.test(id)) return '';++ const gone = !!(c.deleted || c.is_deleted);++ const user = username(c);++ const profile = USERNAME.test(user) ? `https://openvibe.network/@${encodeURIComponent(user)}` : null;++ const avatar = safeHttps(author(c).avatar_url || c.avatar_url) || (profile ? `https://openvibe.network/avatar/${encodeURIComponent(user)}?s=80` : null);++ const when = date(c.created_at);++ const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null;++ const topId = root || id;++ const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}">↪ @${esc(c.reply_to.display_name || 'Member')}</a>` : '';++ const controls = !gone ? `<div class="ovc-actions">${signedIn ? `<a class="ovk-btn ovk-btn--quiet" href="${query(base, sort, { reply: id }, `comment-${id}`)}" data-ovc-reply="${id}">Reply</a>` : ''}${c.can_delete && signedIn ? `<form method="post" action="${esc(remove)}"><input type="hidden" name="comment_id" value="${id}"><button class="ovk-btn ovk-btn--quiet" type="submit">Delete</button></form>` : ''}</div>` : '';++ let replies = '';++ if (!root) {++ const rows = validExpanded === id && Array.isArray(expandedReplies) ? expandedReplies : (Array.isArray(c.replies) ? c.replies : []);++ const more = Math.max(0, Number(c.reply_count || 0) - rows.length);++ replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;++ }++ return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${esc(name(c))}</a>` : `<span>${esc(name(c))}</span>`}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer(id)}</div>` : ''}${replies}</article>`;++ };++ const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));++ const order = `<nav class="ovc-order" aria-label="Comment order">${[['top', 'Top'], ['new', 'Newest']].map(([key, label]) => `<a class="ovk-btn ovk-btn--quiet${sort === key ? ' is-active' : ''}" href="${query(base, key)}"${sort === key ? ' aria-current="page"' : ''} data-ovc-order>${label}</a>`).join('')}</nav>`;++ const next = answer && answer.next_cursor ? `<a class="ovk-btn ovk-btn--quiet ovc-more" href="${query(base, sort, { after: answer.next_cursor })}" data-ovc-order>More comments</a>` : '';++ return `<section class="ovc-thread" id="comments" data-ovc-thread><div class="ovc-head"><h2>Comments</h2>${order}</div>${error ? `<p role="alert">${esc(error)}</p>` : ''}${!hasReplySlot ? `<div data-ovc-composer-slot>${composer()}</div>` : ''}${comments.length ? `<div class="ovc-list">${comments.map((c) => comment(c)).join('')}</div>` : kit.empty({ title: 'Be the first to comment' })}${next}${threadUrl ? `<p class="ovc-community"><a href="${esc(threadUrl)}">Discuss on OpenVibe.Community</a></p>` : ''}</section>`;++}++++module.exports = { renderThread, MEDIA_ID, ID };@@@ -161,7 +161,7 @@ function createPicsApi(ctx) {if (!row) return;return commentAnswer(res, await comments.read(row.id, req.viewer.token, req.query));});-- r.post('/images/:slug/comments', person, async (req, res) => {++ r.post('/images/:slug/comments', person, limits.budget('pics.image.write'), async (req, res) => {const row = await load(req, res);if (!row) return;const b = req.body || {};@@@ -171,13 +171,19 @@ function createPicsApi(ctx) {{ message: b.message, parent_id: b.parent_id || null, attachments: b.attachments || [] }));});const commentFile = express.raw({ type: 'application/octet-stream', limit: '16mb' });-- r.post('/images/:slug/comment-attachments', person, commentFile, async (req, res) => {++ r.post('/images/:slug/comment-attachments', person, limits.budget('pics.image.upload'), commentFile, async (req, res) => {const row = await load(req, res);if (!row) return;if (!Buffer.isBuffer(req.body) || !req.body.length) return problem(req, res, 400, 'pics.comment.file', 'Choose a file');return commentAnswer(res, await comments.upload(row.id, req.viewer.token, req.body,String(req.get('x-file-name') || 'file').slice(0, 255), String(req.get('x-file-mime') || 'application/octet-stream').slice(0, 100)));});++ r.delete('/images/:slug/comments/:commentId', person, limits.budget('pics.image.write'), async (req, res) => {++ const row = await load(req, res);++ if (!row) return;++ if (!/^\d{1,15}$/.test(req.params.commentId)) return problem(req, res, 400, 'pics.comment.id', 'Invalid comment');++ return commentAnswer(res, await comments.remove(req.params.commentId, req.viewer.token));++ });r.get('/images/:slug/bytes', person, async (req, res) => {const row = await load(req, res);@@@ -27,8 +27,8 @@ function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {const found = await thread(id, token);if (!found.id) return found;const qs = new URLSearchParams();-- if (query.sort === 'new') qs.set('sort', 'new');-- if (query.after && /^\d{1,15}$/.test(String(query.after))) qs.set('after', String(query.after));++ if (query.sort === 'new' || query.sort === 'top') qs.set('sort', query.sort);++ if (query.after && /^(?:-?\d{1,12}:)?\d{1,15}$/.test(String(query.after))) qs.set('after', String(query.after));if (query.parent && /^\d{1,15}$/.test(String(query.parent))) qs.set('parent', String(query.parent));if (query.limit) qs.set('limit', String(Math.min(Math.max(Number(query.limit) || 30, 1), 100)));return await call('GET', `/threads/${encodeURIComponent(found.id)}${qs.size ? `?${qs}` : ''}`, { token });@@@ -44,7 +44,22 @@ function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {form.set('file', new Blob([buffer], { type: mime || 'application/octet-stream' }), name || 'file');return await call('POST', '/attachments', { token, body: form });}-- return { read, add, upload };++ async function remove(commentId, token) {++ return await call('DELETE', `/${encodeURIComponent(commentId)}`, { token });++ }++ async function replies(id, token, parent) {++ const all = [];++ let after = null;++ while (true) {++ const result = await read(id, token, { parent, after, limit: 100 });++ if (result.status !== 200) return result;++ all.push(...result.body.comments);++ if (!result.body.next_cursor) return { status: 200, body: { replies: all } };++ if (result.body.next_cursor === after) return { status: 503, body: { error: 'Replies could not be loaded' } };++ after = result.body.next_cursor;++ }++ }++ return { read, add, upload, remove, replies };}module.exports = { createCommentsClient };@@@ -38,6 +38,10 @@ const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');const { send, assetVersion, SITE_NAME } = require('./layout');const store = require('./store');const { createPicsApi } = require('./api');++const { createCommentsClient } = require('./comments-client');++const { createCommentsClient: createServiceCommentsClient } = require('../video/comments-client');++const { renderThread } = require('../comments/inline');++const { multipart: commentMultipart, readCommentForm } = require('../comments/form');const { createPicVariants } = require('./variants');const kit = require('openvibe-shared/kit');const gallery = require('./gallery');@@@ -56,6 +60,9 @@ const KIND = { jpg: 'JPEG', png: 'PNG', gif: 'GIF', webp: 'WebP', avif: 'AVIF' }function createPicsRoutes(ctx) {const { config, s, media, log } = ctx;const pics = ctx.pics;++ const comments = createCommentsClient({ fetchImpl: ctx.fetchImpl || globalThis.fetch });++ const commentModeration = createServiceCommentsClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch,++ refOf: (row) => ({ service: 'pics', type: 'picture', id: String(row.id) }) });const sharing = ctx.sharing;const cfg = config.pics;const base = cfg.url;@@@ -391,6 +398,27 @@ ${raw(showcase.network({const title = row.title || 'Untitled picture';const hidden = row.state !== 'active';const commentsAvailable = !hidden && !!(sharing && await sharing.canView({ kind: 'anonymous' }, 'picture', row.id).catch(() => false));++ const sort = req.query.sort === 'new' ? 'new' : 'top';++ let commentsHtml = '';++ if (commentsAvailable) {++ const result = await comments.read(row.id, req.viewer && req.viewer.token, { sort, after: req.query.after });++ if (result.status === 200) {++ const answer = result.body;++ if (owner || (signedIn(req) && pics.isStaff(req.viewer))) {++ for (const c of answer.comments) {++ c.can_delete = true;++ for (const reply of c.replies || []) reply.can_delete = true;++ }++ }++ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;++ const belongs = expandedId && answer.comments.some((c) => String(c.id) === expandedId);++ const more = belongs ? await comments.replies(row.id, req.viewer && req.viewer.token, expandedId) : null;++ commentsHtml = renderThread({ answer, base: `/i/${row.slug}`, post: `/i/${row.slug}/comments?sort=${sort}`,++ remove: `/i/${row.slug}/comments/delete?sort=${sort}`, signedIn: signedIn(req) && answer.viewer.can_comment !== false,++ sort, replyId: req.query.reply, expandedId: belongs ? expandedId : null,++ expandedReplies: more && more.status === 200 ? more.body.replies : null });++ } else commentsHtml = '<section class="ovc-thread" id="comments"><h2>Comments</h2><p>Comments are unavailable right now.</p></section>';++ }// ?like=<slug> after an upload: a near-duplicate of one of the person's own pictures (VIS-6). Only a slug of theirs is linked.const likeRow = owner && /^[A-Za-z0-9]{4,16}$/.test(String(req.query.like || '')) ? await store.imageBySlug(s, String(req.query.like)) : null;const likeSlug = likeRow && likeRow.id !== row.id && likeRow.owner === row.owner ? likeRow.slug : null;@@@ -399,8 +427,8 @@ ${raw(showcase.network({jsonLd: gallery.isPublic(row) ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, contentUrl: L.direct, url: L.page, caption: row.description || title, author: { '@type': 'Person', name: row.author_name || 'OpenVibe member' } } : undefined,description: row.description || `${title}: a picture on ${SITE_NAME}.`,image: hidden ? undefined : L.direct, ogType: 'article',-- scripts: ['js/pics.js', 'js/pics-gallery.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],-- ...(owner ? { localStyles: ['css/labels.css'] } : {}),++ scripts: ['js/pics.js', 'js/pics-gallery.js', 'js/comments.js'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js'],++ localStyles: ['css/comments.css', ...(owner ? ['css/labels.css'] : [])],private: deleteKey ? true : undefined,body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}@@@ -423,7 +451,7 @@ ${!hidden ? html`<nav class="ovp-pic-make" aria-label="Make something with this${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}</article>--${commentsAvailable ? html`<section class="ovp-section ovp-comments" aria-labelledby="h-comments"><div class="ovp-comments-head"><h2 id="h-comments">Comments</h2><button type="button" class="ovk-btn ovk-btn--quiet" data-comments="${row.slug}">${glyph('chat', 17)}<span>Show the discussion</span></button></div><div class="ovp-comments-list" data-comments-list aria-live="polite"></div></section>` : ''}++${raw(commentsHtml)}<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2><p class="ovp-share-lede">These links follow the audience you choose in Share. Private pictures are visible only to you. The direct link is the picture itself, at full size — no page around it.</p><div class="ovp-link-grid">@@@ -469,6 +497,43 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name}, status);}++ const commentFailure = (req, res, slug, detail, status = 422) => page(req, res, {++ title: 'Comment not sent', cache: cache.htmlHeaders({ private: true }),++ body: html`<h1>Comment not sent</h1><p>${detail}</p><p><a href="/i/${slug}#comments">Go back</a></p>`,++ }, status);++ r.post('/i/:slug/comments', mustSignIn('Sign in to comment.'), budgeted('pics.image.write'), commentMultipart, async (req, res) => {++ const { row, status } = await picture(req, res);++ if (!row || status !== 200) return notFound(req, res);++ if (!sameSite(req, res, `/i/${row.slug}`)) return undefined;++ if (!await sharing.canView({ kind: 'anonymous' }, 'picture', row.id)) return notFound(req, res);++ let form;++ try { form = await readCommentForm(req); }++ catch (err) { return commentFailure(req, res, row.slug, err.message); }++ const attachments = [];++ for (const file of form.files) {++ const uploaded = await comments.upload(row.id, req.viewer.token, Buffer.from(await file.arrayBuffer()), file.name, file.type);++ if (uploaded.status !== 201 || !uploaded.body.attachment) return commentFailure(req, res, row.slug, uploaded.body.detail || uploaded.body.error || 'The attachment could not be saved', uploaded.status);++ attachments.push(uploaded.body.attachment);++ }++ const result = await comments.add(row.id, req.viewer.token, { message: form.message, parent_id: form.parent_id, attachments });++ if (result.status !== 201) return commentFailure(req, res, row.slug, result.body.detail || result.body.error || 'The comment could not be saved', result.status);++ return res.redirect(303, `/i/${row.slug}?sort=${req.query.sort === 'new' ? 'new' : 'top'}#comments`);++ });++ r.post('/i/:slug/comments/delete', mustSignIn('Sign in to remove a comment.'), budgeted('pics.image.write'), async (req, res) => {++ const { row, status } = await picture(req, res);++ if (!row || status !== 200) return notFound(req, res);++ if (!sameSite(req, res, `/i/${row.slug}`)) return undefined;++ const id = String(req.body.comment_id || '');++ if (!/^\d{1,15}$/.test(id)) return commentFailure(req, res, row.slug, 'Invalid comment');++ let result;++ if (pics.isOwner(row, whoOf(req)) || (signedIn(req) && pics.isStaff(req.viewer))) {++ try { await commentModeration.remove(row, req.viewer.subject, true, id, req.ip); result = { status: 200 }; }++ catch (err) { result = { status: err.status || 503, body: { detail: err.message } }; }++ } else result = await comments.remove(id, req.viewer.token);++ if (result.status !== 200) return commentFailure(req, res, row.slug, result.body.detail || result.body.error || 'The comment could not be removed', result.status);++ return res.redirect(303, `/i/${row.slug}?sort=${req.query.sort === 'new' ? 'new' : 'top'}#comments`);++ });++r.get('/i/:slug', async (req, res) => {const { row, status, denied } = await picture(req, res);if (denied && row) {nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++++/** Community's thread for a MediaHub upload, addressed through this item's visibility checks. */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++class CommentError extends Error {++ constructor(status, message) { super(message); this.status = status; }++}++++function createCommentsClient({ config, fetchImpl = globalThis.fetch, refOf = (row) => ({ service: 'media-hub', type: 'video', id: String(row.id), label: String(row.title || 'Video').slice(0, 200) }) } = {}) {++ const origin = (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');++ let tokens = null;++ const ref = refOf;++ async function call(method, path, { subject = null, staff = false, body = null, ip = null } = {}) {++ let token;++ try {++ if (!tokens) tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret, audience: 'openvibe.community', fetch: fetchImpl });++ token = await tokens.getToken();++ } catch { throw new CommentError(503, 'Comments are unavailable right now'); }++ const form = body instanceof FormData;++ let res;++ try {++ res = await fetchImpl(`${origin}/api/v1/comments${path}`, { method, redirect: 'error', signal: AbortSignal.timeout(form ? 120000 : 5000),++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(subject ? { 'X-OV-Subject': subject } : {}),++ ...(staff ? { 'X-OV-Staff': '1' } : {}), ...(ip ? { 'X-Forwarded-For': ip } : {}),++ ...(body && !form ? { 'Content-Type': 'application/json' } : {}) },++ body: form ? body : body ? JSON.stringify(body) : undefined });++ } catch { throw new CommentError(503, 'Comments are unavailable right now'); }++ const data = await res.json().catch(() => null);++ if (!res.ok) throw new CommentError(res.status, data && (data.detail || data.error) || 'Comments are unavailable right now');++ return data;++ }++ async function thread(row, ip) {++ const data = await call('POST', '/threads/resolve', { body: { ref: ref(row) }, ip });++ const id = data && data.thread && data.thread.access_id;++ if (!/^cth_[A-Za-z0-9_-]{22}$/.test(String(id || ''))) throw new CommentError(503, 'Comments are unavailable right now');++ return id;++ }++ async function read(row, { subject = null, staff = false, sort = 'top', after = null, parent = null, limit = 30, ip = null } = {}) {++ const id = await thread(row, ip);++ const q = new URLSearchParams({ sort, limit: String(limit) });++ if (after) q.set('after', after);++ if (parent) q.set('parent', parent);++ const answer = await call('GET', `/threads/${id}?${q}`, { subject, staff, ip });++ if (answer.thread && answer.thread.visibility === 'hidden' && !staff) throw new CommentError(404, 'Comments are hidden here');++ return answer;++ }++ async function upload(row, subject, file, ip) {++ const form = new FormData();++ form.set('ref', JSON.stringify(ref(row)));++ form.set('file', file, file.name || 'file');++ return await call('POST', '/attachments', { subject, body: form, ip });++ }++ async function add(row, subject, body, ip) {++ const id = await thread(row, ip);++ return await call('POST', `/threads/${id}/comments`, { subject, body, ip });++ }++ async function remove(row, subject, staff, commentId, ip) {++ const found = await call('GET', `/${encodeURIComponent(commentId)}`, { subject, staff, ip });++ const linked = found && found.thread && found.thread.ref;++ const expected = ref(row);++ if (!linked || linked.service !== expected.service || linked.type !== expected.type || linked.id !== expected.id) throw new CommentError(404, 'Comment not found');++ if (!staff && (!found.comment.author || found.comment.author.subject !== subject)) throw new CommentError(403, 'Only the author can remove this comment');++ return await call('DELETE', `/${encodeURIComponent(commentId)}`, { subject, staff, ip });++ }++ return { read, upload, add, remove, CommentError };++}++++module.exports = { createCommentsClient, CommentError };@@@ -85,19 +85,19 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console}const refreshing = new Map(); // url → the load that refreshes it-- async function getJson(path) {++ async function getJson(path, { fresh = false } = {}) {const url = `${config.video.liveUrl}${path}`;-- const cached = hit(url);++ const cached = fresh ? undefined : hit(url);if (cached !== undefined) return cached;const entry = cache.get(url);-- if (entry && Date.now() - entry.at < SERVE_STALE_MS) {++ if (!fresh && entry && Date.now() - entry.at < SERVE_STALE_MS) {if (!refreshing.has(url)) refreshing.set(url, load(url).catch(() => {}).finally(() => refreshing.delete(url)));return entry.value;}-- return load(url);++ return load(url, fresh);}-- async function load(url) {++ async function load(url, fresh = false) {let res;try {res = await fetchImpl(url, {@@@ -108,19 +108,24 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = consolesignal: AbortSignal.timeout(TIMEOUT_MS),});} catch (err) {++ if (fresh) throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');return unavailable(url, err && err.name);}if (res.status === 404) return null;-- if (!res.ok) return unavailable(url, `HTTP ${res.status}`);++ if (!res.ok) {++ if (fresh) throw new LiveUnavailableError(`OpenVibe.Live answered ${res.status}; try again in a minute.`);++ return unavailable(url, `HTTP ${res.status}`);++ }let body;try {body = await res.json();} catch {++ if (fresh) throw new LiveUnavailableError('OpenVibe.Live did not answer with JSON.');return unavailable(url, 'not JSON');}-- return remember(url, body);++ return fresh ? body : remember(url, body);}/**@@@ -256,8 +261,30 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = consoleclip(id) {return getJson(`/api/clips/${id}`);},-- comments(kind, id) {-- return getJson(`/api/comments/${kind}/${id}`);++ comments(kind, id, { sort = 'top', after = null } = {}) {++ const q = new URLSearchParams({ inline: '1', sort });++ if (after) q.set('after', after);++ return getJson(`/api/comments/${kind}/${id}?${q}`, { fresh: true });++ },++ commentsAs(token, kind, id, opts = {}) {++ const q = new URLSearchParams({ inline: '1', sort: opts.sort || 'top' });++ if (opts.after) q.set('after', opts.after);++ return asPerson(token, 'GET', `/api/comments/${kind}/${id}?${q}`);++ },++ commentReplies(token, commentId) {++ const path = `/api/comments/${commentId}/replies`;++ return token ? asPerson(token, 'GET', path) : getJson(path, { fresh: true });++ },++ postComment(token, kind, id, body, viewerIp) {++ return asPerson(token, 'POST', `/api/comments/${kind}/${id}`, body, { viewerIp });++ },++ uploadCommentFile(token, kind, id, file, viewerIp) {++ const form = new FormData();++ form.set('file', file, file.name || 'file');++ return asPerson(token, 'POST', `/api/comments/${kind}/${id}/attachments`, form, { viewerIp });++ },++ deleteComment(token, commentId, viewerIp) {++ return asPerson(token, 'DELETE', `/api/comments/${commentId}`, null, { viewerIp });},commentsPage(kind, id, query = {}) {const qs = new URLSearchParams();@@@ -22,6 +22,7 @@* the sitemap. A private item is missing (Live already answers 404) and becomes a 404 here.*/const express = require('express');++const rateLimit = require('express-rate-limit');const path = require('path');const cache = require('openvibe-shared/cache-policy');const securityTxt = require('../http/security-txt');@@@ -46,6 +47,8 @@ const { createUploadApi, mountUploadPages } = require('./upload-routes');const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');const { mountDownloads, downloadMenu } = require('./downloads');const { actionMenu, deleteAction, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');++const { renderThread } = require('../comments/inline');++const { multipart: commentMultipart, readCommentForm } = require('../comments/form');const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');const HOME_LIMIT = 10;@@@ -665,6 +668,9 @@ function createVideoRoutes(ctx) {const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });const shelves = createSearchReads({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });const r = asyncRouter();++ const commentSort = (req) => req.query.sort === 'new' ? 'new' : 'top';++ const commentAfter = (req) => typeof req.query.after === 'string' && /^(?:-?\d{1,12}:)?\d{1,15}$/.test(req.query.after) ? req.query.after : null;++ const commentLimit = rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false });// The menu's network card (frame.menu-card@1): the week's most viewed public recordings and clips. Public,// CORS *, cached two minutes; the rows are Live's Content feed (which reads Media) and never a private item.r.get('/.well-known/openvibe-menu.json', createMenuCardHandler({ feed: (opts) => live.feed(opts) }));@@@ -734,6 +740,26 @@ function createVideoRoutes(ctx) {return sameName(me, item.username) || sameName(me, item.source_streamer_username) || sameName(me, item.channel_username);}const asPerson = (req, res, fn) => ctx.sso.asViewer(req, res, fn);++ async function liveThread(req, res, kind, id) {++ const opts = { sort: commentSort(req), after: commentAfter(req) };++ return signedIn(req) ? asPerson(req, res, (token) => live.commentsAs(token, kind, id, opts)) : live.comments(kind, id, opts);++ }++ async function liveThreadHtml(req, res, kind, id, basePath, answer) {++ if (!answer) return raw('<section class="ovc-thread" id="comments"><h2>Comments</h2><p>Comments are unavailable right now.</p></section>');++ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;++ const belongs = expandedId && answer.comments && answer.comments.some((c) => String(c.id) === expandedId);++ let expandedReplies = null;++ if (belongs) {++ try {++ const out = signedIn(req) ? await asPerson(req, res, (token) => live.commentReplies(token, expandedId)) : await live.commentReplies(null, expandedId);++ expandedReplies = out && out.replies;++ } catch { /* the first two remain visible */ }++ }++ const endpoint = `/${kind === 'clip' ? 'c' : 'v'}/${id}/comments`;++ return raw(renderThread({ answer, base: basePath, post: `${endpoint}?sort=${commentSort(req)}`, remove: `${endpoint}/delete?sort=${commentSort(req)}`,++ signedIn: signedIn(req) && (!answer.viewer || answer.viewer.can_comment !== false), sort: commentSort(req),++ replyId: req.query.reply, expandedId: belongs ? expandedId : null, expandedReplies }));++ }/** A signed-in person's own view of an item Live hides from everyone else (private), or null. */async function ownView(req, res, kind, id) {if (!signedIn(req)) return null;@@@ -974,6 +1000,39 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON// parser, so the two never read each other's bodies.r.use(express.urlencoded({ extended: false, limit: '64kb' }));++ r.post('/:kind(v|c)/:id/comments', mustSignIn('here', 'Sign in to comment.'), commentLimit, commentMultipart, async (req, res) => {++ const kind = req.params.kind === 'c' ? 'clip' : 'vod';++ const id = String(req.params.id || '');++ const back = `/watch?v=${encodeURIComponent(String(req.query.v || ''))}`;++ if (!live.isId(id)) return notFound(req, res);++ if (!sameSite(req, res, back)) return undefined;++ let form;++ try { form = await readCommentForm(req); }++ catch (err) { return page(req, res, { title: 'Comment not sent', body: html`<h1>Comment not sent</h1><p>${err.message}</p><p><a href="/${req.params.kind}/${id}#comments">Go back</a></p>` }, 422); }++ try {++ await asPerson(req, res, async (token) => {++ const attachments = [];++ for (const file of form.files) {++ const out = await live.uploadCommentFile(token, kind, id, file, req.ip);++ if (!out || !out.attachment) throw new Error('The file could not be attached');++ attachments.push(out.attachment);++ }++ return live.postComment(token, kind, id, { message: form.message, parent_id: form.parent_id, attachments }, req.ip);++ });++ return res.redirect(303, `/${req.params.kind}/${id}?sort=${commentSort(req)}#comments`);++ } catch (err) { return ownerRefused(req, res, err, `/${req.params.kind}/${id}#comments`); }++ });++ r.post('/:kind(v|c)/:id/comments/delete', mustSignIn('here', 'Sign in to remove a comment.'), commentLimit, async (req, res) => {++ const id = String(req.params.id || '');++ if (!live.isId(id)) return notFound(req, res);++ if (!sameSite(req, res, `/${req.params.kind}/${id}`)) return undefined;++ const commentId = String(req.body.comment_id || '');++ if (!/^\d{1,15}$/.test(commentId)) return notFound(req, res);++ try {++ await asPerson(req, res, (token) => live.deleteComment(token, commentId, req.ip));++ return res.redirect(303, `/${req.params.kind}/${id}?sort=${commentSort(req)}#comments`);++ } catch (err) { return ownerRefused(req, res, err, `/${req.params.kind}/${id}#comments`); }++ });r.post('/watch/like', async (req, res) => {if (!signedIn(req)) return res.status(401).json({ error: 'Sign in to like this video.' });if (!sameSite(req, res, '/')) return undefined;@@@ -1454,7 +1513,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvconst [m, cm, replay, saveForm, up, hist, more, agent] = await Promise.all([// A private recording's moments are Live's answer to its owner only.Promise.resolve().then(() => (ownOnly ? asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : live.vodMemories(id))).catch(() => fail),-- Promise.resolve().then(() => live.comments('vod', id)).catch(() => fail),++ Promise.resolve().then(() => liveThread(req, res, 'vod', id)).catch(() => fail),Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),playlistForm(req, 'vod', id),upNext(req, 'vod', id),@@@ -1470,16 +1529,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvif (agentSteps) mems = agentSteps.filter((s) => s.status === 'started' && Number.isFinite(Number(s.offset_seconds))).map((s) => ({ offset_seconds: Number(s.offset_seconds), description: String(s.title || '').slice(0, 120) }));let threadUrl = null;-- let commentRows = [];-- let commentsOk = false;-- if (cm !== fail) {-- try {-- threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);-- const list = Array.isArray(cm && cm.comments) ? cm.comments : [];-- commentRows = list.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20);-- commentsOk = true;-- } catch { threadUrl = null; }-- }++ if (cm !== fail) threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);++ const commentsHtml = await liveThreadHtml(req, res, 'vod', id, watchPath, cm === fail ? null : cm);let chatRows = null;if (replay !== fail) {@@@ -1519,15 +1570,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovvnoindex: unlisted || ownOnly || thin,...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),-- styles: ['kit.css'],++ styles: ['kit.css', 'thread.css'],description: about,canonical: `${base}${watchPath}`,...(ownOnly || vod.is_recording ? {} : {jsonLd: videoLd({ base, path: watchPath, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',}),-- scripts: ['js/video.js'],-- ...(manageable ? { localStyles: ['css/labels.css'] } : {}),++ scripts: ['js/video.js', 'js/comments.js'],++ localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],player: true,body: html`<div class="ovv-watch ovv-watch--vod"><div class="ovv-col ovv-col--main">@@@ -1546,7 +1597,7 @@ ${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-so</div>${manageable ? ownerPanel('vod', id, vod) : ''}${watchAbout({ facts, description: vod.description || about, ai: vod.ai_overview, parts: partNav, chapters: mems, path: watchPath, tags: Array.isArray(vod.tags) ? vod.tags : [], from: channelName ? html`<p class="ovv-about-from">Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : '' })}--${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}++<section class="ovv-panel ovv-comments">${commentsHtml}</section></div><aside class="ovv-col ovv-col--side">${side || ''}@@@ -1593,7 +1644,7 @@ ${more || ''}? live.vod(String(clip.vod_id)).then((s) => (s && s.vod) || null).catch(() => 'down'): Promise.resolve('none');const [cm, saveForm, up, sourceVod, more] = await Promise.all([-- Promise.resolve().then(() => live.comments('clip', id)).catch(() => null),++ Promise.resolve().then(() => liveThread(req, res, 'clip', id)).catch(() => null),playlistForm(req, 'clip', id),upNext(req, 'clip', id),sourceAnswer,@@@ -1601,7 +1652,7 @@ ${more || ''}]);let threadUrl = null;try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }-- const commentRows = cm && Array.isArray(cm.comments) ? cm.comments.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20) : null;++ const commentsHtml = await liveThreadHtml(req, res, 'clip', id, watchPath, cm);const unlisted = clip.visibility === 'unlisted';// AI clips stay out of search, as on Live: noindex and no structured data.@@@ -1644,15 +1695,15 @@ ${more || ''}noindex: unlisted || ownOnly || aiClip,...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),-- styles: ['kit.css'],++ styles: ['kit.css', 'thread.css'],description: clip.ai_overview || factualDescription('clip', clip),canonical: `${base}${watchPath}`,...(ownOnly || aiClip ? {} : {jsonLd: videoLd({ base, path: watchPath, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: sourceVod && sourceVod !== 'down' && sourceVod.watch_id ? `/watch?v=${sourceVod.watch_id}` : (clip.vod_id ? `/v/${clip.vod_id}` : null) }),image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',}),-- scripts: ['js/video.js'],-- ...(manageable ? { localStyles: ['css/labels.css'] } : {}),++ scripts: ['js/video.js', 'js/comments.js'],++ localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],player: true,body: html`<div class="ovv-watch ovv-watch--clip"><div class="ovv-col ovv-col--main">@@@ -1671,7 +1722,7 @@ ${channelRow(clip.source_streamer_username || clip.username, clip.source_streame</div>${manageable ? ownerPanel('clip', id, clip) : ''}${watchAbout({ facts, description: clip.description || clip.ai_overview || attribution, source: clipCredit, madeBy, ai: clip.ai_overview, tags: Array.isArray(clip.tags) ? clip.tags : [] })}--${commentRows ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}++<section class="ovv-panel ovv-comments">${commentsHtml}</section></div><aside class="ovv-col ovv-col--side">${side || ''}@@@ -20,11 +20,15 @@* Origin). Reports are open to anyone who can see the video, one per person (or per keyed address) per video.*/const express = require('express');++const rateLimit = require('express-rate-limit');const cache = require('openvibe-shared/cache-policy');const { sameOrigin } = require('../http/principal');const { html, raw, notice, askAgent } = require('../render/html');const { UploadError } = require('./uploads');const { ShareError } = require('openvibe-sdk/share');++const { createCommentsClient, CommentError } = require('./comments-client');++const { renderThread } = require('../comments/inline');++const { multipart: commentMultipart, readCommentForm } = require('../comments/form');const REASON_TEXT = {child_safety: 'It sexualises or endangers a child', violence: 'Violence or a threat', harassment: 'Harassment or someone\'s private details',@@@ -164,6 +168,10 @@ function createUploadApi({ ctx, config }) {function mountUploadPages(r, h) {const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton, renderVod, renderClip } = h;const up = ctx.videoUploads;++ const comments = createCommentsClient({ config: ctx.config, fetchImpl: ctx.fetchImpl || globalThis.fetch });++ const commentLimit = rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false });++ const commentSort = (req) => req.query.sort === 'new' ? 'new' : 'top';++ const commentAfter = (req) => typeof req.query.after === 'string' && /^(?:-?\d{1,12}:)?\d{1,15}$/.test(req.query.after) ? req.query.after : null;const cfg = ctx.config.videoUploads;const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });const GB = 1024 ** 3;@@@ -359,6 +367,34 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : htreturn row;}++ async function uploadThread(req, row, watchPath) {++ if (row.state !== 'active' || row.status !== 'ready') return '';++ const subject = signedIn(req) ? req.viewer.subject : null;++ const staff = signedIn(req) && (isStaff(req) || row.owner === ownerOf(req));++ try {++ const answer = await comments.read(row, { subject, staff, sort: commentSort(req), after: commentAfter(req), ip: req.ip });++ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;++ const belongs = expandedId && answer.comments.some((c) => String(c.id) === expandedId);++ let expandedReplies = null;++ if (belongs) {++ expandedReplies = [];++ let cursor = null;++ do {++ const more = await comments.read(row, { subject, staff, parent: expandedId, after: cursor, limit: 100, ip: req.ip });++ expandedReplies.push(...more.comments);++ if (more.next_cursor === cursor) break;++ cursor = more.next_cursor;++ } while (cursor);++ }++ const shown = row.visibility === 'private' ? { ...answer, thread: {} } : answer;++ return raw(renderThread({ answer: shown, base: watchPath, post: `/u/${row.id}/comments?sort=${commentSort(req)}`,++ remove: `/u/${row.id}/comments/delete?sort=${commentSort(req)}`, signedIn: Boolean(subject) && answer.viewer.can_comment !== false,++ sort: commentSort(req), replyId: req.query.reply, expandedId: belongs ? expandedId : null, expandedReplies }));++ } catch (err) {++ return raw(`<section class="ovc-thread" id="comments"><h2>Comments</h2><p>${err instanceof CommentError && err.status === 404 ? 'Comments are hidden here.' : 'Comments are unavailable right now.'}</p></section>`);++ }++ }++/** A query as the tail of /watch?v=<id> (its own ?v= is never carried): ?t= a moment, ?list= a playlist. */function queryTail(query) {const qs = new URLSearchParams();@@@ -421,6 +457,7 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : htconst thumb = httpsUrl(row.thumbnail_url);// The page's one address: /watch?v=<watch id> (its /u/<id> redirects here until an id exists).const watchPath = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;++ const commentsHtml = await uploadThread(req, row, watchPath);if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done// Playlists (kind upload, by seq): a ready video anyone else may watch can be saved, and ?list= plays the next one.const saveForm = ready && row.state === 'active' && row.visibility !== 'private' && playlistForm ? await playlistForm(req, 'upload', row.seq) : '';@@@ -445,11 +482,12 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : htindex: listed, noindex: !listed,...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),title: row.title,-- styles: ['kit.css'],++ styles: ['kit.css', 'thread.css'],++ localStyles: ['css/comments.css'],description: about,canonical: `${ctx.config.video.url}${watchPath}`,...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),-- scripts: ready ? ['js/video.js'] : [],++ scripts: ready ? ['js/video.js', 'js/comments.js'] : [],player: !!(ready && vod),body: html`<div class="ovv-watch"><div class="ovv-main">@@@ -474,6 +512,7 @@ ${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"${manage && ai.overview !== row.description ? html`<form method="post" action="/u/${row.id}/manage"><input type="hidden" name="description" value="${ai.overview}"><button class="ovv-btn" type="submit">Use this as the description</button></form>` : ''}</section>` : ''}${aiDone && ai.segments.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${ai.segments.length} lines)</span></summary><div class="ovv-transcript-body">${ai.segments.map((x) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${x.start}">${vttTime(x.start).slice(0, 8)}</button> <span>${x.text}</span></p>`)}</div></details>` : ''}${manage && ready && ai && (ai.status === 'queued' || ai.status === 'running') ? html`<p class="ovv-fine">Captions, chapters and a summary are being made by OpenVibe.AI.</p>` : ''}++<section class="ovv-panel ovv-comments">${commentsHtml}</section></div><aside class="ovv-side">${inList.html}@@@ -516,6 +555,42 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thireturn x && x.ai.chapters.length ? sendVtt(res, renderChaptersVtt(x.ai.chapters.map((c) => ({ offset_seconds: c.start, description: c.title })), x.row.duration_seconds)) : notFoundVtt(req, res);});++ const commentFailure = (req, res, row, err) => page(req, res, { title: 'Comment not sent',++ body: html`<h1>Comment not sent</h1><p>${err.message}</p><p><a href="${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}#comments">Go back</a></p>`,++ }, err.status && err.status >= 400 && err.status <= 599 ? err.status : 422);++ const commentReturn = (row, sort) => {++ const path = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;++ return `${path}${path.includes('?') ? '&' : '?'}sort=${sort}#comments`;++ };++ r.post('/u/:id/comments', mustSignIn('here', 'Sign in to comment.'), commentLimit, commentMultipart, async (req, res) => {++ const row = await visibleUpload(req, res, req.params.id);++ if (!row) return undefined;++ if (!sameSite(req, res, commentReturn(row, commentSort(req)))) return undefined;++ if (row.state !== 'active' || row.status !== 'ready') return notFound(req, res);++ try {++ const form = await readCommentForm(req);++ const attachments = [];++ for (const file of form.files) {++ const out = await comments.upload(row, req.viewer.subject, file, req.ip);++ if (!out || !out.attachment) throw new CommentError(503, 'The attachment could not be saved');++ attachments.push(out.attachment);++ }++ await comments.add(row, req.viewer.subject, { message: form.message, parent_id: form.parent_id, attachments }, req.ip);++ return res.redirect(303, commentReturn(row, commentSort(req)));++ } catch (err) { return commentFailure(req, res, row, err); }++ });++ r.post('/u/:id/comments/delete', mustSignIn('here', 'Sign in to remove a comment.'), commentLimit, async (req, res) => {++ const row = await visibleUpload(req, res, req.params.id);++ if (!row) return undefined;++ if (!sameSite(req, res, commentReturn(row, commentSort(req)))) return undefined;++ const id = String(req.body.comment_id || '');++ if (!/^\d{1,15}$/.test(id)) return notFound(req, res);++ try {++ await comments.remove(row, req.viewer.subject, isStaff(req) || row.owner === ownerOf(req), id, req.ip);++ return res.redirect(303, commentReturn(row, commentSort(req)));++ } catch (err) { return commentFailure(req, res, row, err); }++ });++r.post('/u/:id/manage', mustSignIn('here'), async (req, res) => {const row = await up.byId(req.params.id);const who = whoOf(req);nnew file mode 100644@@@ -0,0 +1,61 @@++'use strict';++const assert = require('node:assert/strict');++const http = require('node:http');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const posts = [];++ const live = http.createServer((req, res) => {++ const path = new URL(req.url, 'http://local').pathname;++ const json = (body) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };++ if (path === '/api/vods/101') return json({ vod: { id: 101, title: 'A recording', visibility: 'public', username: 'maker',++ created_at: '2026-11-10T12:00:00Z', duration_seconds: 60, playback_url: 'https://openvibe.media/v/101' }, clips: [] });++ if (path === '/api/comments/vod/101' && req.method === 'POST') {++ const chunks = [];++ req.on('data', (chunk) => chunks.push(chunk));++ req.on('end', () => { posts.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); res.statusCode = 201; json({ comment: { id: 8 } }); });++ return;++ }++ if (path === '/api/comments/vod/101') return json({ thread: { id: 'cth_1234567890123456789012', url: 'https://openvibe.community/c/cth_1234567890123456789012' },++ comments: [{ id: 7, message: 'Good video', username: 'reader', display_name: 'Reader', created_at: '2026-11-10T12:00:00Z', reply_count: 0 }], viewer: { can_comment: true } });++ return json({});++ });++ await new Promise((resolve) => live.listen(0, '127.0.0.1', resolve));++ const url = `http://127.0.0.1:${live.address().port}`;++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: url,++ MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: url, MEDIAHUB_SEARCH_URL: url, MEDIAHUB_VIDEO_SEARCH: 'off' } });++ const videoRequest = (path, { method = 'GET', headers = {}, body = null } = {}) => new Promise((resolve, reject) => {++ const target = new URL(t.base);++ const request = http.request({ hostname: target.hostname, port: target.port, path, method,++ headers: { Host: 'openvibe.video', ...(body ? { 'Content-Length': body.length } : {}), ...headers } }, (response) => {++ const chunks = [];++ response.on('data', (chunk) => chunks.push(chunk));++ response.on('end', () => resolve({ status: response.statusCode, text: Buffer.concat(chunks).toString('utf8') }));++ });++ request.on('error', reject);++ request.end(body);++ });++ try {++ await check('recording renders the Community thread and a signed-out sign-in action', async () => {++ const r = await videoRequest('/v/101');++ assert.equal(r.status, 200, `${t.logs().slice(-1200)}\n${r.text.slice(0, 400)}`);++ assert.match(r.text, /class="ovc-thread"/);++ assert.match(r.text, /Good video/);++ assert.match(r.text, /Sign in to comment/);++ });++ await check('signed-in recording shows the shared composer and posts through Live', async () => {++ const person = t.network.addUser('threadwriter');++ const cookie = t.signIn(person);++ const page = await videoRequest('/v/101', { headers: { Cookie: cookie } });++ assert.equal(page.status, 200, page.text.slice(0, 300));++ assert.match(page.text, /data-ovt-composer/);++ const boundary = 'inline-thread-test';++ const body = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message"\r\n\r\nA new comment\r\n--${boundary}--\r\n`);++ const sent = await videoRequest('/v/101/comments?sort=new', { method: 'POST', body,++ headers: { Cookie: cookie, Origin: 'https://openvibe.video', 'Sec-Fetch-Site': 'same-origin', 'Content-Type': `multipart/form-data; boundary=${boundary}` } });++ assert.equal(sent.status, 303, `${sent.text.slice(0, 300)}\n${t.logs().slice(-600)}`);++ assert.deepEqual(posts[0], { message: 'A new comment', parent_id: null, attachments: [] });++ });++ } finally { await t.close(); await new Promise((resolve) => live.close(resolve)); }++ done();++})().catch((err) => { console.error(err); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++const assert = require('node:assert/strict');++const { renderThread } = require('../server/comments/inline');++++const answer = {++ thread: { id: 'cth_1234567890123456789012' }, viewer: { can_comment: true },++ comments: [{ id: 10, message: 'A <picture>', author: { username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.network/avatar/alice' },++ created_at: '2026-11-10T12:00:00Z', reply_count: 3, can_delete: true,++ attachments: [{ id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'image.png', media_type: 'image/png' },++ { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPR', name: 'notes.pdf', media_type: 'application/pdf' }],++ replies: [{ id: 11, parent_id: 10, reply_to_id: 10, reply_to: { display_name: 'Alice' }, message: 'First', author: { username: 'bob', display_name: 'Bob' } },++ { id: 12, parent_id: 10, reply_to_id: 11, reply_to: { display_name: 'Bob' }, message: 'Second', author: { username: 'alice', display_name: 'Alice' } }],++ }],++};++const options = { answer, base: '/watch?v=12345678901', post: '/u/vup_1/comments', remove: '/u/vup_1/comments/delete', sort: 'top' };++const signedOut = renderThread({ ...options, signedIn: false });++assert.match(signedOut, /class="ovc-thread"/);++assert.match(signedOut, /Sign in to comment/);++assert.doesNotMatch(signedOut, /data-ovt-composer/);++assert.match(signedOut, /sort=new/);++assert.match(signedOut, /1 more reply/);++assert.match(signedOut, /href="https:\/\/openvibe.network\/@alice"/);++assert.match(signedOut, /class="ovc-target"/);++assert.match(signedOut, /<picture>/);++assert.match(signedOut, /<img src="https:\/\/openvibe.media\/o\/med_/);++assert.match(signedOut, /notes.pdf/);++assert.match(signedOut, /%2Fwatch%3Fv%3D12345678901%26sort%3Dtop/);++const signedIn = renderThread({ ...options, signedIn: true, replyId: 11 });++assert.match(signedIn, /data-ovt-composer/);++assert.match(signedIn, /name="parent_id" value="11"/);++assert.match(signedIn, /data-ovt-files/);++assert.match(signedIn, /name="comment_id" value="10"/);++assert.match(renderThread({ ...options, answer: { ...answer, comments: [] }, signedIn: false }), /Be the first to comment/);++process.stdout.write('Inline thread renders replies, targets, attachments, order, composer and signed-out state\n');@@@ -42,7 +42,8 @@ async function boot(opts = {}) {const { createStore } = require('../../server/db');const testdb = await require('./db').testDb();const store = createStore(testdb.db, { now: opts.now });-- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true });++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true,++ fetchImpl: opts.fetchImpl });await built.ctx.keys.ensure();const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });const base = `http://127.0.0.1:${server.address().port}`;@@@ -18,6 +18,7 @@ const { createCommentsClient } = require('../server/pics/comments-client');assert.equal(form.get('file').name, 'look.png');return { status: 201, json: async () => ({ attachment: { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP7' } }) };}++ if (init.method === 'DELETE') return { status: 200, json: async () => ({ ok: true }) };if (init.method === 'GET') return { status: 200, json: async () => ({ comments: [{ id: 4, message: 'hello' }], next_cursor: null }) };return { status: 201, json: async () => ({ comment: { id: 5, parent_id: 4 } }) };};@@@ -32,6 +33,10 @@ const { createCommentsClient } = require('../server/pics/comments-client');assert.deepEqual(JSON.parse(calls[3].init.body), { message: 'reply', parent_id: 4, attachments: [] });const uploaded = await client.upload(picture, token, Buffer.from('look'), 'look.png', 'image/png');assert.equal(uploaded.status, 201);++ const removed = await client.remove('4', token);++ assert.equal(removed.status, 200);++ await client.read(picture, token, { sort: 'top' });++ assert.ok(calls.some((c) => c.url.includes('sort=top')));assert.ok(calls.every((c) => c.init.headers.Authorization === `Bearer ${token}`));process.stdout.write('Pics comments client read, reply, and upload pass\n');})().catch((err) => { process.stderr.write(`${err.stack}\n`); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++++const assert = require('node:assert/strict');++const http = require('node:http');++const sharp = require('sharp');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const posts = [];++ const fetchImpl = async (url, init) => {++ if (!url.startsWith('https://openvibe.community/api/v1/comments')) return fetch(url, init);++ if (url.endsWith('/threads/resolve')) return Response.json({ thread: { id: 'cth_1234567890123456789012' } });++ if (init.method === 'GET') {++ const q = new URL(url).searchParams;++ if (q.has('parent')) return Response.json({ comments: [{ id: 3, parent_id: 1, message: 'third reply', author: { username: 'reader', display_name: 'Reader' } }], next_cursor: null });++ return Response.json({ thread: { id: 'cth_1234567890123456789012', url: 'https://openvibe.community/c/cth_1234567890123456789012' },++ comments: [{ id: 1, message: 'On the picture', author: { username: 'reader', display_name: 'Reader' }, created_at: '2026-11-10T12:00:00Z',++ reply_count: 3, replies: [{ id: 2, parent_id: 1, reply_to_id: 1, reply_to: { display_name: 'Reader' }, message: 'first reply' }],++ attachments: [{ id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP6', name: 'look.png', media_type: 'image/png' }] }],++ viewer: { can_comment: true }, next_cursor: null });++ }++ if (init.method === 'POST') { posts.push(JSON.parse(init.body)); return Response.json({ comment: { id: 4 } }, { status: 201 }); }++ throw new Error(`Unexpected Community call: ${url}`);++ };++ const t = await boot({ fetchImpl });++ const person = t.network.addUser('picturewriter');++ const request = (path, { method = 'GET', body = null, headers = {}, signedIn = false } = {}) => new Promise((resolve, reject) => {++ const target = new URL(t.base);++ const req = http.request({ hostname: target.hostname, port: target.port, path, method,++ headers: { Host: 'openvibe.pics', ...(signedIn ? { Cookie: t.signIn(person) } : {}), ...(body ? { 'Content-Length': body.length } : {}), ...headers } }, (res) => {++ const chunks = [];++ res.on('data', (part) => chunks.push(part));++ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, text, location: res.headers.location }); });++ });++ req.on('error', reject);++ req.end(body);++ });++ try {++ const photo = await sharp({ create: { width: 32, height: 32, channels: 3, background: '#a84' } }).jpeg().toBuffer();++ const uploaded = await request('/api/v1/images', { method: 'POST', body: photo, signedIn: true,++ headers: { Origin: 'https://openvibe.pics', 'Content-Type': 'image/jpeg' } });++ assert.equal(uploaded.status, 201, uploaded.text.slice(0, 400));++ const slug = JSON.parse(uploaded.text).slug;++ await check('picture page renders comments, replies, target and attachment for signed-out visitors', async () => {++ const page = await request(`/i/${slug}`);++ assert.equal(page.status, 200, page.text.slice(0, 400));++ for (const value of ['ovc-thread', 'On the picture', 'first reply', 'ovc-target', 'ovc-more', 'ovc-attach', 'Sign in to comment'])++ assert.ok(page.text.includes(value), value);++ });++ await check('picture composer posts through the Pics proxy to Community', async () => {++ const page = await request(`/i/${slug}`, { signedIn: true });++ assert.match(page.text, /data-ovt-composer/);++ const boundary = 'pics-comment-test';++ const body = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message"\r\n\r\nA new comment\r\n--${boundary}--\r\n`);++ const sent = await request(`/i/${slug}/comments`, { method: 'POST', body, signedIn: true,++ headers: { Origin: 'https://openvibe.pics', 'Sec-Fetch-Site': 'same-origin', 'Content-Type': `multipart/form-data; boundary=${boundary}` } });++ assert.equal(sent.status, 303, sent.text.slice(0, 400));++ assert.deepEqual(posts[0], { message: 'A new comment', parent_id: null, attachments: [] });++ });++ } finally { await t.close(); }++ done();++})().catch((err) => { console.error(err); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++++const assert = require('node:assert/strict');++const { createCommentsClient } = require('../server/video/comments-client');++++(async () => {++ const calls = [];++ const id = 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ const row = { id, title: 'Unlisted test' };++ const subject = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++ const ref = { service: 'media-hub', type: 'video', id, label: row.title };++ const reply = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });++ const fetchImpl = async (url, init) => {++ calls.push({ url, init });++ if (url === 'http://network/oauth/token') return reply({ access_token: 'service-token', expires_in: 300 });++ assert.equal(init.headers.Authorization, 'Bearer service-token');++ if (url.endsWith('/threads/resolve')) {++ assert.deepEqual(JSON.parse(init.body).ref, ref);++ return reply({ thread: { access_id: 'cth_1234567890123456789012' } });++ }++ if (url.endsWith('/attachments')) {++ assert.deepEqual(JSON.parse(init.body.get('ref')), ref);++ return reply({ attachment: { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP6' } }, 201);++ }++ if (url.endsWith('/123')) return reply({ comment: { author: { subject } }, thread: { ref } });++ if (url.includes('/threads/cth_') && init.method === 'GET') return reply({ thread: { ref }, comments: [], next_cursor: null, viewer: { can_comment: true } });++ if (init.method === 'DELETE') return reply({ ok: true });++ if (init.method === 'POST') return reply({ comment: { id: 123 } }, 201);++ throw new Error(url);++ };++ const client = createCommentsClient({ config: { networkInternalUrl: 'http://network', oauth: { clientId: 'media-hub', clientSecret: 'test' } }, fetchImpl });++ const answer = await client.read(row, { subject, sort: 'top' });++ assert.deepEqual(answer.comments, []);++ assert.ok(calls.some((c) => c.url.includes('sort=top')));++ await client.upload(row, subject, new File(['image'], 'look.png', { type: 'image/png' }));++ await client.add(row, subject, { message: 'hello' });++ await client.remove(row, subject, true, '123');++ assert.ok(calls.some((c) => c.init.headers['X-OV-Subject'] === subject));++ assert.ok(calls.some((c) => c.init.headers['X-OV-Staff'] === '1'));++ console.log('Video Community proxy read, upload, post and scoped removal pass');++})().catch((err) => { console.error(err); process.exitCode = 1; });@@@ -80,9 +80,12 @@ function startLive() {};const comments101 = [-- { id: 'c1', message: 'Nice <b>stream</b>!', display_name: 'Commenter', username: 'commenter', created_at: '2026-10-12T10:00:00.000Z', reply_count: 3, is_deleted: 0, deleted: false },-- { id: 'c2', message: 'Agreed', display_name: null, username: 'other_user', created_at: '2026-10-12T10:05:00.000Z', reply_count: 0, is_deleted: 0, deleted: false },-- { id: 'c3', message: '', display_name: null, username: null, created_at: '2026-10-12T10:10:00.000Z', reply_count: 0, is_deleted: 1, deleted: true },++ { id: 1, message: 'Nice <b>stream</b>!', display_name: 'Commenter', username: 'commenter', created_at: '2026-10-12T10:00:00.000Z', reply_count: 3, is_deleted: 0, deleted: false,++ attachments: [{ id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'look.png', media_type: 'image/png' }],++ replies: [{ id: 4, message: 'First reply', display_name: 'Alice', username: 'alice', created_at: '2026-10-12T10:01:00.000Z', reply_to_id: 1, reply_to: { display_name: 'Commenter' } },++ { id: 5, message: 'Second reply', display_name: 'Bob', username: 'bob', created_at: '2026-10-12T10:02:00.000Z', reply_to_id: 4, reply_to: { display_name: 'Alice' } }] },++ { id: 2, message: 'Agreed', display_name: null, username: 'other_user', created_at: '2026-10-12T10:05:00.000Z', reply_count: 0, is_deleted: 0, deleted: false },++ { id: 3, message: '', display_name: null, username: null, created_at: '2026-10-12T10:10:00.000Z', reply_count: 0, is_deleted: 1, deleted: true },];const chatMessages101 = [{ id: 'm1', timestamp: '2026-10-12 09:29:59', username: 'early', display_name: 'Early', message: 'EARLY_MSG_XYZ', message_type: 'chat', user_id: 900001, anon_id: 'anon-PRIVATE-1', metadata: { note: 'meta-PRIVATE-1' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },@@@ -767,7 +770,7 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('1005 messages'), 'the Download menu counts every message');});-- await check('the VOD page shows the newest comments and the Community link', async () => {++ await check('the VOD page renders replies, targets, attachments, order and the Community link', async () => {const r = await getVideo('/v/101');assert.strictEqual(r.status, 200);assert.ok(r.text.includes('Comments'));@@@ -775,17 +778,21 @@ function countOccurrences(haystack, needle) {assert.ok(r.text.includes('other_user'), 'username fallback for the author');assert.ok(r.text.includes('Nice <b>stream</b>!'), 'comment text is escaped');assert.ok(!r.text.includes('Nice <b>stream</b>!'), 'raw comment HTML never appears');-- assert.ok(r.text.includes('3 replies'), 'reply count when present');-- assert.ok(r.text.includes('<time class="ovv-comment-date"'), 'a relative date is shown');-- assert.strictEqual(countOccurrences(r.text, 'class="ovv-comment"'), 2, 'the deleted comment is not rendered');++ assert.ok(r.text.includes('1 more reply'), 'the remaining replies load separately');++ assert.ok(r.text.includes('class="ovc-target"'), 'the exact reply target is shown');++ assert.ok(r.text.includes('class="ovc-attach ovc-attach--image"'), 'the picture attachment is shown');++ assert.ok(r.text.includes('<time datetime='), 'a relative date is shown');++ assert.strictEqual(countOccurrences(r.text, 'class="ovc-comment"'), 5, 'top-level comments and first replies are rendered');++ assert.ok(r.text.includes('class="ovc-order"'), 'Top and Newest are server-rendered');++ assert.ok(r.text.includes('Sign in to comment.'), 'the signed-out composer spot has a sign-in action');assert.ok(r.text.includes('Discuss on OpenVibe.Community'));assert.ok(r.text.includes('href="https://openvibe.community/t/vod-101"'));});-- await check('a VOD with no comments shows the empty line and the link', async () => {++ await check('a VOD with no comments uses the kit empty state and keeps the Community link', async () => {const r = await getVideo('/v/102');assert.strictEqual(r.status, 200);-- assert.ok(r.text.includes('No comments yet.'));++ assert.ok(r.text.includes('Be the first to comment'));assert.ok(r.text.includes('href="https://openvibe.community/t/vod-102"'));});
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+.ovc-thread { margin: 24px 0; }
+.ovc-head, .ovc-order, .ovc-comment header, .ovc-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.ovc-head { justify-content: space-between; }
+.ovc-comment { padding: 12px 0; border-bottom: 1px solid var(--border, #5555); }
+.ovc-comment header img { border-radius: 50%; object-fit: cover; }
+.ovc-comment time, .ovc-community { font-size: .875em; opacity: .8; }
+.ovc-replies { margin-left: 24px; }
+.ovc-actions form { display: inline; }
+.ovc-attach { display: inline-block; margin: 4px 8px 4px 0; }
+.ovc-attach--file { border: 1px solid currentColor; border-radius: 999px; padding: 4px 12px; }
+.ovc-attach img { max-width: 120px; max-height: 90px; object-fit: cover; }
+.ovc-order { display: inline-flex; gap: 0; border: 1px solid currentColor; border-radius: 8px; overflow: hidden; }
+.ovc-order a { border: 0; border-radius: 0; }
+.ovc-order a + a { border-left: 1px solid currentColor; }
+.ovc-order .is-active { font-weight: 700; background: color-mix(in srgb, currentColor 12%, transparent); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -0,0 +1,81 @@
+(function () {
+ 'use strict';
+ var thread = document.querySelector('[data-ovc-thread]');
+ if (!thread) return;
+ var allowed = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);
+ function read(html) { return new DOMParser().parseFromString(html, 'text/html').querySelector('[data-ovc-thread]'); }
+ function replace(next, url) {
+ if (!next) return false;
+ thread.replaceWith(next); thread = next;
+ if (url) history.pushState(null, '', url);
+ return true;
+ }
+ document.addEventListener('click', async function (event) {
+ var reply = event.target.closest('[data-ovc-reply]');
+ if (reply && thread.contains(reply)) {
+ var form = thread.querySelector('[data-ovt-composer]');
+ var item = reply.closest('.ovc-comment');
+ if (!form || !item) return;
+ event.preventDefault();
+ var parent = form.querySelector('[name="parent_id"]');
+ if (!parent) { parent = document.createElement('input'); parent.type = 'hidden'; parent.name = 'parent_id'; form.appendChild(parent); }
+ parent.value = reply.dataset.ovcReply;
+ var slot = item.querySelector(':scope > [data-ovc-composer-slot]');
+ if (!slot) { slot = document.createElement('div'); slot.dataset.ovcComposerSlot = ''; item.insertBefore(slot, item.querySelector(':scope > .ovc-replies')); }
+ slot.appendChild(form);
+ form.querySelector('textarea').focus();
+ history.replaceState(null, '', reply.href);
+ return;
+ }
+ var more = event.target.closest('[data-ovc-more]');
+ if (more && thread.contains(more)) {
+ event.preventDefault();
+ more.setAttribute('aria-busy', 'true');
+ try {
+ var response = await fetch(more.href, { credentials: 'same-origin' });
+ if (!response.ok) throw new Error('Replies could not be loaded.');
+ var next = read(await response.text());
+ var currentReplies = more.closest('[data-ovc-replies]');
+ var loadedReplies = next && next.querySelector('[data-ovc-replies="' + more.dataset.ovcMore + '"]');
+ if (!loadedReplies) throw new Error('Replies could not be loaded.');
+ currentReplies.replaceWith(loadedReplies);
+ history.pushState(null, '', more.href);
+ } catch (error) { more.textContent = error.message; more.removeAttribute('aria-busy'); }
+ return;
+ }
+ var order = event.target.closest('[data-ovc-order]');
+ if (order && thread.contains(order)) {
+ event.preventDefault();
+ order.setAttribute('aria-busy', 'true');
+ try {
+ var page = await fetch(order.href, { credentials: 'same-origin' });
+ if (!page.ok || !replace(read(await page.text()), order.href)) throw new Error('Comments could not be refreshed.');
+ } catch (error) { order.textContent = error.message; order.removeAttribute('aria-busy'); }
+ }
+ });
+ document.addEventListener('submit', async function (event) {
+ var form = event.target;
+ if (!thread.contains(form) || (!form.matches('[data-ovt-composer]') && !form.closest('.ovc-actions'))) return;
+ var files = form.querySelector('[data-ovt-files]');
+ var note = form.querySelector('[data-ovt-note]');
+ if (files && files.files.length) {
+ var problem = files.files.length > 4 ? 'Attach up to four files.' : '';
+ for (var file of files.files) {
+ if (file.size > 16 * 1024 * 1024) problem = 'A file can be up to 16 MB.';
+ if (!allowed.has(file.type)) problem = 'Attach JPEG, PNG, GIF, WebP, PDF or text files.';
+ }
+ if (problem) { event.preventDefault(); if (note) note.textContent = problem; return; }
+ }
+ event.preventDefault();
+ var button = form.querySelector('[type="submit"]');
+ if (button) button.disabled = true;
+ if (note) note.textContent = 'Sending…';
+ try {
+ var body = form.matches('[data-ovt-composer]') ? new FormData(form) : new URLSearchParams(new FormData(form));
+ var response = await fetch(form.action, { method: 'POST', body: body, credentials: 'same-origin' });
+ var html = await response.text();
+ var next = read(html);
+ if (!response.ok || !replace(next, response.url)) throw new Error('The comment could not be saved. Try again.');
+ } catch (error) { if (note) note.textContent = error.message; if (button) button.disabled = false; }
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,27 @@
@@ -0,0 +1,27 @@
+'use strict';
+
+const express = require('express');
+const { ID } = require('./inline');
+
+const ACCEPT = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf', 'text/plain']);
+const MAX_FILE = 16 * 1024 * 1024;
+const multipart = express.raw({ type: 'multipart/form-data', limit: '65mb' });
+
+async function readCommentForm(req) {
+ if (!Buffer.isBuffer(req.body)) throw new Error('The comment form was not received');
+ const request = new Request('http://localhost/comment', { method: 'POST', headers: { 'content-type': req.get('content-type') }, body: req.body });
+ const form = await request.formData();
+ const message = String(form.get('message') || '').trim();
+ if (!message || message.length > 2000) throw new Error('A comment is 1 to 2,000 characters');
+ const parent = String(form.get('parent_id') || '');
+ if (parent && !ID.test(parent)) throw new Error('That reply target is not valid');
+ const files = form.getAll('files').filter((f) => f && typeof f.arrayBuffer === 'function' && f.size);
+ if (files.length > 4) throw new Error('Attach up to four files');
+ for (const file of files) {
+ if (file.size > MAX_FILE) throw new Error('A file can be up to 16 MB');
+ if (!ACCEPT.has(file.type)) throw new Error('Attach JPEG, PNG, GIF, WebP, PDF or text files');
+ }
+ return { message, parent_id: parent || null, files };
+}
+
+module.exports = { multipart, readCommentForm };
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+
+/** Plain, server-rendered Community thread for the Video and Pics item pages. */
+const kit = require('openvibe-shared/kit');
+const thread = require('openvibe-shared/thread');
+const { esc } = require('../render/html');
+
+const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USERNAME = /^[A-Za-z0-9_]{1,32}$/;
+const ID = /^\d{1,15}$/;
+const safeHttps = (value) => { try { const url = new URL(String(value)); return url.protocol === 'https:' ? url.href : null; } catch { return null; } };
+const author = (c) => c.author || c;
+const username = (c) => author(c).username || c.username || '';
+const name = (c) => author(c).display_name || c.display_name || username(c) || c.anon_name || 'Member';
+const date = (iso) => {
+ const ms = Date.parse(String(iso || ''));
+ if (!Number.isFinite(ms)) return { short: '', full: '' };
+ const full = new Date(ms).toLocaleString('en-US', { dateStyle: 'long', timeStyle: 'short', timeZone: 'UTC' }) + ' UTC';
+ const seconds = Math.max(0, Math.floor((Date.now() - ms) / 1000));
+ const unit = seconds < 60 ? ['second', seconds] : seconds < 3600 ? ['minute', Math.floor(seconds / 60)]
+ : seconds < 86400 ? ['hour', Math.floor(seconds / 3600)] : seconds < 2592000 ? ['day', Math.floor(seconds / 86400)]
+ : seconds < 31536000 ? ['month', Math.floor(seconds / 2592000)] : ['year', Math.floor(seconds / 31536000)];
+ return { short: seconds < 60 ? 'just now' : `${unit[1]} ${unit[0]}${unit[1] === 1 ? '' : 's'} ago`, full };
+};
+
+function query(base, sort, extra = {}, anchor = 'comments') {
+ const q = new URLSearchParams({ sort, ...extra });
+ return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}#${anchor}`;
+}
+
+function renderThread({ answer, base, post, remove, signedIn, sort = 'top', replyId = null, expandedId = null, expandedReplies = null, error = null }) {
+ const comments = Array.isArray(answer && answer.comments) ? answer.comments : [];
+ const accessId = answer && answer.thread && String(answer.thread.access_id || answer.thread.id || '');
+ const threadUrl = safeHttps(answer && answer.thread && answer.thread.url) || (/^cth_[A-Za-z0-9_-]{22}$/.test(accessId) ? `https://openvibe.community/c/${accessId}` : null);
+ const validReply = ID.test(String(replyId || '')) ? String(replyId) : null;
+ const validExpanded = ID.test(String(expandedId || '')) ? String(expandedId) : null;
+ const signIn = `<p class="ovc-signin">Sign in to comment. <a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(query(base, sort))}">Sign in</a></p>`;
+ const composer = (parent = null) => signedIn ? thread.composer({ action: post, name: 'message', label: 'Comment', placeholder: parent ? 'Write a reply…' : 'Add a comment…', maxlength: 2000,
+ sendLabel: parent ? 'Reply' : 'Comment', hidden: parent ? { parent_id: parent } : {},
+ attach: { name: 'files', accept: 'image/jpeg,image/png,image/gif,image/webp,application/pdf,text/plain' },
+ note: 'Up to four files, 16 MB each. JPEG, PNG, GIF, WebP, PDF or text.',
+ }) : signIn;
+ const attachments = (c) => (Array.isArray(c.attachments) ? c.attachments : []).map((a) => {
+ if (!MEDIA_ID.test(String(a.id || ''))) return '';
+ const url = `https://openvibe.media/o/${a.id}`;
+ const title = esc(a.name || 'Attached file');
+ return `<a class="ovc-attach${String(a.media_type || '').startsWith('image/') ? ' ovc-attach--image' : ' ovc-attach--file'}" href="${url}" target="_blank" rel="noopener noreferrer">${String(a.media_type || '').startsWith('image/')
+ ? `<img src="${url}" alt="${title}" loading="lazy" width="120">` : title}</a>`;
+ }).join('');
+ const comment = (c, root = null) => {
+ const id = String(c.id);
+ if (!ID.test(id)) return '';
+ const gone = !!(c.deleted || c.is_deleted);
+ const user = username(c);
+ const profile = USERNAME.test(user) ? `https://openvibe.network/@${encodeURIComponent(user)}` : null;
+ const avatar = safeHttps(author(c).avatar_url || c.avatar_url) || (profile ? `https://openvibe.network/avatar/${encodeURIComponent(user)}?s=80` : null);
+ const when = date(c.created_at);
+ const targetId = ID.test(String(c.reply_to_id || '')) ? String(c.reply_to_id) : null;
+ const topId = root || id;
+ const target = targetId && c.reply_to ? `<a class="ovc-target" href="${query(base, sort, { replies: topId }, `comment-${targetId}`)}">↪ @${esc(c.reply_to.display_name || 'Member')}</a>` : '';
+ const controls = !gone ? `<div class="ovc-actions">${signedIn ? `<a class="ovk-btn ovk-btn--quiet" href="${query(base, sort, { reply: id }, `comment-${id}`)}" data-ovc-reply="${id}">Reply</a>` : ''}${c.can_delete && signedIn ? `<form method="post" action="${esc(remove)}"><input type="hidden" name="comment_id" value="${id}"><button class="ovk-btn ovk-btn--quiet" type="submit">Delete</button></form>` : ''}</div>` : '';
+ let replies = '';
+ if (!root) {
+ const rows = validExpanded === id && Array.isArray(expandedReplies) ? expandedReplies : (Array.isArray(c.replies) ? c.replies : []);
+ const more = Math.max(0, Number(c.reply_count || 0) - rows.length);
+ replies = `<div class="ovc-replies" data-ovc-replies="${id}">${rows.map((r) => comment(r, id)).join('')}${more ? `<a class="ovc-more ovk-btn ovk-btn--quiet" data-ovc-more="${id}" href="${query(base, sort, { replies: id })}">${more} more ${more === 1 ? 'reply' : 'replies'}</a>` : ''}</div>`;
+ }
+ return `<article class="ovc-comment" id="comment-${id}"><header>${avatar ? `<img src="${esc(avatar)}" alt="" width="32" height="32" loading="lazy">` : ''}${profile ? `<a href="${profile}">${esc(name(c))}</a>` : `<span>${esc(name(c))}</span>`}${when.short ? `<time datetime="${esc(c.created_at)}" title="${esc(when.full)}">${esc(when.short)}</time>` : ''}</header>${target}${gone ? '<p>Comment removed</p>' : `<p>${esc(c.message || '')}</p>${attachments(c)}`}${controls}${validReply === id ? `<div data-ovc-composer-slot>${composer(id)}</div>` : ''}${replies}</article>`;
+ };
+ const hasReplySlot = validReply && comments.some((c) => String(c.id) === validReply || (validExpanded === String(c.id) && Array.isArray(expandedReplies) ? expandedReplies : c.replies || []).some((r) => String(r.id) === validReply));
+ const order = `<nav class="ovc-order" aria-label="Comment order">${[['top', 'Top'], ['new', 'Newest']].map(([key, label]) => `<a class="ovk-btn ovk-btn--quiet${sort === key ? ' is-active' : ''}" href="${query(base, key)}"${sort === key ? ' aria-current="page"' : ''} data-ovc-order>${label}</a>`).join('')}</nav>`;
+ const next = answer && answer.next_cursor ? `<a class="ovk-btn ovk-btn--quiet ovc-more" href="${query(base, sort, { after: answer.next_cursor })}" data-ovc-order>More comments</a>` : '';
+ return `<section class="ovc-thread" id="comments" data-ovc-thread><div class="ovc-head"><h2>Comments</h2>${order}</div>${error ? `<p role="alert">${esc(error)}</p>` : ''}${!hasReplySlot ? `<div data-ovc-composer-slot>${composer()}</div>` : ''}${comments.length ? `<div class="ovc-list">${comments.map((c) => comment(c)).join('')}</div>` : kit.empty({ title: 'Be the first to comment' })}${next}${threadUrl ? `<p class="ovc-community"><a href="${esc(threadUrl)}">Discuss on OpenVibe.Community</a></p>` : ''}</section>`;
+}
+
+module.exports = { renderThread, MEDIA_ID, ID };
@@ -161,7 +161,7 @@ function createPicsApi(ctx) {
@@ -161,7 +161,7 @@ function createPicsApi(ctx) {
if (!row) return;
if (!row) return;
return commentAnswer(res, await comments.read(row.id, req.viewer.token, req.query));
return commentAnswer(res, await comments.read(row.id, req.viewer.token, req.query));
});
});
- r.post('/images/:slug/comments', person, async (req, res) => {
+ r.post('/images/:slug/comments', person, limits.budget('pics.image.write'), async (req, res) => {
const row = await load(req, res);
const row = await load(req, res);
if (!row) return;
if (!row) return;
const b = req.body || {};
const b = req.body || {};
@@ -171,13 +171,19 @@ function createPicsApi(ctx) {
@@ -171,13 +171,19 @@ function createPicsApi(ctx) {
{ message: b.message, parent_id: b.parent_id || null, attachments: b.attachments || [] }));
{ message: b.message, parent_id: b.parent_id || null, attachments: b.attachments || [] }));
});
});
const commentFile = express.raw({ type: 'application/octet-stream', limit: '16mb' });
const commentFile = express.raw({ type: 'application/octet-stream', limit: '16mb' });
- r.post('/images/:slug/comment-attachments', person, commentFile, async (req, res) => {
+ r.post('/images/:slug/comment-attachments', person, limits.budget('pics.image.upload'), commentFile, async (req, res) => {
const row = await load(req, res);
const row = await load(req, res);
if (!row) return;
if (!row) return;
if (!Buffer.isBuffer(req.body) || !req.body.length) return problem(req, res, 400, 'pics.comment.file', 'Choose a file');
if (!Buffer.isBuffer(req.body) || !req.body.length) return problem(req, res, 400, 'pics.comment.file', 'Choose a file');
return commentAnswer(res, await comments.upload(row.id, req.viewer.token, req.body,
return commentAnswer(res, await comments.upload(row.id, req.viewer.token, req.body,
String(req.get('x-file-name') || 'file').slice(0, 255), String(req.get('x-file-mime') || 'application/octet-stream').slice(0, 100)));
String(req.get('x-file-name') || 'file').slice(0, 255), String(req.get('x-file-mime') || 'application/octet-stream').slice(0, 100)));
});
});
+ r.delete('/images/:slug/comments/:commentId', person, limits.budget('pics.image.write'), async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return;
+ if (!/^\d{1,15}$/.test(req.params.commentId)) return problem(req, res, 400, 'pics.comment.id', 'Invalid comment');
+ return commentAnswer(res, await comments.remove(req.params.commentId, req.viewer.token));
+ });
r.get('/images/:slug/bytes', person, async (req, res) => {
r.get('/images/:slug/bytes', person, async (req, res) => {
const row = await load(req, res);
const row = await load(req, res);
@@ -27,8 +27,8 @@ function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {
@@ -27,8 +27,8 @@ function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {
const found = await thread(id, token);
const found = await thread(id, token);
if (!found.id) return found;
if (!found.id) return found;
const qs = new URLSearchParams();
const qs = new URLSearchParams();
- if (query.sort === 'new') qs.set('sort', 'new');
- if (query.after && /^\d{1,15}$/.test(String(query.after))) qs.set('after', String(query.after));
+ if (query.sort === 'new' || query.sort === 'top') qs.set('sort', query.sort);
+ if (query.after && /^(?:-?\d{1,12}:)?\d{1,15}$/.test(String(query.after))) qs.set('after', String(query.after));
if (query.parent && /^\d{1,15}$/.test(String(query.parent))) qs.set('parent', String(query.parent));
if (query.parent && /^\d{1,15}$/.test(String(query.parent))) qs.set('parent', String(query.parent));
if (query.limit) qs.set('limit', String(Math.min(Math.max(Number(query.limit) || 30, 1), 100)));
if (query.limit) qs.set('limit', String(Math.min(Math.max(Number(query.limit) || 30, 1), 100)));
return await call('GET', `/threads/${encodeURIComponent(found.id)}${qs.size ? `?${qs}` : ''}`, { token });
return await call('GET', `/threads/${encodeURIComponent(found.id)}${qs.size ? `?${qs}` : ''}`, { token });
@@ -44,7 +44,22 @@ function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {
@@ -44,7 +44,22 @@ function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {
form.set('file', new Blob([buffer], { type: mime || 'application/octet-stream' }), name || 'file');
form.set('file', new Blob([buffer], { type: mime || 'application/octet-stream' }), name || 'file');
return await call('POST', '/attachments', { token, body: form });
return await call('POST', '/attachments', { token, body: form });
}
}
- return { read, add, upload };
+ async function remove(commentId, token) {
+ return await call('DELETE', `/${encodeURIComponent(commentId)}`, { token });
+ }
+ async function replies(id, token, parent) {
+ const all = [];
+ let after = null;
+ while (true) {
+ const result = await read(id, token, { parent, after, limit: 100 });
+ if (result.status !== 200) return result;
+ all.push(...result.body.comments);
+ if (!result.body.next_cursor) return { status: 200, body: { replies: all } };
+ if (result.body.next_cursor === after) return { status: 503, body: { error: 'Replies could not be loaded' } };
+ after = result.body.next_cursor;
+ }
+ }
+ return { read, add, upload, remove, replies };
}
}
module.exports = { createCommentsClient };
module.exports = { createCommentsClient };
@@ -38,6 +38,10 @@ const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
@@ -38,6 +38,10 @@ const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { send, assetVersion, SITE_NAME } = require('./layout');
const { send, assetVersion, SITE_NAME } = require('./layout');
const store = require('./store');
const store = require('./store');
const { createPicsApi } = require('./api');
const { createPicsApi } = require('./api');
+const { createCommentsClient } = require('./comments-client');
+const { createCommentsClient: createServiceCommentsClient } = require('../video/comments-client');
+const { renderThread } = require('../comments/inline');
+const { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const { createPicVariants } = require('./variants');
const { createPicVariants } = require('./variants');
const kit = require('openvibe-shared/kit');
const kit = require('openvibe-shared/kit');
const gallery = require('./gallery');
const gallery = require('./gallery');
@@ -56,6 +60,9 @@ const KIND = { jpg: 'JPEG', png: 'PNG', gif: 'GIF', webp: 'WebP', avif: 'AVIF' }
@@ -56,6 +60,9 @@ const KIND = { jpg: 'JPEG', png: 'PNG', gif: 'GIF', webp: 'WebP', avif: 'AVIF' }
function createPicsRoutes(ctx) {
function createPicsRoutes(ctx) {
const { config, s, media, log } = ctx;
const { config, s, media, log } = ctx;
const pics = ctx.pics;
const pics = ctx.pics;
+ const comments = createCommentsClient({ fetchImpl: ctx.fetchImpl || globalThis.fetch });
+ const commentModeration = createServiceCommentsClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch,
+ refOf: (row) => ({ service: 'pics', type: 'picture', id: String(row.id) }) });
const sharing = ctx.sharing;
const sharing = ctx.sharing;
const cfg = config.pics;
const cfg = config.pics;
const base = cfg.url;
const base = cfg.url;
@@ -391,6 +398,27 @@ ${raw(showcase.network({
@@ -391,6 +398,27 @@ ${raw(showcase.network({
const title = row.title || 'Untitled picture';
const title = row.title || 'Untitled picture';
const hidden = row.state !== 'active';
const hidden = row.state !== 'active';
const commentsAvailable = !hidden && !!(sharing && await sharing.canView({ kind: 'anonymous' }, 'picture', row.id).catch(() => false));
const commentsAvailable = !hidden && !!(sharing && await sharing.canView({ kind: 'anonymous' }, 'picture', row.id).catch(() => false));
+ const sort = req.query.sort === 'new' ? 'new' : 'top';
+ let commentsHtml = '';
+ if (commentsAvailable) {
+ const result = await comments.read(row.id, req.viewer && req.viewer.token, { sort, after: req.query.after });
+ if (result.status === 200) {
+ const answer = result.body;
+ if (owner || (signedIn(req) && pics.isStaff(req.viewer))) {
+ for (const c of answer.comments) {
+ c.can_delete = true;
+ for (const reply of c.replies || []) reply.can_delete = true;
+ }
+ }
+ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;
+ const belongs = expandedId && answer.comments.some((c) => String(c.id) === expandedId);
+ const more = belongs ? await comments.replies(row.id, req.viewer && req.viewer.token, expandedId) : null;
+ commentsHtml = renderThread({ answer, base: `/i/${row.slug}`, post: `/i/${row.slug}/comments?sort=${sort}`,
+ remove: `/i/${row.slug}/comments/delete?sort=${sort}`, signedIn: signedIn(req) && answer.viewer.can_comment !== false,
+ sort, replyId: req.query.reply, expandedId: belongs ? expandedId : null,
+ expandedReplies: more && more.status === 200 ? more.body.replies : null });
+ } else commentsHtml = '<section class="ovc-thread" id="comments"><h2>Comments</h2><p>Comments are unavailable right now.</p></section>';
+ }
// ?like=<slug> after an upload: a near-duplicate of one of the person's own pictures (VIS-6). Only a slug of theirs is linked.
// ?like=<slug> after an upload: a near-duplicate of one of the person's own pictures (VIS-6). Only a slug of theirs is linked.
const likeRow = owner && /^[A-Za-z0-9]{4,16}$/.test(String(req.query.like || '')) ? await store.imageBySlug(s, String(req.query.like)) : null;
const likeRow = owner && /^[A-Za-z0-9]{4,16}$/.test(String(req.query.like || '')) ? await store.imageBySlug(s, String(req.query.like)) : null;
const likeSlug = likeRow && likeRow.id !== row.id && likeRow.owner === row.owner ? likeRow.slug : null;
const likeSlug = likeRow && likeRow.id !== row.id && likeRow.owner === row.owner ? likeRow.slug : null;
@@ -399,8 +427,8 @@ ${raw(showcase.network({
@@ -399,8 +427,8 @@ ${raw(showcase.network({
jsonLd: gallery.isPublic(row) ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, contentUrl: L.direct, url: L.page, caption: row.description || title, author: { '@type': 'Person', name: row.author_name || 'OpenVibe member' } } : undefined,
jsonLd: gallery.isPublic(row) ? { '@context': 'https://schema.org', '@type': 'ImageObject', name: title, contentUrl: L.direct, url: L.page, caption: row.description || title, author: { '@type': 'Person', name: row.author_name || 'OpenVibe member' } } : undefined,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
description: row.description || `${title}: a picture on ${SITE_NAME}.`,
image: hidden ? undefined : L.direct, ogType: 'article',
image: hidden ? undefined : L.direct, ogType: 'article',
- scripts: ['js/pics.js', 'js/pics-gallery.js'], styles: ['kit.css'], sharedScripts: ['kit-web.js'],
- ...(owner ? { localStyles: ['css/labels.css'] } : {}),
+ scripts: ['js/pics.js', 'js/pics-gallery.js', 'js/comments.js'], styles: ['kit.css', 'thread.css'], sharedScripts: ['kit-web.js'],
+ localStyles: ['css/comments.css', ...(owner ? ['css/labels.css'] : [])],
private: deleteKey ? true : undefined,
private: deleteKey ? true : undefined,
body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}
body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${owner && req.query.again ? notice('You had already uploaded this picture: here it is. Nothing was stored twice.', 'info') : ''}${owner && req.query.new && likeSlug ? notice(html`It looks like a picture you uploaded before: <a href="/i/${likeSlug}">that one</a>. If you meant to keep only one, delete the other.`, 'info') : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
@@ -423,7 +451,7 @@ ${!hidden ? html`<nav class="ovp-pic-make" aria-label="Make something with this
@@ -423,7 +451,7 @@ ${!hidden ? html`<nav class="ovp-pic-make" aria-label="Make something with this
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
</article>
</article>
-${commentsAvailable ? html`<section class="ovp-section ovp-comments" aria-labelledby="h-comments"><div class="ovp-comments-head"><h2 id="h-comments">Comments</h2><button type="button" class="ovk-btn ovk-btn--quiet" data-comments="${row.slug}">${glyph('chat', 17)}<span>Show the discussion</span></button></div><div class="ovp-comments-list" data-comments-list aria-live="polite"></div></section>` : ''}
+${raw(commentsHtml)}
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
<p class="ovp-share-lede">These links follow the audience you choose in Share. Private pictures are visible only to you. The direct link is the picture itself, at full size — no page around it.</p>
<p class="ovp-share-lede">These links follow the audience you choose in Share. Private pictures are visible only to you. The direct link is the picture itself, at full size — no page around it.</p>
<div class="ovp-link-grid">
<div class="ovp-link-grid">
@@ -469,6 +497,43 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
@@ -469,6 +497,43 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
}, status);
}, status);
}
}
+ const commentFailure = (req, res, slug, detail, status = 422) => page(req, res, {
+ title: 'Comment not sent', cache: cache.htmlHeaders({ private: true }),
+ body: html`<h1>Comment not sent</h1><p>${detail}</p><p><a href="/i/${slug}#comments">Go back</a></p>`,
+ }, status);
+ r.post('/i/:slug/comments', mustSignIn('Sign in to comment.'), budgeted('pics.image.write'), commentMultipart, async (req, res) => {
+ const { row, status } = await picture(req, res);
+ if (!row || status !== 200) return notFound(req, res);
+ if (!sameSite(req, res, `/i/${row.slug}`)) return undefined;
+ if (!await sharing.canView({ kind: 'anonymous' }, 'picture', row.id)) return notFound(req, res);
+ let form;
+ try { form = await readCommentForm(req); }
+ catch (err) { return commentFailure(req, res, row.slug, err.message); }
+ const attachments = [];
+ for (const file of form.files) {
+ const uploaded = await comments.upload(row.id, req.viewer.token, Buffer.from(await file.arrayBuffer()), file.name, file.type);
+ if (uploaded.status !== 201 || !uploaded.body.attachment) return commentFailure(req, res, row.slug, uploaded.body.detail || uploaded.body.error || 'The attachment could not be saved', uploaded.status);
+ attachments.push(uploaded.body.attachment);
+ }
+ const result = await comments.add(row.id, req.viewer.token, { message: form.message, parent_id: form.parent_id, attachments });
+ if (result.status !== 201) return commentFailure(req, res, row.slug, result.body.detail || result.body.error || 'The comment could not be saved', result.status);
+ return res.redirect(303, `/i/${row.slug}?sort=${req.query.sort === 'new' ? 'new' : 'top'}#comments`);
+ });
+ r.post('/i/:slug/comments/delete', mustSignIn('Sign in to remove a comment.'), budgeted('pics.image.write'), async (req, res) => {
+ const { row, status } = await picture(req, res);
+ if (!row || status !== 200) return notFound(req, res);
+ if (!sameSite(req, res, `/i/${row.slug}`)) return undefined;
+ const id = String(req.body.comment_id || '');
+ if (!/^\d{1,15}$/.test(id)) return commentFailure(req, res, row.slug, 'Invalid comment');
+ let result;
+ if (pics.isOwner(row, whoOf(req)) || (signedIn(req) && pics.isStaff(req.viewer))) {
+ try { await commentModeration.remove(row, req.viewer.subject, true, id, req.ip); result = { status: 200 }; }
+ catch (err) { result = { status: err.status || 503, body: { detail: err.message } }; }
+ } else result = await comments.remove(id, req.viewer.token);
+ if (result.status !== 200) return commentFailure(req, res, row.slug, result.body.detail || result.body.error || 'The comment could not be removed', result.status);
+ return res.redirect(303, `/i/${row.slug}?sort=${req.query.sort === 'new' ? 'new' : 'top'}#comments`);
+ });
+
r.get('/i/:slug', async (req, res) => {
r.get('/i/:slug', async (req, res) => {
const { row, status, denied } = await picture(req, res);
const { row, status, denied } = await picture(req, res);
if (denied && row) {
if (denied && row) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+
+/** Community's thread for a MediaHub upload, addressed through this item's visibility checks. */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+class CommentError extends Error {
+ constructor(status, message) { super(message); this.status = status; }
+}
+
+function createCommentsClient({ config, fetchImpl = globalThis.fetch, refOf = (row) => ({ service: 'media-hub', type: 'video', id: String(row.id), label: String(row.title || 'Video').slice(0, 200) }) } = {}) {
+ const origin = (process.env.OV_COMMUNITY_INTERNAL_URL || 'http://127.0.0.1:4200').replace(/\/+$/, '');
+ let tokens = null;
+ const ref = refOf;
+ async function call(method, path, { subject = null, staff = false, body = null, ip = null } = {}) {
+ let token;
+ try {
+ if (!tokens) tokens = createServiceTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret, audience: 'openvibe.community', fetch: fetchImpl });
+ token = await tokens.getToken();
+ } catch { throw new CommentError(503, 'Comments are unavailable right now'); }
+ const form = body instanceof FormData;
+ let res;
+ try {
+ res = await fetchImpl(`${origin}/api/v1/comments${path}`, { method, redirect: 'error', signal: AbortSignal.timeout(form ? 120000 : 5000),
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}`, ...(subject ? { 'X-OV-Subject': subject } : {}),
+ ...(staff ? { 'X-OV-Staff': '1' } : {}), ...(ip ? { 'X-Forwarded-For': ip } : {}),
+ ...(body && !form ? { 'Content-Type': 'application/json' } : {}) },
+ body: form ? body : body ? JSON.stringify(body) : undefined });
+ } catch { throw new CommentError(503, 'Comments are unavailable right now'); }
+ const data = await res.json().catch(() => null);
+ if (!res.ok) throw new CommentError(res.status, data && (data.detail || data.error) || 'Comments are unavailable right now');
+ return data;
+ }
+ async function thread(row, ip) {
+ const data = await call('POST', '/threads/resolve', { body: { ref: ref(row) }, ip });
+ const id = data && data.thread && data.thread.access_id;
+ if (!/^cth_[A-Za-z0-9_-]{22}$/.test(String(id || ''))) throw new CommentError(503, 'Comments are unavailable right now');
+ return id;
+ }
+ async function read(row, { subject = null, staff = false, sort = 'top', after = null, parent = null, limit = 30, ip = null } = {}) {
+ const id = await thread(row, ip);
+ const q = new URLSearchParams({ sort, limit: String(limit) });
+ if (after) q.set('after', after);
+ if (parent) q.set('parent', parent);
+ const answer = await call('GET', `/threads/${id}?${q}`, { subject, staff, ip });
+ if (answer.thread && answer.thread.visibility === 'hidden' && !staff) throw new CommentError(404, 'Comments are hidden here');
+ return answer;
+ }
+ async function upload(row, subject, file, ip) {
+ const form = new FormData();
+ form.set('ref', JSON.stringify(ref(row)));
+ form.set('file', file, file.name || 'file');
+ return await call('POST', '/attachments', { subject, body: form, ip });
+ }
+ async function add(row, subject, body, ip) {
+ const id = await thread(row, ip);
+ return await call('POST', `/threads/${id}/comments`, { subject, body, ip });
+ }
+ async function remove(row, subject, staff, commentId, ip) {
+ const found = await call('GET', `/${encodeURIComponent(commentId)}`, { subject, staff, ip });
+ const linked = found && found.thread && found.thread.ref;
+ const expected = ref(row);
+ if (!linked || linked.service !== expected.service || linked.type !== expected.type || linked.id !== expected.id) throw new CommentError(404, 'Comment not found');
+ if (!staff && (!found.comment.author || found.comment.author.subject !== subject)) throw new CommentError(403, 'Only the author can remove this comment');
+ return await call('DELETE', `/${encodeURIComponent(commentId)}`, { subject, staff, ip });
+ }
+ return { read, upload, add, remove, CommentError };
+}
+
+module.exports = { createCommentsClient, CommentError };
@@ -85,19 +85,19 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -85,19 +85,19 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
}
}
const refreshing = new Map(); // url → the load that refreshes it
const refreshing = new Map(); // url → the load that refreshes it
- async function getJson(path) {
+ async function getJson(path, { fresh = false } = {}) {
const url = `${config.video.liveUrl}${path}`;
const url = `${config.video.liveUrl}${path}`;
- const cached = hit(url);
+ const cached = fresh ? undefined : hit(url);
if (cached !== undefined) return cached;
if (cached !== undefined) return cached;
const entry = cache.get(url);
const entry = cache.get(url);
- if (entry && Date.now() - entry.at < SERVE_STALE_MS) {
+ if (!fresh && entry && Date.now() - entry.at < SERVE_STALE_MS) {
if (!refreshing.has(url)) refreshing.set(url, load(url).catch(() => {}).finally(() => refreshing.delete(url)));
if (!refreshing.has(url)) refreshing.set(url, load(url).catch(() => {}).finally(() => refreshing.delete(url)));
return entry.value;
return entry.value;
}
}
- return load(url);
+ return load(url, fresh);
}
}
- async function load(url) {
+ async function load(url, fresh = false) {
let res;
let res;
try {
try {
res = await fetchImpl(url, {
res = await fetchImpl(url, {
@@ -108,19 +108,24 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -108,19 +108,24 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
signal: AbortSignal.timeout(TIMEOUT_MS),
signal: AbortSignal.timeout(TIMEOUT_MS),
});
});
} catch (err) {
} catch (err) {
+ if (fresh) throw new LiveUnavailableError('OpenVibe.Live did not answer; try again in a minute.');
return unavailable(url, err && err.name);
return unavailable(url, err && err.name);
}
}
if (res.status === 404) return null;
if (res.status === 404) return null;
- if (!res.ok) return unavailable(url, `HTTP ${res.status}`);
+ if (!res.ok) {
+ if (fresh) throw new LiveUnavailableError(`OpenVibe.Live answered ${res.status}; try again in a minute.`);
+ return unavailable(url, `HTTP ${res.status}`);
+ }
let body;
let body;
try {
try {
body = await res.json();
body = await res.json();
} catch {
} catch {
+ if (fresh) throw new LiveUnavailableError('OpenVibe.Live did not answer with JSON.');
return unavailable(url, 'not JSON');
return unavailable(url, 'not JSON');
}
}
- return remember(url, body);
+ return fresh ? body : remember(url, body);
}
}
/**
/**
@@ -256,8 +261,30 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -256,8 +261,30 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
clip(id) {
clip(id) {
return getJson(`/api/clips/${id}`);
return getJson(`/api/clips/${id}`);
},
},
- comments(kind, id) {
- return getJson(`/api/comments/${kind}/${id}`);
+ comments(kind, id, { sort = 'top', after = null } = {}) {
+ const q = new URLSearchParams({ inline: '1', sort });
+ if (after) q.set('after', after);
+ return getJson(`/api/comments/${kind}/${id}?${q}`, { fresh: true });
+ },
+ commentsAs(token, kind, id, opts = {}) {
+ const q = new URLSearchParams({ inline: '1', sort: opts.sort || 'top' });
+ if (opts.after) q.set('after', opts.after);
+ return asPerson(token, 'GET', `/api/comments/${kind}/${id}?${q}`);
+ },
+ commentReplies(token, commentId) {
+ const path = `/api/comments/${commentId}/replies`;
+ return token ? asPerson(token, 'GET', path) : getJson(path, { fresh: true });
+ },
+ postComment(token, kind, id, body, viewerIp) {
+ return asPerson(token, 'POST', `/api/comments/${kind}/${id}`, body, { viewerIp });
+ },
+ uploadCommentFile(token, kind, id, file, viewerIp) {
+ const form = new FormData();
+ form.set('file', file, file.name || 'file');
+ return asPerson(token, 'POST', `/api/comments/${kind}/${id}/attachments`, form, { viewerIp });
+ },
+ deleteComment(token, commentId, viewerIp) {
+ return asPerson(token, 'DELETE', `/api/comments/${commentId}`, null, { viewerIp });
},
},
commentsPage(kind, id, query = {}) {
commentsPage(kind, id, query = {}) {
const qs = new URLSearchParams();
const qs = new URLSearchParams();
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
* the sitemap. A private item is missing (Live already answers 404) and becomes a 404 here.
* the sitemap. A private item is missing (Live already answers 404) and becomes a 404 here.
*/
*/
const express = require('express');
const express = require('express');
+const rateLimit = require('express-rate-limit');
const path = require('path');
const path = require('path');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const securityTxt = require('../http/security-txt');
const securityTxt = require('../http/security-txt');
@@ -46,6 +47,8 @@ const { createUploadApi, mountUploadPages } = require('./upload-routes');
@@ -46,6 +47,8 @@ const { createUploadApi, mountUploadPages } = require('./upload-routes');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { ACCESS_COOKIE, REFRESH_COOKIE } = require('../auth/sso');
const { mountDownloads, downloadMenu } = require('./downloads');
const { mountDownloads, downloadMenu } = require('./downloads');
const { actionMenu, deleteAction, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');
const { actionMenu, deleteAction, menuLinks, companion, whatItIs, shareButton, lengthWords, dayLabel, streamDate } = require('./watch-ui');
+const { renderThread } = require('../comments/inline');
+const { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
const HOME_LIMIT = 10;
const HOME_LIMIT = 10;
@@ -665,6 +668,9 @@ function createVideoRoutes(ctx) {
@@ -665,6 +668,9 @@ function createVideoRoutes(ctx) {
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const shelves = createSearchReads({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const shelves = createSearchReads({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
const r = asyncRouter();
+ const commentSort = (req) => req.query.sort === 'new' ? 'new' : 'top';
+ const commentAfter = (req) => typeof req.query.after === 'string' && /^(?:-?\d{1,12}:)?\d{1,15}$/.test(req.query.after) ? req.query.after : null;
+ const commentLimit = rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false });
// The menu's network card (frame.menu-card@1): the week's most viewed public recordings and clips. Public,
// The menu's network card (frame.menu-card@1): the week's most viewed public recordings and clips. Public,
// CORS *, cached two minutes; the rows are Live's Content feed (which reads Media) and never a private item.
// CORS *, cached two minutes; the rows are Live's Content feed (which reads Media) and never a private item.
r.get('/.well-known/openvibe-menu.json', createMenuCardHandler({ feed: (opts) => live.feed(opts) }));
r.get('/.well-known/openvibe-menu.json', createMenuCardHandler({ feed: (opts) => live.feed(opts) }));
@@ -734,6 +740,26 @@ function createVideoRoutes(ctx) {
@@ -734,6 +740,26 @@ function createVideoRoutes(ctx) {
return sameName(me, item.username) || sameName(me, item.source_streamer_username) || sameName(me, item.channel_username);
return sameName(me, item.username) || sameName(me, item.source_streamer_username) || sameName(me, item.channel_username);
}
}
const asPerson = (req, res, fn) => ctx.sso.asViewer(req, res, fn);
const asPerson = (req, res, fn) => ctx.sso.asViewer(req, res, fn);
+ async function liveThread(req, res, kind, id) {
+ const opts = { sort: commentSort(req), after: commentAfter(req) };
+ return signedIn(req) ? asPerson(req, res, (token) => live.commentsAs(token, kind, id, opts)) : live.comments(kind, id, opts);
+ }
+ async function liveThreadHtml(req, res, kind, id, basePath, answer) {
+ if (!answer) return raw('<section class="ovc-thread" id="comments"><h2>Comments</h2><p>Comments are unavailable right now.</p></section>');
+ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;
+ const belongs = expandedId && answer.comments && answer.comments.some((c) => String(c.id) === expandedId);
+ let expandedReplies = null;
+ if (belongs) {
+ try {
+ const out = signedIn(req) ? await asPerson(req, res, (token) => live.commentReplies(token, expandedId)) : await live.commentReplies(null, expandedId);
+ expandedReplies = out && out.replies;
+ } catch { /* the first two remain visible */ }
+ }
+ const endpoint = `/${kind === 'clip' ? 'c' : 'v'}/${id}/comments`;
+ return raw(renderThread({ answer, base: basePath, post: `${endpoint}?sort=${commentSort(req)}`, remove: `${endpoint}/delete?sort=${commentSort(req)}`,
+ signedIn: signedIn(req) && (!answer.viewer || answer.viewer.can_comment !== false), sort: commentSort(req),
+ replyId: req.query.reply, expandedId: belongs ? expandedId : null, expandedReplies }));
+ }
/** A signed-in person's own view of an item Live hides from everyone else (private), or null. */
/** A signed-in person's own view of an item Live hides from everyone else (private), or null. */
async function ownView(req, res, kind, id) {
async function ownView(req, res, kind, id) {
if (!signedIn(req)) return null;
if (!signedIn(req)) return null;
@@ -974,6 +1000,39 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -974,6 +1000,39 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// parser, so the two never read each other's bodies.
// parser, so the two never read each other's bodies.
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
+ r.post('/:kind(v|c)/:id/comments', mustSignIn('here', 'Sign in to comment.'), commentLimit, commentMultipart, async (req, res) => {
+ const kind = req.params.kind === 'c' ? 'clip' : 'vod';
+ const id = String(req.params.id || '');
+ const back = `/watch?v=${encodeURIComponent(String(req.query.v || ''))}`;
+ if (!live.isId(id)) return notFound(req, res);
+ if (!sameSite(req, res, back)) return undefined;
+ let form;
+ try { form = await readCommentForm(req); }
+ catch (err) { return page(req, res, { title: 'Comment not sent', body: html`<h1>Comment not sent</h1><p>${err.message}</p><p><a href="/${req.params.kind}/${id}#comments">Go back</a></p>` }, 422); }
+ try {
+ await asPerson(req, res, async (token) => {
+ const attachments = [];
+ for (const file of form.files) {
+ const out = await live.uploadCommentFile(token, kind, id, file, req.ip);
+ if (!out || !out.attachment) throw new Error('The file could not be attached');
+ attachments.push(out.attachment);
+ }
+ return live.postComment(token, kind, id, { message: form.message, parent_id: form.parent_id, attachments }, req.ip);
+ });
+ return res.redirect(303, `/${req.params.kind}/${id}?sort=${commentSort(req)}#comments`);
+ } catch (err) { return ownerRefused(req, res, err, `/${req.params.kind}/${id}#comments`); }
+ });
+ r.post('/:kind(v|c)/:id/comments/delete', mustSignIn('here', 'Sign in to remove a comment.'), commentLimit, async (req, res) => {
+ const id = String(req.params.id || '');
+ if (!live.isId(id)) return notFound(req, res);
+ if (!sameSite(req, res, `/${req.params.kind}/${id}`)) return undefined;
+ const commentId = String(req.body.comment_id || '');
+ if (!/^\d{1,15}$/.test(commentId)) return notFound(req, res);
+ try {
+ await asPerson(req, res, (token) => live.deleteComment(token, commentId, req.ip));
+ return res.redirect(303, `/${req.params.kind}/${id}?sort=${commentSort(req)}#comments`);
+ } catch (err) { return ownerRefused(req, res, err, `/${req.params.kind}/${id}#comments`); }
+ });
r.post('/watch/like', async (req, res) => {
r.post('/watch/like', async (req, res) => {
if (!signedIn(req)) return res.status(401).json({ error: 'Sign in to like this video.' });
if (!signedIn(req)) return res.status(401).json({ error: 'Sign in to like this video.' });
if (!sameSite(req, res, '/')) return undefined;
if (!sameSite(req, res, '/')) return undefined;
@@ -1454,7 +1513,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1454,7 +1513,7 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
const [m, cm, replay, saveForm, up, hist, more, agent] = await Promise.all([
const [m, cm, replay, saveForm, up, hist, more, agent] = await Promise.all([
// A private recording's moments are Live's answer to its owner only.
// A private recording's moments are Live's answer to its owner only.
Promise.resolve().then(() => (ownOnly ? asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : live.vodMemories(id))).catch(() => fail),
Promise.resolve().then(() => (ownOnly ? asPerson(req, res, (token) => live.vodMemoriesAs(token, id)) : live.vodMemories(id))).catch(() => fail),
- Promise.resolve().then(() => live.comments('vod', id)).catch(() => fail),
+ Promise.resolve().then(() => liveThread(req, res, 'vod', id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
Promise.resolve().then(() => chat.chatReplay(vod.stream_id)).catch(() => fail),
playlistForm(req, 'vod', id),
playlistForm(req, 'vod', id),
upNext(req, 'vod', id),
upNext(req, 'vod', id),
@@ -1470,16 +1529,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1470,16 +1529,8 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
if (agentSteps) mems = agentSteps.filter((s) => s.status === 'started' && Number.isFinite(Number(s.offset_seconds)))
if (agentSteps) mems = agentSteps.filter((s) => s.status === 'started' && Number.isFinite(Number(s.offset_seconds)))
.map((s) => ({ offset_seconds: Number(s.offset_seconds), description: String(s.title || '').slice(0, 120) }));
.map((s) => ({ offset_seconds: Number(s.offset_seconds), description: String(s.title || '').slice(0, 120) }));
let threadUrl = null;
let threadUrl = null;
- let commentRows = [];
- let commentsOk = false;
- if (cm !== fail) {
- try {
- threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);
- const list = Array.isArray(cm && cm.comments) ? cm.comments : [];
- commentRows = list.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20);
- commentsOk = true;
- } catch { threadUrl = null; }
- }
+ if (cm !== fail) threadUrl = httpsUrl(cm && cm.thread && cm.thread.url);
+ const commentsHtml = await liveThreadHtml(req, res, 'vod', id, watchPath, cm === fail ? null : cm);
let chatRows = null;
let chatRows = null;
if (replay !== fail) {
if (replay !== fail) {
@@ -1519,15 +1570,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
@@ -1519,15 +1570,15 @@ ${found ? sections.map((x) => (x.cards.length || kind ? html`<section class="ovv
noindex: unlisted || ownOnly || thin,
noindex: unlisted || ownOnly || thin,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
title: watchTitle(vod.title || 'Stream recording', vod.username, vod.created_at),
- styles: ['kit.css'],
+ styles: ['kit.css', 'thread.css'],
description: about,
description: about,
canonical: `${base}${watchPath}`,
canonical: `${base}${watchPath}`,
...(ownOnly || vod.is_recording ? {} : {
...(ownOnly || vod.is_recording ? {} : {
jsonLd: videoLd({ base, path: watchPath, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
jsonLd: videoLd({ base, path: watchPath, item: vod, title: vod.title || 'Stream recording', description: about, chapterList: mems }),
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
image: httpsUrl(vod.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
- scripts: ['js/video.js'],
- ...(manageable ? { localStyles: ['css/labels.css'] } : {}),
+ scripts: ['js/video.js', 'js/comments.js'],
+ localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],
player: true,
player: true,
body: html`<div class="ovv-watch ovv-watch--vod">
body: html`<div class="ovv-watch ovv-watch--vod">
<div class="ovv-col ovv-col--main">
<div class="ovv-col ovv-col--main">
@@ -1546,7 +1597,7 @@ ${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-so
@@ -1546,7 +1597,7 @@ ${vod.is_recording ? html`<p class="ovv-recording" role="status"><i class="fa-so
</div>
</div>
${manageable ? ownerPanel('vod', id, vod) : ''}
${manageable ? ownerPanel('vod', id, vod) : ''}
${watchAbout({ facts, description: vod.description || about, ai: vod.ai_overview, parts: partNav, chapters: mems, path: watchPath, tags: Array.isArray(vod.tags) ? vod.tags : [], from: channelName ? html`<p class="ovv-about-from">Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : '' })}
${watchAbout({ facts, description: vod.description || about, ai: vod.ai_overview, parts: partNav, chapters: mems, path: watchPath, tags: Array.isArray(vod.tags) ? vod.tags : [], from: channelName ? html`<p class="ovv-about-from">Streamed live on <a href="${liveChannel}">${channelName}'s OpenVibe.Live channel</a>${vod.stream_category ? html`, in ${String(vod.stream_category).charAt(0).toUpperCase()}${String(vod.stream_category).slice(1)}` : ''}.</p>` : '' })}
-${commentsOk ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
+<section class="ovv-panel ovv-comments">${commentsHtml}</section>
</div>
</div>
<aside class="ovv-col ovv-col--side">
<aside class="ovv-col ovv-col--side">
${side || ''}
${side || ''}
@@ -1593,7 +1644,7 @@ ${more || ''}
@@ -1593,7 +1644,7 @@ ${more || ''}
? live.vod(String(clip.vod_id)).then((s) => (s && s.vod) || null).catch(() => 'down')
? live.vod(String(clip.vod_id)).then((s) => (s && s.vod) || null).catch(() => 'down')
: Promise.resolve('none');
: Promise.resolve('none');
const [cm, saveForm, up, sourceVod, more] = await Promise.all([
const [cm, saveForm, up, sourceVod, more] = await Promise.all([
- Promise.resolve().then(() => live.comments('clip', id)).catch(() => null),
+ Promise.resolve().then(() => liveThread(req, res, 'clip', id)).catch(() => null),
playlistForm(req, 'clip', id),
playlistForm(req, 'clip', id),
upNext(req, 'clip', id),
upNext(req, 'clip', id),
sourceAnswer,
sourceAnswer,
@@ -1601,7 +1652,7 @@ ${more || ''}
@@ -1601,7 +1652,7 @@ ${more || ''}
]);
]);
let threadUrl = null;
let threadUrl = null;
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }
try { threadUrl = httpsUrl(cm && cm.thread && cm.thread.url); } catch { threadUrl = null; }
- const commentRows = cm && Array.isArray(cm.comments) ? cm.comments.filter((c) => c && !c.is_deleted && !c.deleted).slice(0, 20) : null;
+ const commentsHtml = await liveThreadHtml(req, res, 'clip', id, watchPath, cm);
const unlisted = clip.visibility === 'unlisted';
const unlisted = clip.visibility === 'unlisted';
// AI clips stay out of search, as on Live: noindex and no structured data.
// AI clips stay out of search, as on Live: noindex and no structured data.
@@ -1644,15 +1695,15 @@ ${more || ''}
@@ -1644,15 +1695,15 @@ ${more || ''}
noindex: unlisted || ownOnly || aiClip,
noindex: unlisted || ownOnly || aiClip,
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
...(ownOnly ? { cache: cache.htmlHeaders({ private: true }) } : {}),
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
title: watchTitle(clip.title || 'Clip', clip.source_streamer_username, clip.created_at),
- styles: ['kit.css'],
+ styles: ['kit.css', 'thread.css'],
description: clip.ai_overview || factualDescription('clip', clip),
description: clip.ai_overview || factualDescription('clip', clip),
canonical: `${base}${watchPath}`,
canonical: `${base}${watchPath}`,
...(ownOnly || aiClip ? {} : {
...(ownOnly || aiClip ? {} : {
jsonLd: videoLd({ base, path: watchPath, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: sourceVod && sourceVod !== 'down' && sourceVod.watch_id ? `/watch?v=${sourceVod.watch_id}` : (clip.vod_id ? `/v/${clip.vod_id}` : null) }),
jsonLd: videoLd({ base, path: watchPath, item: clip, title: clip.title || 'Clip', description: clip.ai_overview || factualDescription('clip', clip) || attribution, partOf: sourceVod && sourceVod !== 'down' && sourceVod.watch_id ? `/watch?v=${sourceVod.watch_id}` : (clip.vod_id ? `/v/${clip.vod_id}` : null) }),
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
image: httpsUrl(clip.thumbnail_url) || undefined, ogType: 'video.other',
}),
}),
- scripts: ['js/video.js'],
- ...(manageable ? { localStyles: ['css/labels.css'] } : {}),
+ scripts: ['js/video.js', 'js/comments.js'],
+ localStyles: ['css/comments.css', ...(manageable ? ['css/labels.css'] : [])],
player: true,
player: true,
body: html`<div class="ovv-watch ovv-watch--clip">
body: html`<div class="ovv-watch ovv-watch--clip">
<div class="ovv-col ovv-col--main">
<div class="ovv-col ovv-col--main">
@@ -1671,7 +1722,7 @@ ${channelRow(clip.source_streamer_username || clip.username, clip.source_streame
@@ -1671,7 +1722,7 @@ ${channelRow(clip.source_streamer_username || clip.username, clip.source_streame
</div>
</div>
${manageable ? ownerPanel('clip', id, clip) : ''}
${manageable ? ownerPanel('clip', id, clip) : ''}
${watchAbout({ facts, description: clip.description || clip.ai_overview || attribution, source: clipCredit, madeBy, ai: clip.ai_overview, tags: Array.isArray(clip.tags) ? clip.tags : [] })}
${watchAbout({ facts, description: clip.description || clip.ai_overview || attribution, source: clipCredit, madeBy, ai: clip.ai_overview, tags: Array.isArray(clip.tags) ? clip.tags : [] })}
-${commentRows ? html`<section class="ovv-panel ovv-comments" aria-labelledby="h-comments"><header class="ovv-panel-head"><h2 id="h-comments">Comments${commentRows.length ? html` <span class="ovv-count">${commentRows.length}</span>` : ''}</h2>${threadUrl ? html`<a class="ovv-panel-link" href="${threadUrl}">Discuss on OpenVibe.Community <i class="fa-solid fa-arrow-up-right-from-square" aria-hidden="true"></i></a>` : ''}</header>${commentRows.length ? commentRows.map(commentItem) : html`<p class="ovv-comments-empty">No comments yet.${threadUrl ? html` <a href="${threadUrl}">Start the discussion</a>.` : ''}</p>`}</section>` : ''}
+<section class="ovv-panel ovv-comments">${commentsHtml}</section>
</div>
</div>
<aside class="ovv-col ovv-col--side">
<aside class="ovv-col ovv-col--side">
${side || ''}
${side || ''}
@@ -20,11 +20,15 @@
@@ -20,11 +20,15 @@
* Origin). Reports are open to anyone who can see the video, one per person (or per keyed address) per video.
* Origin). Reports are open to anyone who can see the video, one per person (or per keyed address) per video.
*/
*/
const express = require('express');
const express = require('express');
+const rateLimit = require('express-rate-limit');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
const { sameOrigin } = require('../http/principal');
const { sameOrigin } = require('../http/principal');
const { html, raw, notice, askAgent } = require('../render/html');
const { html, raw, notice, askAgent } = require('../render/html');
const { UploadError } = require('./uploads');
const { UploadError } = require('./uploads');
const { ShareError } = require('openvibe-sdk/share');
const { ShareError } = require('openvibe-sdk/share');
+const { createCommentsClient, CommentError } = require('./comments-client');
+const { renderThread } = require('../comments/inline');
+const { multipart: commentMultipart, readCommentForm } = require('../comments/form');
const REASON_TEXT = {
const REASON_TEXT = {
child_safety: 'It sexualises or endangers a child', violence: 'Violence or a threat', harassment: 'Harassment or someone\'s private details',
child_safety: 'It sexualises or endangers a child', violence: 'Violence or a threat', harassment: 'Harassment or someone\'s private details',
@@ -164,6 +168,10 @@ function createUploadApi({ ctx, config }) {
@@ -164,6 +168,10 @@ function createUploadApi({ ctx, config }) {
function mountUploadPages(r, h) {
function mountUploadPages(r, h) {
const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton, renderVod, renderClip } = h;
const { ctx, page, notFound, signedIn, ownerOf, mustSignIn, sameSite, isStaff, visibilityOptions, visibilityLabel, durationLabel, dateLabel, httpsUrl, fileOf, hlsAttrs, grid, card, renderCaptionsVtt, renderChaptersVtt, vttTime, sendVtt, notFoundVtt, playlistForm, upNext, whatItIs, shareButton, renderVod, renderClip } = h;
const up = ctx.videoUploads;
const up = ctx.videoUploads;
+ const comments = createCommentsClient({ config: ctx.config, fetchImpl: ctx.fetchImpl || globalThis.fetch });
+ const commentLimit = rateLimit({ windowMs: 60_000, limit: 10, standardHeaders: true, legacyHeaders: false });
+ const commentSort = (req) => req.query.sort === 'new' ? 'new' : 'top';
+ const commentAfter = (req) => typeof req.query.after === 'string' && /^(?:-?\d{1,12}:)?\d{1,15}$/.test(req.query.after) ? req.query.after : null;
const cfg = ctx.config.videoUploads;
const cfg = ctx.config.videoUploads;
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
const whoOf = (req) => ({ requester: signedIn(req) ? ownerOf(req) : null, staff: isStaff(req) });
const GB = 1024 ** 3;
const GB = 1024 ** 3;
@@ -359,6 +367,34 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
@@ -359,6 +367,34 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
return row;
return row;
}
}
+ async function uploadThread(req, row, watchPath) {
+ if (row.state !== 'active' || row.status !== 'ready') return '';
+ const subject = signedIn(req) ? req.viewer.subject : null;
+ const staff = signedIn(req) && (isStaff(req) || row.owner === ownerOf(req));
+ try {
+ const answer = await comments.read(row, { subject, staff, sort: commentSort(req), after: commentAfter(req), ip: req.ip });
+ const expandedId = /^\d{1,15}$/.test(String(req.query.replies || '')) ? String(req.query.replies) : null;
+ const belongs = expandedId && answer.comments.some((c) => String(c.id) === expandedId);
+ let expandedReplies = null;
+ if (belongs) {
+ expandedReplies = [];
+ let cursor = null;
+ do {
+ const more = await comments.read(row, { subject, staff, parent: expandedId, after: cursor, limit: 100, ip: req.ip });
+ expandedReplies.push(...more.comments);
+ if (more.next_cursor === cursor) break;
+ cursor = more.next_cursor;
+ } while (cursor);
+ }
+ const shown = row.visibility === 'private' ? { ...answer, thread: {} } : answer;
+ return raw(renderThread({ answer: shown, base: watchPath, post: `/u/${row.id}/comments?sort=${commentSort(req)}`,
+ remove: `/u/${row.id}/comments/delete?sort=${commentSort(req)}`, signedIn: Boolean(subject) && answer.viewer.can_comment !== false,
+ sort: commentSort(req), replyId: req.query.reply, expandedId: belongs ? expandedId : null, expandedReplies }));
+ } catch (err) {
+ return raw(`<section class="ovc-thread" id="comments"><h2>Comments</h2><p>${err instanceof CommentError && err.status === 404 ? 'Comments are hidden here.' : 'Comments are unavailable right now.'}</p></section>`);
+ }
+ }
+
/** A query as the tail of /watch?v=<id> (its own ?v= is never carried): ?t= a moment, ?list= a playlist. */
/** A query as the tail of /watch?v=<id> (its own ?v= is never carried): ?t= a moment, ?list= a playlist. */
function queryTail(query) {
function queryTail(query) {
const qs = new URLSearchParams();
const qs = new URLSearchParams();
@@ -421,6 +457,7 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
@@ -421,6 +457,7 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
const thumb = httpsUrl(row.thumbnail_url);
const thumb = httpsUrl(row.thumbnail_url);
// The page's one address: /watch?v=<watch id> (its /u/<id> redirects here until an id exists).
// The page's one address: /watch?v=<watch id> (its /u/<id> redirects here until an id exists).
const watchPath = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
const watchPath = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
+ const commentsHtml = await uploadThread(req, row, watchPath);
if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done
if (row.status === 'processing') res.set('Refresh', '15'); // the page reloads itself until Media is done
// Playlists (kind upload, by seq): a ready video anyone else may watch can be saved, and ?list= plays the next one.
// Playlists (kind upload, by seq): a ready video anyone else may watch can be saved, and ?list= plays the next one.
const saveForm = ready && row.state === 'active' && row.visibility !== 'private' && playlistForm ? await playlistForm(req, 'upload', row.seq) : '';
const saveForm = ready && row.state === 'active' && row.visibility !== 'private' && playlistForm ? await playlistForm(req, 'upload', row.seq) : '';
@@ -445,11 +482,12 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
@@ -445,11 +482,12 @@ ${fresh.length ? html`<ul class="ovv-mylist">${fresh.map(myVideoRow)}</ul>` : ht
index: listed, noindex: !listed,
index: listed, noindex: !listed,
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
...(listed ? {} : { cache: cache.htmlHeaders({ private: true }) }),
title: row.title,
title: row.title,
- styles: ['kit.css'],
+ styles: ['kit.css', 'thread.css'],
+ localStyles: ['css/comments.css'],
description: about,
description: about,
canonical: `${ctx.config.video.url}${watchPath}`,
canonical: `${ctx.config.video.url}${watchPath}`,
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
...(listed && thumb ? { image: thumb, ogType: 'video.other' } : {}),
- scripts: ready ? ['js/video.js'] : [],
+ scripts: ready ? ['js/video.js', 'js/comments.js'] : [],
player: !!(ready && vod),
player: !!(ready && vod),
body: html`<div class="ovv-watch">
body: html`<div class="ovv-watch">
<div class="ovv-main">
<div class="ovv-main">
@@ -474,6 +512,7 @@ ${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"
@@ -474,6 +512,7 @@ ${aiDone && ai.overview ? html`<section class="ovv-panel" aria-labelledby="h-ai"
${manage && ai.overview !== row.description ? html`<form method="post" action="/u/${row.id}/manage"><input type="hidden" name="description" value="${ai.overview}"><button class="ovv-btn" type="submit">Use this as the description</button></form>` : ''}</section>` : ''}
${manage && ai.overview !== row.description ? html`<form method="post" action="/u/${row.id}/manage"><input type="hidden" name="description" value="${ai.overview}"><button class="ovv-btn" type="submit">Use this as the description</button></form>` : ''}</section>` : ''}
${aiDone && ai.segments.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${ai.segments.length} lines)</span></summary><div class="ovv-transcript-body">${ai.segments.map((x) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${x.start}">${vttTime(x.start).slice(0, 8)}</button> <span>${x.text}</span></p>`)}</div></details>` : ''}
${aiDone && ai.segments.length ? html`<details class="ovv-panel ovv-transcript"><summary>Transcript <span class="ovv-fine">(AI, ${ai.segments.length} lines)</span></summary><div class="ovv-transcript-body">${ai.segments.map((x) => html`<p><button type="button" class="ovv-seek" data-ovv-seek="${x.start}">${vttTime(x.start).slice(0, 8)}</button> <span>${x.text}</span></p>`)}</div></details>` : ''}
${manage && ready && ai && (ai.status === 'queued' || ai.status === 'running') ? html`<p class="ovv-fine">Captions, chapters and a summary are being made by OpenVibe.AI.</p>` : ''}
${manage && ready && ai && (ai.status === 'queued' || ai.status === 'running') ? html`<p class="ovv-fine">Captions, chapters and a summary are being made by OpenVibe.AI.</p>` : ''}
+<section class="ovv-panel ovv-comments">${commentsHtml}</section>
</div>
</div>
<aside class="ovv-side">
<aside class="ovv-side">
${inList.html}
${inList.html}
@@ -516,6 +555,42 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
@@ -516,6 +555,42 @@ ${!own && ready ? html`<details class="ovv-panel ovv-report"><summary>Report thi
return x && x.ai.chapters.length ? sendVtt(res, renderChaptersVtt(x.ai.chapters.map((c) => ({ offset_seconds: c.start, description: c.title })), x.row.duration_seconds)) : notFoundVtt(req, res);
return x && x.ai.chapters.length ? sendVtt(res, renderChaptersVtt(x.ai.chapters.map((c) => ({ offset_seconds: c.start, description: c.title })), x.row.duration_seconds)) : notFoundVtt(req, res);
});
});
+ const commentFailure = (req, res, row, err) => page(req, res, { title: 'Comment not sent',
+ body: html`<h1>Comment not sent</h1><p>${err.message}</p><p><a href="${row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`}#comments">Go back</a></p>`,
+ }, err.status && err.status >= 400 && err.status <= 599 ? err.status : 422);
+ const commentReturn = (row, sort) => {
+ const path = row.watch_id ? `/watch?v=${row.watch_id}` : `/u/${row.id}`;
+ return `${path}${path.includes('?') ? '&' : '?'}sort=${sort}#comments`;
+ };
+ r.post('/u/:id/comments', mustSignIn('here', 'Sign in to comment.'), commentLimit, commentMultipart, async (req, res) => {
+ const row = await visibleUpload(req, res, req.params.id);
+ if (!row) return undefined;
+ if (!sameSite(req, res, commentReturn(row, commentSort(req)))) return undefined;
+ if (row.state !== 'active' || row.status !== 'ready') return notFound(req, res);
+ try {
+ const form = await readCommentForm(req);
+ const attachments = [];
+ for (const file of form.files) {
+ const out = await comments.upload(row, req.viewer.subject, file, req.ip);
+ if (!out || !out.attachment) throw new CommentError(503, 'The attachment could not be saved');
+ attachments.push(out.attachment);
+ }
+ await comments.add(row, req.viewer.subject, { message: form.message, parent_id: form.parent_id, attachments }, req.ip);
+ return res.redirect(303, commentReturn(row, commentSort(req)));
+ } catch (err) { return commentFailure(req, res, row, err); }
+ });
+ r.post('/u/:id/comments/delete', mustSignIn('here', 'Sign in to remove a comment.'), commentLimit, async (req, res) => {
+ const row = await visibleUpload(req, res, req.params.id);
+ if (!row) return undefined;
+ if (!sameSite(req, res, commentReturn(row, commentSort(req)))) return undefined;
+ const id = String(req.body.comment_id || '');
+ if (!/^\d{1,15}$/.test(id)) return notFound(req, res);
+ try {
+ await comments.remove(row, req.viewer.subject, isStaff(req) || row.owner === ownerOf(req), id, req.ip);
+ return res.redirect(303, commentReturn(row, commentSort(req)));
+ } catch (err) { return commentFailure(req, res, row, err); }
+ });
+
r.post('/u/:id/manage', mustSignIn('here'), async (req, res) => {
r.post('/u/:id/manage', mustSignIn('here'), async (req, res) => {
const row = await up.byId(req.params.id);
const row = await up.byId(req.params.id);
const who = whoOf(req);
const who = whoOf(req);
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -0,0 +1,61 @@
+'use strict';
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const posts = [];
+ const live = http.createServer((req, res) => {
+ const path = new URL(req.url, 'http://local').pathname;
+ const json = (body) => { res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };
+ if (path === '/api/vods/101') return json({ vod: { id: 101, title: 'A recording', visibility: 'public', username: 'maker',
+ created_at: '2026-11-10T12:00:00Z', duration_seconds: 60, playback_url: 'https://openvibe.media/v/101' }, clips: [] });
+ if (path === '/api/comments/vod/101' && req.method === 'POST') {
+ const chunks = [];
+ req.on('data', (chunk) => chunks.push(chunk));
+ req.on('end', () => { posts.push(JSON.parse(Buffer.concat(chunks).toString('utf8'))); res.statusCode = 201; json({ comment: { id: 8 } }); });
+ return;
+ }
+ if (path === '/api/comments/vod/101') return json({ thread: { id: 'cth_1234567890123456789012', url: 'https://openvibe.community/c/cth_1234567890123456789012' },
+ comments: [{ id: 7, message: 'Good video', username: 'reader', display_name: 'Reader', created_at: '2026-11-10T12:00:00Z', reply_count: 0 }], viewer: { can_comment: true } });
+ return json({});
+ });
+ await new Promise((resolve) => live.listen(0, '127.0.0.1', resolve));
+ const url = `http://127.0.0.1:${live.address().port}`;
+ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video', MEDIAHUB_LIVE_URL: url,
+ MEDIAHUB_LIVE_PUBLIC_URL: 'https://openvibe.live', MEDIAHUB_CHAT_URL: url, MEDIAHUB_SEARCH_URL: url, MEDIAHUB_VIDEO_SEARCH: 'off' } });
+ const videoRequest = (path, { method = 'GET', headers = {}, body = null } = {}) => new Promise((resolve, reject) => {
+ const target = new URL(t.base);
+ const request = http.request({ hostname: target.hostname, port: target.port, path, method,
+ headers: { Host: 'openvibe.video', ...(body ? { 'Content-Length': body.length } : {}), ...headers } }, (response) => {
+ const chunks = [];
+ response.on('data', (chunk) => chunks.push(chunk));
+ response.on('end', () => resolve({ status: response.statusCode, text: Buffer.concat(chunks).toString('utf8') }));
+ });
+ request.on('error', reject);
+ request.end(body);
+ });
+ try {
+ await check('recording renders the Community thread and a signed-out sign-in action', async () => {
+ const r = await videoRequest('/v/101');
+ assert.equal(r.status, 200, `${t.logs().slice(-1200)}\n${r.text.slice(0, 400)}`);
+ assert.match(r.text, /class="ovc-thread"/);
+ assert.match(r.text, /Good video/);
+ assert.match(r.text, /Sign in to comment/);
+ });
+ await check('signed-in recording shows the shared composer and posts through Live', async () => {
+ const person = t.network.addUser('threadwriter');
+ const cookie = t.signIn(person);
+ const page = await videoRequest('/v/101', { headers: { Cookie: cookie } });
+ assert.equal(page.status, 200, page.text.slice(0, 300));
+ assert.match(page.text, /data-ovt-composer/);
+ const boundary = 'inline-thread-test';
+ const body = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message"\r\n\r\nA new comment\r\n--${boundary}--\r\n`);
+ const sent = await videoRequest('/v/101/comments?sort=new', { method: 'POST', body,
+ headers: { Cookie: cookie, Origin: 'https://openvibe.video', 'Sec-Fetch-Site': 'same-origin', 'Content-Type': `multipart/form-data; boundary=${boundary}` } });
+ assert.equal(sent.status, 303, `${sent.text.slice(0, 300)}\n${t.logs().slice(-600)}`);
+ assert.deepEqual(posts[0], { message: 'A new comment', parent_id: null, attachments: [] });
+ });
+ } finally { await t.close(); await new Promise((resolve) => live.close(resolve)); }
+ done();
+})().catch((err) => { console.error(err); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { renderThread } = require('../server/comments/inline');
+
+const answer = {
+ thread: { id: 'cth_1234567890123456789012' }, viewer: { can_comment: true },
+ comments: [{ id: 10, message: 'A <picture>', author: { username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.network/avatar/alice' },
+ created_at: '2026-11-10T12:00:00Z', reply_count: 3, can_delete: true,
+ attachments: [{ id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'image.png', media_type: 'image/png' },
+ { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPR', name: 'notes.pdf', media_type: 'application/pdf' }],
+ replies: [{ id: 11, parent_id: 10, reply_to_id: 10, reply_to: { display_name: 'Alice' }, message: 'First', author: { username: 'bob', display_name: 'Bob' } },
+ { id: 12, parent_id: 10, reply_to_id: 11, reply_to: { display_name: 'Bob' }, message: 'Second', author: { username: 'alice', display_name: 'Alice' } }],
+ }],
+};
+const options = { answer, base: '/watch?v=12345678901', post: '/u/vup_1/comments', remove: '/u/vup_1/comments/delete', sort: 'top' };
+const signedOut = renderThread({ ...options, signedIn: false });
+assert.match(signedOut, /class="ovc-thread"/);
+assert.match(signedOut, /Sign in to comment/);
+assert.doesNotMatch(signedOut, /data-ovt-composer/);
+assert.match(signedOut, /sort=new/);
+assert.match(signedOut, /1 more reply/);
+assert.match(signedOut, /href="https:\/\/openvibe.network\/@alice"/);
+assert.match(signedOut, /class="ovc-target"/);
+assert.match(signedOut, /<picture>/);
+assert.match(signedOut, /<img src="https:\/\/openvibe.media\/o\/med_/);
+assert.match(signedOut, /notes.pdf/);
+assert.match(signedOut, /%2Fwatch%3Fv%3D12345678901%26sort%3Dtop/);
+const signedIn = renderThread({ ...options, signedIn: true, replyId: 11 });
+assert.match(signedIn, /data-ovt-composer/);
+assert.match(signedIn, /name="parent_id" value="11"/);
+assert.match(signedIn, /data-ovt-files/);
+assert.match(signedIn, /name="comment_id" value="10"/);
+assert.match(renderThread({ ...options, answer: { ...answer, comments: [] }, signedIn: false }), /Be the first to comment/);
+process.stdout.write('Inline thread renders replies, targets, attachments, order, composer and signed-out state\n');
@@ -42,7 +42,8 @@ async function boot(opts = {}) {
@@ -42,7 +42,8 @@ async function boot(opts = {}) {
const { createStore } = require('../../server/db');
const { createStore } = require('../../server/db');
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
const store = createStore(testdb.db, { now: opts.now });
const store = createStore(testdb.db, { now: opts.now });
- const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true });
+ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, limitsNow: opts.limitsNow, callerLimits: opts.callerLimits === true,
+ fetchImpl: opts.fetchImpl });
await built.ctx.keys.ensure();
await built.ctx.keys.ensure();
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
const base = `http://127.0.0.1:${server.address().port}`;
@@ -18,6 +18,7 @@ const { createCommentsClient } = require('../server/pics/comments-client');
@@ -18,6 +18,7 @@ const { createCommentsClient } = require('../server/pics/comments-client');
assert.equal(form.get('file').name, 'look.png');
assert.equal(form.get('file').name, 'look.png');
return { status: 201, json: async () => ({ attachment: { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP7' } }) };
return { status: 201, json: async () => ({ attachment: { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP7' } }) };
}
}
+ if (init.method === 'DELETE') return { status: 200, json: async () => ({ ok: true }) };
if (init.method === 'GET') return { status: 200, json: async () => ({ comments: [{ id: 4, message: 'hello' }], next_cursor: null }) };
if (init.method === 'GET') return { status: 200, json: async () => ({ comments: [{ id: 4, message: 'hello' }], next_cursor: null }) };
return { status: 201, json: async () => ({ comment: { id: 5, parent_id: 4 } }) };
return { status: 201, json: async () => ({ comment: { id: 5, parent_id: 4 } }) };
};
};
@@ -32,6 +33,10 @@ const { createCommentsClient } = require('../server/pics/comments-client');
@@ -32,6 +33,10 @@ const { createCommentsClient } = require('../server/pics/comments-client');
assert.deepEqual(JSON.parse(calls[3].init.body), { message: 'reply', parent_id: 4, attachments: [] });
assert.deepEqual(JSON.parse(calls[3].init.body), { message: 'reply', parent_id: 4, attachments: [] });
const uploaded = await client.upload(picture, token, Buffer.from('look'), 'look.png', 'image/png');
const uploaded = await client.upload(picture, token, Buffer.from('look'), 'look.png', 'image/png');
assert.equal(uploaded.status, 201);
assert.equal(uploaded.status, 201);
+ const removed = await client.remove('4', token);
+ assert.equal(removed.status, 200);
+ await client.read(picture, token, { sort: 'top' });
+ assert.ok(calls.some((c) => c.url.includes('sort=top')));
assert.ok(calls.every((c) => c.init.headers.Authorization === `Bearer ${token}`));
assert.ok(calls.every((c) => c.init.headers.Authorization === `Bearer ${token}`));
process.stdout.write('Pics comments client read, reply, and upload pass\n');
process.stdout.write('Pics comments client read, reply, and upload pass\n');
})().catch((err) => { process.stderr.write(`${err.stack}\n`); process.exitCode = 1; });
})().catch((err) => { process.stderr.write(`${err.stack}\n`); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const http = require('node:http');
+const sharp = require('sharp');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const posts = [];
+ const fetchImpl = async (url, init) => {
+ if (!url.startsWith('https://openvibe.community/api/v1/comments')) return fetch(url, init);
+ if (url.endsWith('/threads/resolve')) return Response.json({ thread: { id: 'cth_1234567890123456789012' } });
+ if (init.method === 'GET') {
+ const q = new URL(url).searchParams;
+ if (q.has('parent')) return Response.json({ comments: [{ id: 3, parent_id: 1, message: 'third reply', author: { username: 'reader', display_name: 'Reader' } }], next_cursor: null });
+ return Response.json({ thread: { id: 'cth_1234567890123456789012', url: 'https://openvibe.community/c/cth_1234567890123456789012' },
+ comments: [{ id: 1, message: 'On the picture', author: { username: 'reader', display_name: 'Reader' }, created_at: '2026-11-10T12:00:00Z',
+ reply_count: 3, replies: [{ id: 2, parent_id: 1, reply_to_id: 1, reply_to: { display_name: 'Reader' }, message: 'first reply' }],
+ attachments: [{ id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP6', name: 'look.png', media_type: 'image/png' }] }],
+ viewer: { can_comment: true }, next_cursor: null });
+ }
+ if (init.method === 'POST') { posts.push(JSON.parse(init.body)); return Response.json({ comment: { id: 4 } }, { status: 201 }); }
+ throw new Error(`Unexpected Community call: ${url}`);
+ };
+ const t = await boot({ fetchImpl });
+ const person = t.network.addUser('picturewriter');
+ const request = (path, { method = 'GET', body = null, headers = {}, signedIn = false } = {}) => new Promise((resolve, reject) => {
+ const target = new URL(t.base);
+ const req = http.request({ hostname: target.hostname, port: target.port, path, method,
+ headers: { Host: 'openvibe.pics', ...(signedIn ? { Cookie: t.signIn(person) } : {}), ...(body ? { 'Content-Length': body.length } : {}), ...headers } }, (res) => {
+ const chunks = [];
+ res.on('data', (part) => chunks.push(part));
+ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, text, location: res.headers.location }); });
+ });
+ req.on('error', reject);
+ req.end(body);
+ });
+ try {
+ const photo = await sharp({ create: { width: 32, height: 32, channels: 3, background: '#a84' } }).jpeg().toBuffer();
+ const uploaded = await request('/api/v1/images', { method: 'POST', body: photo, signedIn: true,
+ headers: { Origin: 'https://openvibe.pics', 'Content-Type': 'image/jpeg' } });
+ assert.equal(uploaded.status, 201, uploaded.text.slice(0, 400));
+ const slug = JSON.parse(uploaded.text).slug;
+ await check('picture page renders comments, replies, target and attachment for signed-out visitors', async () => {
+ const page = await request(`/i/${slug}`);
+ assert.equal(page.status, 200, page.text.slice(0, 400));
+ for (const value of ['ovc-thread', 'On the picture', 'first reply', 'ovc-target', 'ovc-more', 'ovc-attach', 'Sign in to comment'])
+ assert.ok(page.text.includes(value), value);
+ });
+ await check('picture composer posts through the Pics proxy to Community', async () => {
+ const page = await request(`/i/${slug}`, { signedIn: true });
+ assert.match(page.text, /data-ovt-composer/);
+ const boundary = 'pics-comment-test';
+ const body = Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="message"\r\n\r\nA new comment\r\n--${boundary}--\r\n`);
+ const sent = await request(`/i/${slug}/comments`, { method: 'POST', body, signedIn: true,
+ headers: { Origin: 'https://openvibe.pics', 'Sec-Fetch-Site': 'same-origin', 'Content-Type': `multipart/form-data; boundary=${boundary}` } });
+ assert.equal(sent.status, 303, sent.text.slice(0, 400));
+ assert.deepEqual(posts[0], { message: 'A new comment', parent_id: null, attachments: [] });
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((err) => { console.error(err); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { createCommentsClient } = require('../server/video/comments-client');
+
+(async () => {
+ const calls = [];
+ const id = 'vup_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ const row = { id, title: 'Unlisted test' };
+ const subject = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+ const ref = { service: 'media-hub', type: 'video', id, label: row.title };
+ const reply = (body, status = 200) => ({ ok: status < 300, status, json: async () => body });
+ const fetchImpl = async (url, init) => {
+ calls.push({ url, init });
+ if (url === 'http://network/oauth/token') return reply({ access_token: 'service-token', expires_in: 300 });
+ assert.equal(init.headers.Authorization, 'Bearer service-token');
+ if (url.endsWith('/threads/resolve')) {
+ assert.deepEqual(JSON.parse(init.body).ref, ref);
+ return reply({ thread: { access_id: 'cth_1234567890123456789012' } });
+ }
+ if (url.endsWith('/attachments')) {
+ assert.deepEqual(JSON.parse(init.body.get('ref')), ref);
+ return reply({ attachment: { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP6' } }, 201);
+ }
+ if (url.endsWith('/123')) return reply({ comment: { author: { subject } }, thread: { ref } });
+ if (url.includes('/threads/cth_') && init.method === 'GET') return reply({ thread: { ref }, comments: [], next_cursor: null, viewer: { can_comment: true } });
+ if (init.method === 'DELETE') return reply({ ok: true });
+ if (init.method === 'POST') return reply({ comment: { id: 123 } }, 201);
+ throw new Error(url);
+ };
+ const client = createCommentsClient({ config: { networkInternalUrl: 'http://network', oauth: { clientId: 'media-hub', clientSecret: 'test' } }, fetchImpl });
+ const answer = await client.read(row, { subject, sort: 'top' });
+ assert.deepEqual(answer.comments, []);
+ assert.ok(calls.some((c) => c.url.includes('sort=top')));
+ await client.upload(row, subject, new File(['image'], 'look.png', { type: 'image/png' }));
+ await client.add(row, subject, { message: 'hello' });
+ await client.remove(row, subject, true, '123');
+ assert.ok(calls.some((c) => c.init.headers['X-OV-Subject'] === subject));
+ assert.ok(calls.some((c) => c.init.headers['X-OV-Staff'] === '1'));
+ console.log('Video Community proxy read, upload, post and scoped removal pass');
+})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -80,9 +80,12 @@ function startLive() {
@@ -80,9 +80,12 @@ function startLive() {
};
};
const comments101 = [
const comments101 = [
- { id: 'c1', message: 'Nice <b>stream</b>!', display_name: 'Commenter', username: 'commenter', created_at: '2026-10-12T10:00:00.000Z', reply_count: 3, is_deleted: 0, deleted: false },
- { id: 'c2', message: 'Agreed', display_name: null, username: 'other_user', created_at: '2026-10-12T10:05:00.000Z', reply_count: 0, is_deleted: 0, deleted: false },
- { id: 'c3', message: '', display_name: null, username: null, created_at: '2026-10-12T10:10:00.000Z', reply_count: 0, is_deleted: 1, deleted: true },
+ { id: 1, message: 'Nice <b>stream</b>!', display_name: 'Commenter', username: 'commenter', created_at: '2026-10-12T10:00:00.000Z', reply_count: 3, is_deleted: 0, deleted: false,
+ attachments: [{ id: 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'look.png', media_type: 'image/png' }],
+ replies: [{ id: 4, message: 'First reply', display_name: 'Alice', username: 'alice', created_at: '2026-10-12T10:01:00.000Z', reply_to_id: 1, reply_to: { display_name: 'Commenter' } },
+ { id: 5, message: 'Second reply', display_name: 'Bob', username: 'bob', created_at: '2026-10-12T10:02:00.000Z', reply_to_id: 4, reply_to: { display_name: 'Alice' } }] },
+ { id: 2, message: 'Agreed', display_name: null, username: 'other_user', created_at: '2026-10-12T10:05:00.000Z', reply_count: 0, is_deleted: 0, deleted: false },
+ { id: 3, message: '', display_name: null, username: null, created_at: '2026-10-12T10:10:00.000Z', reply_count: 0, is_deleted: 1, deleted: true },
];
];
const chatMessages101 = [
const chatMessages101 = [
{ id: 'm1', timestamp: '2026-10-12 09:29:59', username: 'early', display_name: 'Early', message: 'EARLY_MSG_XYZ', message_type: 'chat', user_id: 900001, anon_id: 'anon-PRIVATE-1', metadata: { note: 'meta-PRIVATE-1' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
{ id: 'm1', timestamp: '2026-10-12 09:29:59', username: 'early', display_name: 'Early', message: 'EARLY_MSG_XYZ', message_type: 'chat', user_id: 900001, anon_id: 'anon-PRIVATE-1', metadata: { note: 'meta-PRIVATE-1' }, profile_color: '#PRIVATE-COLOR', role: 'PRIVATE-ROLE' },
@@ -767,7 +770,7 @@ function countOccurrences(haystack, needle) {
@@ -767,7 +770,7 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('1005 messages'), 'the Download menu counts every message');
assert.ok(r.text.includes('1005 messages'), 'the Download menu counts every message');
});
});
- await check('the VOD page shows the newest comments and the Community link', async () => {
+ await check('the VOD page renders replies, targets, attachments, order and the Community link', async () => {
const r = await getVideo('/v/101');
const r = await getVideo('/v/101');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.ok(r.text.includes('Comments'));
assert.ok(r.text.includes('Comments'));
@@ -775,17 +778,21 @@ function countOccurrences(haystack, needle) {
@@ -775,17 +778,21 @@ function countOccurrences(haystack, needle) {
assert.ok(r.text.includes('other_user'), 'username fallback for the author');
assert.ok(r.text.includes('other_user'), 'username fallback for the author');
assert.ok(r.text.includes('Nice <b>stream</b>!'), 'comment text is escaped');
assert.ok(r.text.includes('Nice <b>stream</b>!'), 'comment text is escaped');
assert.ok(!r.text.includes('Nice <b>stream</b>!'), 'raw comment HTML never appears');
assert.ok(!r.text.includes('Nice <b>stream</b>!'), 'raw comment HTML never appears');
- assert.ok(r.text.includes('3 replies'), 'reply count when present');
- assert.ok(r.text.includes('<time class="ovv-comment-date"'), 'a relative date is shown');
- assert.strictEqual(countOccurrences(r.text, 'class="ovv-comment"'), 2, 'the deleted comment is not rendered');
+ assert.ok(r.text.includes('1 more reply'), 'the remaining replies load separately');
+ assert.ok(r.text.includes('class="ovc-target"'), 'the exact reply target is shown');
+ assert.ok(r.text.includes('class="ovc-attach ovc-attach--image"'), 'the picture attachment is shown');
+ assert.ok(r.text.includes('<time datetime='), 'a relative date is shown');
+ assert.strictEqual(countOccurrences(r.text, 'class="ovc-comment"'), 5, 'top-level comments and first replies are rendered');
+ assert.ok(r.text.includes('class="ovc-order"'), 'Top and Newest are server-rendered');
+ assert.ok(r.text.includes('Sign in to comment.'), 'the signed-out composer spot has a sign-in action');
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('Discuss on OpenVibe.Community'));
assert.ok(r.text.includes('href="https://openvibe.community/t/vod-101"'));
assert.ok(r.text.includes('href="https://openvibe.community/t/vod-101"'));
});
});
- await check('a VOD with no comments shows the empty line and the link', async () => {
+ await check('a VOD with no comments uses the kit empty state and keeps the Community link', async () => {
const r = await getVideo('/v/102');
const r = await getVideo('/v/102');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.ok(r.text.includes('No comments yet.'));
+ assert.ok(r.text.includes('Be the first to comment'));
assert.ok(r.text.includes('href="https://openvibe.community/t/vod-102"'));
assert.ok(r.text.includes('href="https://openvibe.community/t/vod-102"'));
});
});