openvibers/openvibe.mediahub Commits
Connect Video and Pics to shared comments (#397)
Changes
@@@ -26,15 +26,25 @@if (discussion) discussion.addEventListener('click', async function () {var box = document.querySelector('[data-comments-list]'); discussion.disabled = true;try {-- var response = await fetch('https://openvibe.community/api/v1/comments/threads/resolve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref: { service: 'pics', type: 'picture', id: discussion.dataset.comments } }) });-- if (!response.ok) throw new Error('Discussion is unavailable. Try again later.');-- var result = await response.json(), thread = result.thread;-- if (!thread || !/^cth_[A-Za-z0-9_-]+$/.test(thread.id)) throw new Error('Discussion is unavailable.');-- var listing = await fetch('https://openvibe.community/api/v1/comments/threads/' + encodeURIComponent(thread.id));-- if (!listing.ok) throw new Error('Comments could not be loaded.');-- var data = await listing.json(); box.replaceChildren();-- (data.comments || []).forEach(function (comment) { var p = document.createElement('p'); p.textContent = comment.message; box.appendChild(p); });-- var link = document.createElement('a'); link.href = 'https://openvibe.community/c/' + encodeURIComponent(thread.id); link.textContent = 'Join the discussion on Community'; box.appendChild(link);++ var response = await fetch('/api/v1/images/' + encodeURIComponent(discussion.dataset.comments) + '/comments?sort=old', { credentials: 'same-origin' });++ if (!response.ok) throw new Error('Comments could not be loaded.');++ var data = await response.json(); box.replaceChildren();++ function show(comment, parent) {++ var item = document.createElement('article');++ var text = document.createElement('p'); text.textContent = comment.deleted ? 'Comment removed' : comment.message; item.appendChild(text);++ (comment.attachments || []).forEach(function (file) {++ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(file.id || '')) return;++ var link = document.createElement('a'); link.href = 'https://openvibe.media/o/' + file.id;++ link.textContent = file.name || 'Attached file'; link.rel = 'noopener noreferrer'; item.appendChild(link);++ });++ (comment.replies || []).forEach(function (reply) { show(reply, item); });++ parent.appendChild(item);++ }++ (data.comments || []).forEach(function (comment) { show(comment, box); });++ if (data.thread && /^cth_[A-Za-z0-9_-]+$/.test(data.thread.id || '')) {++ var link = document.createElement('a'); link.href = 'https://openvibe.community/c/' + encodeURIComponent(data.thread.id);++ link.textContent = 'Join the discussion on Community'; box.appendChild(link);++ }} catch (error) { box.textContent = error.message; }finally { discussion.disabled = false; }});@@@ -30,12 +30,14 @@ const { sameOrigin } = require('../http/principal');const store = require('./store');const contentLabels = require('../labels');const gallery = require('./gallery');++const { createCommentsClient } = require('./comments-client');function createPicsApi(ctx) {const { config, s, principal, limits } = ctx;const pics = ctx.pics;const cfg = config.pics;const r = asyncRouter();++ const comments = createCommentsClient({ fetchImpl: ctx.fetchImpl || globalThis.fetch });const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });@@@ -138,6 +140,30 @@ function createPicsApi(ctx) {r.use(express.json({ limit: '64kb' }));++ const commentAnswer = (res, result) => res.status(result.status).json(result.body);++ r.get('/images/:slug/comments', async (req, res) => {++ const row = await load(req, res);++ 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) => {++ const row = await load(req, res);++ if (!row) return;++ const b = req.body || {};++ if (!Array.isArray(b.attachments || []) || (b.attachments || []).length > 4)++ return problem(req, res, 400, 'pics.comment.attachments', 'Attach up to four files');++ return commentAnswer(res, await comments.add(row.id, req.viewer.token,++ { 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) => {++ 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.get('/images/:slug/bytes', person, async (req, res) => {const row = await load(req, res);if (!row) return;nnew file mode 100644@@@ -0,0 +1,50 @@++'use strict';++++// Pics keeps picture visibility and the person's session; Community owns the discussion.++const ORIGIN = 'https://openvibe.community';++++function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {++ async function call(method, path, { token = null, body } = {}) {++ const form = body instanceof FormData;++ let response;++ try {++ response = await fetchImpl(`${ORIGIN}/api/v1/comments${path}`, {++ method, redirect: 'error', signal: AbortSignal.timeout(form ? 120_000 : 5000),++ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}),++ ...(body && !form ? { 'Content-Type': 'application/json' } : {}) },++ body: form ? body : body ? JSON.stringify(body) : undefined,++ });++ } catch { return { status: 503, body: { error: 'Comments are unavailable right now' } }; }++ const data = await response.json().catch(() => null);++ return { status: response.status, body: data || { error: 'Comments are unavailable right now' } };++ }++ const ref = (id) => ({ service: 'pics', type: 'picture', id: String(id) });++ async function thread(id, token) {++ const result = await call('POST', '/threads/resolve', { token, body: { ref: ref(id) } });++ return result.status < 300 && result.body.thread ? result.body.thread : result;++ }++ async function read(id, token, query = {}) {++ 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.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 });++ }++ async function add(id, token, body) {++ const found = await thread(id, token);++ if (!found.id) return found;++ return await call('POST', `/threads/${encodeURIComponent(found.id)}/comments`, { token, body });++ }++ async function upload(id, token, buffer, name, mime) {++ const form = new FormData();++ form.set('ref', JSON.stringify(ref(id)));++ 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 };++}++++module.exports = { createCommentsClient };@@@ -386,6 +386,7 @@ ${raw(showcase.network({const holding = owner ? await store.albumsHolding(s, row.id) : [];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));// ?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;@@@ -418,7 +419,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>--${gallery.isPublic(row) ? 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.id}">${glyph('chat', 17)}<span>Show the discussion</span></button></div><div class="ovp-comments-list" data-comments-list aria-live="polite"></div></section>` : ''}++${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>` : ''}<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">@@@ -23,10 +23,11 @@ const express = require('express');const rateLimit = require('express-rate-limit');const contracts = require('openvibe-contracts');const { asyncRouter } = require('../http/router');--const { USERNAME_RE } = require('./live-client');++const { sameOrigin } = require('../http/principal');++const { USERNAME_RE, isId, LiveRefusedError } = require('./live-client');function createVideoApiRoutes(ctx) {-- const { config, playlists, principal, limits, identity } = ctx;++ const { config, playlists, principal, limits, identity, live } = ctx;const r = asyncRouter();const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });const videoSite = 'openvibe.video';@@@ -39,6 +40,53 @@ function createVideoApiRoutes(ctx) {const read = principal.requireCapability('video.playlist.read', { origin: config.video.url, site: videoSite });const manage = principal.requireCapability('video.playlist.manage', { origin: config.video.url, site: videoSite });++ const commentTarget = (req, res) => {++ const kind = String(req.params.kind || '');++ const id = String(req.params.id || '');++ if (!['vod', 'clip'].includes(kind) || !isId(id)) { problem(req, res, 400, 'video.comment.invalid_item', 'Choose a VOD or clip'); return null; }++ return { kind, id };++ };++ const commentPerson = (req, res) => {++ if (req.viewer.kind !== 'user' || !req.viewer.token) { problem(req, res, 401, 'video.comment.sign_in', 'Sign in to comment'); return null; }++ if (!sameOrigin(req, config.video.url)) { problem(req, res, 403, 'request.cross_site', 'Post from openvibe.video'); return null; }++ return req.viewer.token;++ };++ const commentError = (req, res, err) => problem(req, res, err instanceof LiveRefusedError ? err.status : 502,++ 'video.comment.unavailable', err instanceof LiveRefusedError ? err.message : 'Comments are unavailable right now');++++ r.get('/comments/:kind/:id', async (req, res) => {++ const target = commentTarget(req, res);++ if (!target) return;++ try {++ const query = { sort: req.query.sort, offset: req.query.offset, limit: req.query.limit };++ const out = req.viewer.kind === 'user' && req.viewer.token++ ? await live.commentsPageAs(req.viewer.token, target.kind, target.id, query)++ : await live.commentsPage(target.kind, target.id, query);++ if (!out) return problem(req, res, 404, 'video.comment.not_found', 'Video not found');++ return res.json(out);++ } catch (err) { return commentError(req, res, err); }++ });++ r.post('/comments/:kind/:id', async (req, res) => {++ const target = commentTarget(req, res);++ const token = target && commentPerson(req, res);++ if (!token) return;++ const b = req.body || {};++ if (!Array.isArray(b.attachments || []) || (b.attachments || []).length > 4) return problem(req, res, 400, 'video.comment.attachments', 'Attach up to four files');++ try { return res.status(201).json(await live.postCommentAs(token, target.kind, target.id,++ { message: b.message, parent_id: b.parent_id || null, attachments: b.attachments || [] })); }++ catch (err) { return commentError(req, res, err); }++ });++ const fileBody = express.raw({ type: 'application/octet-stream', limit: '16mb' });++ r.post('/comments/:kind/:id/attachments', fileBody, async (req, res) => {++ const target = commentTarget(req, res);++ const token = target && commentPerson(req, res);++ if (!token) return;++ if (!Buffer.isBuffer(req.body) || !req.body.length) return problem(req, res, 400, 'video.comment.file', 'Choose a file');++ try { return res.status(201).json(await live.uploadCommentAttachmentAs(token, target.kind, target.id, req.body,++ String(req.get('x-file-name') || 'file').slice(0, 255), String(req.get('x-file-mime') || 'application/octet-stream').slice(0, 100))); }++ catch (err) { return commentError(req, res, err); }++ });++/** The owner string this principal acts as, or null for a signed-out caller. */const requesterOf = (req) => {const p = req.principal;@@@ -134,15 +134,15 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = consoleres = await fetchImpl(`${config.video.liveUrl}${path}`, {method,headers: {-- Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}),++ Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),// Live limits clip-making per address too; every call from here comes from this server, so the// person's own address is passed (Live reads the first X-Forwarded-For entry)....(viewerIp ? { 'X-Forwarded-For': viewerIp } : {}),},-- body: body ? JSON.stringify(body) : undefined,++ body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,credentials: 'omit',cache: 'no-store',-- signal: AbortSignal.timeout(TIMEOUT_MS * 2),++ signal: AbortSignal.timeout(body instanceof FormData ? 120_000 : TIMEOUT_MS * 2),});} catch (err) {log.warn('[OpenVibe.Video] Live did not answer an owner call', err && err.name);@@@ -259,6 +259,30 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = consolecomments(kind, id) {return getJson(`/api/comments/${kind}/${id}`);},++ commentsPage(kind, id, query = {}) {++ const qs = new URLSearchParams();++ if (query.sort === 'old') qs.set('sort', 'old');++ if (query.offset != null) qs.set('offset', String(query.offset));++ if (query.limit != null) qs.set('limit', String(query.limit));++ return getJson(`/api/comments/${kind}/${id}${qs.size ? `?${qs}` : ''}`);++ },++ commentsPageAs(token, kind, id, query = {}) {++ const qs = new URLSearchParams();++ if (query.sort === 'old') qs.set('sort', 'old');++ if (query.offset != null) qs.set('offset', String(query.offset));++ if (query.limit != null) qs.set('limit', String(query.limit));++ return asPerson(token, 'GET', `/api/comments/${kind}/${id}${qs.size ? `?${qs}` : ''}`);++ },++ async postCommentAs(token, kind, id, body) {++ const out = await asPerson(token, 'POST', `/api/comments/${kind}/${id}`, body);++ for (const key of [...cache.keys()]) if (key.includes(`/api/comments/${kind}/${id}`)) cache.delete(key);++ return out;++ },++ uploadCommentAttachmentAs(token, kind, id, buffer, name, mime) {++ const form = new FormData();++ form.set('file', new Blob([buffer], { type: mime || 'application/octet-stream' }), name || 'file');++ return asPerson(token, 'POST', `/api/comments/${kind}/${id}/attachments`, form);++ },isId,isUsername: (u) => USERNAME_RE.test(String(u || '')),};@@@ -401,14 +401,18 @@ function chatMessageRows(vod, messages, limit = 1000) {return { rows: rows.slice(0, limit), leftOut };}--function commentItem(c) {++function commentItem(c, depth = 0) {const name = c.display_name || c.username || '';const text = c.message == null ? '' : String(c.message);const replies = Number(c.reply_count) || 0;++ const files = (c.attachments || []).filter((a) => /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(a.id || ''))).slice(0, 4);++ const shownReplies = depth ? [] : (c.replies || []).slice(0, 2);return html`<article class="ovv-comment"><header class="ovv-comment-header"><span class="ovv-comment-author">${name}</span>${c.created_at ? html`<time class="ovv-comment-date" datetime="${c.created_at}">${relativeDate(c.created_at)}</time>` : ''}</header><p class="ovv-comment-text">${text}</p>--${replies > 0 ? html`<p class="ovv-comment-replies"><i class="fa-regular fa-comments" aria-hidden="true"></i> ${replies} ${replies === 1 ? 'reply' : 'replies'}</p>` : ''}++${files.length ? html`<ul>${files.map((file) => html`<li><a href="https://openvibe.media/o/${file.id}" rel="noopener noreferrer">${file.name || 'Attached file'}</a></li>`)}</ul>` : ''}++${shownReplies.map((reply) => commentItem(reply, 1))}++${replies > 0 ? html`<p class="ovv-comment-replies"><i class="fa-regular fa-comments" aria-hidden="true"></i> ${replies} ${replies === 1 ? 'reply' : 'replies'}${replies > shownReplies.length ? html` · ${replies - shownReplies.length} more` : ''}</p>` : ''}</article>`;}@@@ -950,7 +954,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}res.json(item);});-- r.use('/api/v1/video', createVideoApiRoutes(ctx));++ r.use('/api/v1/video', createVideoApiRoutes({ ...ctx, live }));// Uploads' part API (server/video/upload-routes.js): raw parts and its own JSON, before the forms' parser.r.use('/api/v1/videos', createUploadApi({ ctx, config }));// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSONnnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++++const assert = require('node:assert/strict');++const { createCommentsClient } = require('../server/pics/comments-client');++++(async () => {++ const calls = [];++ const token = 'person-token';++ const picture = 'pic_01JAB2C3D4E5F6G7H8J9K0MNP6';++ const fetchImpl = async (url, init) => {++ calls.push({ url, init });++ assert.ok(url.startsWith('https://openvibe.community/api/v1/comments/'));++ if (url.endsWith('/threads/resolve')) return { status: 200, json: async () => ({ thread: { id: 'cth_1234567890123456789012' } }) };++ if (url.includes('/attachments')) {++ assert.ok(init.body instanceof FormData);++ const form = init.body;++ assert.deepEqual(JSON.parse(form.get('ref')), { service: 'pics', type: 'picture', id: picture });++ assert.equal(form.get('file').name, 'look.png');++ return { status: 201, json: async () => ({ attachment: { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP7' } }) };++ }++ 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 } }) };++ };++ const client = createCommentsClient({ fetchImpl });++ const page = await client.read(picture, token, { parent: 4, sort: 'new', after: 3 });++ assert.equal(page.status, 200);++ assert.equal(page.body.comments[0].message, 'hello');++ assert.match(calls[1].url, /parent=4/);++ assert.match(calls[1].url, /sort=new/);++ const added = await client.add(picture, token, { message: 'reply', parent_id: 4, attachments: [] });++ assert.equal(added.body.comment.parent_id, 4);++ 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);++ 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; });@@@ -33,6 +33,10 @@ function hostClient(t) {const same = { 'sec-fetch-site': 'same-origin' };try {const image = (await t.ctx.pics.upload({requester: `user:${a.subject}`}, {buffer: await sharp({create:{width:120,height:90,channels:3,background:'#246'}}).png().toBuffer(), name:'forest.png'}, {title:'Forest'})).image;++ await check('Pics comment writes require a signed-in person', async () => {++ const out = await get(`/api/v1/images/${image.slug}/comments`, { json: { message: 'hello' }, headers: same });++ assert.equal(out.status, 401, out.text);++ });const pub = (as, json) => get(`/api/v1/images/${image.slug}/publication`, { as, json, headers: same });await check('resumable chunks enforce ownership, offsets and idempotent completion', async () => {const bytes = await sharp({create:{width:31,height:41,channels:3,background:'#a42'}}).png().toBuffer();@@@ -109,7 +109,7 @@ function startLive() {// Live's answer since T4 step 5: playback_url is the playlist, file_url the file.const hlsFirstVod = { ...secondVod, id: 105, title: 'Playlist first', playback_url: 'https://openvibe.media/o/med_105/master.m3u8',hls_url: 'https://openvibe.media/o/med_105/master.m3u8', file_url: 'https://openvibe.media/v/105' };-- const state = { down: false, chatDown: false };++ const state = { down: false, chatDown: false, commentPosts: [] };const server = http.createServer((req, res) => {const url = new URL(req.url, 'http://x');const json = (status, body) => {@@@ -219,6 +219,15 @@ function startLive() {}const comments = /^\/api\/comments\/(vod|clip)\/(\d+)$/.exec(p);if (comments) {++ if (req.method === 'POST') {++ const chunks = [];++ req.on('data', (chunk) => chunks.push(chunk));++ req.on('end', () => {++ state.commentPosts.push({ body: JSON.parse(Buffer.concat(chunks).toString('utf8')), authorization: req.headers.authorization });++ json(201, { comment: { id: 99, message: 'a reply' } });++ });++ return;++ }if (comments[1] === 'vod' && comments[2] === '101') {return json(200, { comments: comments101, total: comments101.length, thread: { id: 'thr_vod_101', url: 'https://openvibe.community/t/vod-101' } });}@@@ -239,27 +248,28 @@ function startLive() {url: `http://127.0.0.1:${server.address().port}`,setDown: (v) => { state.down = v; },setChatDown: (v) => { state.chatDown = v; },++ commentPosts: state.commentPosts,close: () => new Promise((r) => server.close(r)),}));});}--function request(base, path, host) {++function request(base, path, host, { method = 'GET', body = null, headers = {} } = {}) {return new Promise((resolve, reject) => {const u = new URL(base + path);const req = http.request({hostname: u.hostname,port: u.port,path: u.pathname + u.search,-- method: 'GET',-- headers: { Host: host },++ method,++ headers: { Host: host, ...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}), ...headers },}, (res) => {const chunks = [];res.on('data', (c) => chunks.push(c));res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));});req.on('error', reject);-- req.end();++ req.end(body);});}@@@ -285,6 +295,22 @@ function countOccurrences(haystack, needle) {const getVideo = (p) => request(t.base, p, 'openvibe.video');const getDownload = (p) => request(t.base, p, 'openvibe.download');++ await check('Video comment API reads the Live thread and requires sign-in for writes', async () => {++ const read = await getVideo('/api/v1/video/comments/vod/101');++ assert.strictEqual(read.status, 200, read.text);++ assert.strictEqual(JSON.parse(read.text).comments[0].message, 'Nice <b>stream</b>!');++ const write = await request(t.base, '/api/v1/video/comments/vod/101', 'openvibe.video', { method: 'POST', body: JSON.stringify({ message: 'hello' }) });++ assert.strictEqual(write.status, 401, write.text);++ const person = t.network.addUser('videocommenter');++ const posted = await request(t.base, '/api/v1/video/comments/vod/101', 'openvibe.video', {++ method: 'POST', body: JSON.stringify({ message: 'a reply', parent_id: 4, attachments: [] }),++ headers: { Cookie: t.signIn(person), Origin: 'https://openvibe.video', 'Sec-Fetch-Site': 'same-origin' },++ });++ assert.strictEqual(posted.status, 201, posted.text);++ assert.deepStrictEqual(live.commentPosts[0].body, { message: 'a reply', parent_id: 4, attachments: [] });++ assert.match(live.commentPosts[0].authorization, /^Bearer /);++ });++await check('Live being down renders a 502 page', async () => {live.setDown(true);const r = await getVideo('/v/101');
@@ -26,15 +26,25 @@
@@ -26,15 +26,25 @@
if (discussion) discussion.addEventListener('click', async function () {
if (discussion) discussion.addEventListener('click', async function () {
var box = document.querySelector('[data-comments-list]'); discussion.disabled = true;
var box = document.querySelector('[data-comments-list]'); discussion.disabled = true;
try {
try {
- var response = await fetch('https://openvibe.community/api/v1/comments/threads/resolve', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ ref: { service: 'pics', type: 'picture', id: discussion.dataset.comments } }) });
- if (!response.ok) throw new Error('Discussion is unavailable. Try again later.');
- var result = await response.json(), thread = result.thread;
- if (!thread || !/^cth_[A-Za-z0-9_-]+$/.test(thread.id)) throw new Error('Discussion is unavailable.');
- var listing = await fetch('https://openvibe.community/api/v1/comments/threads/' + encodeURIComponent(thread.id));
- if (!listing.ok) throw new Error('Comments could not be loaded.');
- var data = await listing.json(); box.replaceChildren();
- (data.comments || []).forEach(function (comment) { var p = document.createElement('p'); p.textContent = comment.message; box.appendChild(p); });
- var link = document.createElement('a'); link.href = 'https://openvibe.community/c/' + encodeURIComponent(thread.id); link.textContent = 'Join the discussion on Community'; box.appendChild(link);
+ var response = await fetch('/api/v1/images/' + encodeURIComponent(discussion.dataset.comments) + '/comments?sort=old', { credentials: 'same-origin' });
+ if (!response.ok) throw new Error('Comments could not be loaded.');
+ var data = await response.json(); box.replaceChildren();
+ function show(comment, parent) {
+ var item = document.createElement('article');
+ var text = document.createElement('p'); text.textContent = comment.deleted ? 'Comment removed' : comment.message; item.appendChild(text);
+ (comment.attachments || []).forEach(function (file) {
+ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(file.id || '')) return;
+ var link = document.createElement('a'); link.href = 'https://openvibe.media/o/' + file.id;
+ link.textContent = file.name || 'Attached file'; link.rel = 'noopener noreferrer'; item.appendChild(link);
+ });
+ (comment.replies || []).forEach(function (reply) { show(reply, item); });
+ parent.appendChild(item);
+ }
+ (data.comments || []).forEach(function (comment) { show(comment, box); });
+ if (data.thread && /^cth_[A-Za-z0-9_-]+$/.test(data.thread.id || '')) {
+ var link = document.createElement('a'); link.href = 'https://openvibe.community/c/' + encodeURIComponent(data.thread.id);
+ link.textContent = 'Join the discussion on Community'; box.appendChild(link);
+ }
} catch (error) { box.textContent = error.message; }
} catch (error) { box.textContent = error.message; }
finally { discussion.disabled = false; }
finally { discussion.disabled = false; }
});
});
@@ -30,12 +30,14 @@ const { sameOrigin } = require('../http/principal');
@@ -30,12 +30,14 @@ const { sameOrigin } = require('../http/principal');
const store = require('./store');
const store = require('./store');
const contentLabels = require('../labels');
const contentLabels = require('../labels');
const gallery = require('./gallery');
const gallery = require('./gallery');
+const { createCommentsClient } = require('./comments-client');
function createPicsApi(ctx) {
function createPicsApi(ctx) {
const { config, s, principal, limits } = ctx;
const { config, s, principal, limits } = ctx;
const pics = ctx.pics;
const pics = ctx.pics;
const cfg = config.pics;
const cfg = config.pics;
const r = asyncRouter();
const r = asyncRouter();
+ const comments = createCommentsClient({ fetchImpl: ctx.fetchImpl || globalThis.fetch });
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@ -138,6 +140,30 @@ function createPicsApi(ctx) {
@@ -138,6 +140,30 @@ function createPicsApi(ctx) {
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
+ const commentAnswer = (res, result) => res.status(result.status).json(result.body);
+ r.get('/images/:slug/comments', async (req, res) => {
+ const row = await load(req, res);
+ 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) => {
+ const row = await load(req, res);
+ if (!row) return;
+ const b = req.body || {};
+ if (!Array.isArray(b.attachments || []) || (b.attachments || []).length > 4)
+ return problem(req, res, 400, 'pics.comment.attachments', 'Attach up to four files');
+ return commentAnswer(res, await comments.add(row.id, req.viewer.token,
+ { 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) => {
+ 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.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);
if (!row) return;
if (!row) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,50 @@
@@ -0,0 +1,50 @@
+'use strict';
+
+// Pics keeps picture visibility and the person's session; Community owns the discussion.
+const ORIGIN = 'https://openvibe.community';
+
+function createCommentsClient({ fetchImpl = globalThis.fetch } = {}) {
+ async function call(method, path, { token = null, body } = {}) {
+ const form = body instanceof FormData;
+ let response;
+ try {
+ response = await fetchImpl(`${ORIGIN}/api/v1/comments${path}`, {
+ method, redirect: 'error', signal: AbortSignal.timeout(form ? 120_000 : 5000),
+ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ ...(body && !form ? { 'Content-Type': 'application/json' } : {}) },
+ body: form ? body : body ? JSON.stringify(body) : undefined,
+ });
+ } catch { return { status: 503, body: { error: 'Comments are unavailable right now' } }; }
+ const data = await response.json().catch(() => null);
+ return { status: response.status, body: data || { error: 'Comments are unavailable right now' } };
+ }
+ const ref = (id) => ({ service: 'pics', type: 'picture', id: String(id) });
+ async function thread(id, token) {
+ const result = await call('POST', '/threads/resolve', { token, body: { ref: ref(id) } });
+ return result.status < 300 && result.body.thread ? result.body.thread : result;
+ }
+ async function read(id, token, query = {}) {
+ 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.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 });
+ }
+ async function add(id, token, body) {
+ const found = await thread(id, token);
+ if (!found.id) return found;
+ return await call('POST', `/threads/${encodeURIComponent(found.id)}/comments`, { token, body });
+ }
+ async function upload(id, token, buffer, name, mime) {
+ const form = new FormData();
+ form.set('ref', JSON.stringify(ref(id)));
+ 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 };
+}
+
+module.exports = { createCommentsClient };
@@ -386,6 +386,7 @@ ${raw(showcase.network({
@@ -386,6 +386,7 @@ ${raw(showcase.network({
const holding = owner ? await store.albumsHolding(s, row.id) : [];
const holding = owner ? await store.albumsHolding(s, row.id) : [];
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));
// ?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;
@@ -418,7 +419,7 @@ ${!hidden ? html`<nav class="ovp-pic-make" aria-label="Make something with this
@@ -418,7 +419,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>
-${gallery.isPublic(row) ? 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.id}">${glyph('chat', 17)}<span>Show the discussion</span></button></div><div class="ovp-comments-list" data-comments-list aria-live="polite"></div></section>` : ''}
+${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>` : ''}
<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">
@@ -23,10 +23,11 @@ const express = require('express');
@@ -23,10 +23,11 @@ const express = require('express');
const rateLimit = require('express-rate-limit');
const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('../http/router');
const { asyncRouter } = require('../http/router');
-const { USERNAME_RE } = require('./live-client');
+const { sameOrigin } = require('../http/principal');
+const { USERNAME_RE, isId, LiveRefusedError } = require('./live-client');
function createVideoApiRoutes(ctx) {
function createVideoApiRoutes(ctx) {
- const { config, playlists, principal, limits, identity } = ctx;
+ const { config, playlists, principal, limits, identity, live } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
const videoSite = 'openvibe.video';
const videoSite = 'openvibe.video';
@@ -39,6 +40,53 @@ function createVideoApiRoutes(ctx) {
@@ -39,6 +40,53 @@ function createVideoApiRoutes(ctx) {
const read = principal.requireCapability('video.playlist.read', { origin: config.video.url, site: videoSite });
const read = principal.requireCapability('video.playlist.read', { origin: config.video.url, site: videoSite });
const manage = principal.requireCapability('video.playlist.manage', { origin: config.video.url, site: videoSite });
const manage = principal.requireCapability('video.playlist.manage', { origin: config.video.url, site: videoSite });
+ const commentTarget = (req, res) => {
+ const kind = String(req.params.kind || '');
+ const id = String(req.params.id || '');
+ if (!['vod', 'clip'].includes(kind) || !isId(id)) { problem(req, res, 400, 'video.comment.invalid_item', 'Choose a VOD or clip'); return null; }
+ return { kind, id };
+ };
+ const commentPerson = (req, res) => {
+ if (req.viewer.kind !== 'user' || !req.viewer.token) { problem(req, res, 401, 'video.comment.sign_in', 'Sign in to comment'); return null; }
+ if (!sameOrigin(req, config.video.url)) { problem(req, res, 403, 'request.cross_site', 'Post from openvibe.video'); return null; }
+ return req.viewer.token;
+ };
+ const commentError = (req, res, err) => problem(req, res, err instanceof LiveRefusedError ? err.status : 502,
+ 'video.comment.unavailable', err instanceof LiveRefusedError ? err.message : 'Comments are unavailable right now');
+
+ r.get('/comments/:kind/:id', async (req, res) => {
+ const target = commentTarget(req, res);
+ if (!target) return;
+ try {
+ const query = { sort: req.query.sort, offset: req.query.offset, limit: req.query.limit };
+ const out = req.viewer.kind === 'user' && req.viewer.token
+ ? await live.commentsPageAs(req.viewer.token, target.kind, target.id, query)
+ : await live.commentsPage(target.kind, target.id, query);
+ if (!out) return problem(req, res, 404, 'video.comment.not_found', 'Video not found');
+ return res.json(out);
+ } catch (err) { return commentError(req, res, err); }
+ });
+ r.post('/comments/:kind/:id', async (req, res) => {
+ const target = commentTarget(req, res);
+ const token = target && commentPerson(req, res);
+ if (!token) return;
+ const b = req.body || {};
+ if (!Array.isArray(b.attachments || []) || (b.attachments || []).length > 4) return problem(req, res, 400, 'video.comment.attachments', 'Attach up to four files');
+ try { return res.status(201).json(await live.postCommentAs(token, target.kind, target.id,
+ { message: b.message, parent_id: b.parent_id || null, attachments: b.attachments || [] })); }
+ catch (err) { return commentError(req, res, err); }
+ });
+ const fileBody = express.raw({ type: 'application/octet-stream', limit: '16mb' });
+ r.post('/comments/:kind/:id/attachments', fileBody, async (req, res) => {
+ const target = commentTarget(req, res);
+ const token = target && commentPerson(req, res);
+ if (!token) return;
+ if (!Buffer.isBuffer(req.body) || !req.body.length) return problem(req, res, 400, 'video.comment.file', 'Choose a file');
+ try { return res.status(201).json(await live.uploadCommentAttachmentAs(token, target.kind, target.id, req.body,
+ String(req.get('x-file-name') || 'file').slice(0, 255), String(req.get('x-file-mime') || 'application/octet-stream').slice(0, 100))); }
+ catch (err) { return commentError(req, res, err); }
+ });
+
/** The owner string this principal acts as, or null for a signed-out caller. */
/** The owner string this principal acts as, or null for a signed-out caller. */
const requesterOf = (req) => {
const requesterOf = (req) => {
const p = req.principal;
const p = req.principal;
@@ -134,15 +134,15 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -134,15 +134,15 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
res = await fetchImpl(`${config.video.liveUrl}${path}`, {
res = await fetchImpl(`${config.video.liveUrl}${path}`, {
method,
method,
headers: {
headers: {
- Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body ? { 'Content-Type': 'application/json' } : {}),
+ Accept: 'application/json', Authorization: `Bearer ${token}`, ...(body && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
// Live limits clip-making per address too; every call from here comes from this server, so the
// Live limits clip-making per address too; every call from here comes from this server, so the
// person's own address is passed (Live reads the first X-Forwarded-For entry).
// person's own address is passed (Live reads the first X-Forwarded-For entry).
...(viewerIp ? { 'X-Forwarded-For': viewerIp } : {}),
...(viewerIp ? { 'X-Forwarded-For': viewerIp } : {}),
},
},
- body: body ? JSON.stringify(body) : undefined,
+ body: body instanceof FormData ? body : body ? JSON.stringify(body) : undefined,
credentials: 'omit',
credentials: 'omit',
cache: 'no-store',
cache: 'no-store',
- signal: AbortSignal.timeout(TIMEOUT_MS * 2),
+ signal: AbortSignal.timeout(body instanceof FormData ? 120_000 : TIMEOUT_MS * 2),
});
});
} catch (err) {
} catch (err) {
log.warn('[OpenVibe.Video] Live did not answer an owner call', err && err.name);
log.warn('[OpenVibe.Video] Live did not answer an owner call', err && err.name);
@@ -259,6 +259,30 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
@@ -259,6 +259,30 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
comments(kind, id) {
comments(kind, id) {
return getJson(`/api/comments/${kind}/${id}`);
return getJson(`/api/comments/${kind}/${id}`);
},
},
+ commentsPage(kind, id, query = {}) {
+ const qs = new URLSearchParams();
+ if (query.sort === 'old') qs.set('sort', 'old');
+ if (query.offset != null) qs.set('offset', String(query.offset));
+ if (query.limit != null) qs.set('limit', String(query.limit));
+ return getJson(`/api/comments/${kind}/${id}${qs.size ? `?${qs}` : ''}`);
+ },
+ commentsPageAs(token, kind, id, query = {}) {
+ const qs = new URLSearchParams();
+ if (query.sort === 'old') qs.set('sort', 'old');
+ if (query.offset != null) qs.set('offset', String(query.offset));
+ if (query.limit != null) qs.set('limit', String(query.limit));
+ return asPerson(token, 'GET', `/api/comments/${kind}/${id}${qs.size ? `?${qs}` : ''}`);
+ },
+ async postCommentAs(token, kind, id, body) {
+ const out = await asPerson(token, 'POST', `/api/comments/${kind}/${id}`, body);
+ for (const key of [...cache.keys()]) if (key.includes(`/api/comments/${kind}/${id}`)) cache.delete(key);
+ return out;
+ },
+ uploadCommentAttachmentAs(token, kind, id, buffer, name, mime) {
+ const form = new FormData();
+ form.set('file', new Blob([buffer], { type: mime || 'application/octet-stream' }), name || 'file');
+ return asPerson(token, 'POST', `/api/comments/${kind}/${id}/attachments`, form);
+ },
isId,
isId,
isUsername: (u) => USERNAME_RE.test(String(u || '')),
isUsername: (u) => USERNAME_RE.test(String(u || '')),
};
};
@@ -401,14 +401,18 @@ function chatMessageRows(vod, messages, limit = 1000) {
@@ -401,14 +401,18 @@ function chatMessageRows(vod, messages, limit = 1000) {
return { rows: rows.slice(0, limit), leftOut };
return { rows: rows.slice(0, limit), leftOut };
}
}
-function commentItem(c) {
+function commentItem(c, depth = 0) {
const name = c.display_name || c.username || '';
const name = c.display_name || c.username || '';
const text = c.message == null ? '' : String(c.message);
const text = c.message == null ? '' : String(c.message);
const replies = Number(c.reply_count) || 0;
const replies = Number(c.reply_count) || 0;
+ const files = (c.attachments || []).filter((a) => /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(a.id || ''))).slice(0, 4);
+ const shownReplies = depth ? [] : (c.replies || []).slice(0, 2);
return html`<article class="ovv-comment">
return html`<article class="ovv-comment">
<header class="ovv-comment-header"><span class="ovv-comment-author">${name}</span>${c.created_at ? html`<time class="ovv-comment-date" datetime="${c.created_at}">${relativeDate(c.created_at)}</time>` : ''}</header>
<header class="ovv-comment-header"><span class="ovv-comment-author">${name}</span>${c.created_at ? html`<time class="ovv-comment-date" datetime="${c.created_at}">${relativeDate(c.created_at)}</time>` : ''}</header>
<p class="ovv-comment-text">${text}</p>
<p class="ovv-comment-text">${text}</p>
-${replies > 0 ? html`<p class="ovv-comment-replies"><i class="fa-regular fa-comments" aria-hidden="true"></i> ${replies} ${replies === 1 ? 'reply' : 'replies'}</p>` : ''}
+${files.length ? html`<ul>${files.map((file) => html`<li><a href="https://openvibe.media/o/${file.id}" rel="noopener noreferrer">${file.name || 'Attached file'}</a></li>`)}</ul>` : ''}
+${shownReplies.map((reply) => commentItem(reply, 1))}
+${replies > 0 ? html`<p class="ovv-comment-replies"><i class="fa-regular fa-comments" aria-hidden="true"></i> ${replies} ${replies === 1 ? 'reply' : 'replies'}${replies > shownReplies.length ? html` · ${replies - shownReplies.length} more` : ''}</p>` : ''}
</article>`;
</article>`;
}
}
@@ -950,7 +954,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
@@ -950,7 +954,7 @@ ${labelsUi.picker(item, { what: `the ${what}`, id: `labels-${kind}` })}
res.json(item);
res.json(item);
});
});
- r.use('/api/v1/video', createVideoApiRoutes(ctx));
+ r.use('/api/v1/video', createVideoApiRoutes({ ...ctx, live }));
// Uploads' part API (server/video/upload-routes.js): raw parts and its own JSON, before the forms' parser.
// Uploads' part API (server/video/upload-routes.js): raw parts and its own JSON, before the forms' parser.
r.use('/api/v1/videos', createUploadApi({ ctx, config }));
r.use('/api/v1/videos', createUploadApi({ ctx, config }));
// 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
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { createCommentsClient } = require('../server/pics/comments-client');
+
+(async () => {
+ const calls = [];
+ const token = 'person-token';
+ const picture = 'pic_01JAB2C3D4E5F6G7H8J9K0MNP6';
+ const fetchImpl = async (url, init) => {
+ calls.push({ url, init });
+ assert.ok(url.startsWith('https://openvibe.community/api/v1/comments/'));
+ if (url.endsWith('/threads/resolve')) return { status: 200, json: async () => ({ thread: { id: 'cth_1234567890123456789012' } }) };
+ if (url.includes('/attachments')) {
+ assert.ok(init.body instanceof FormData);
+ const form = init.body;
+ assert.deepEqual(JSON.parse(form.get('ref')), { service: 'pics', type: 'picture', id: picture });
+ assert.equal(form.get('file').name, 'look.png');
+ return { status: 201, json: async () => ({ attachment: { id: 'med_01JAB2C3D4E5F6G7H8J9K0MNP7' } }) };
+ }
+ 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 } }) };
+ };
+ const client = createCommentsClient({ fetchImpl });
+ const page = await client.read(picture, token, { parent: 4, sort: 'new', after: 3 });
+ assert.equal(page.status, 200);
+ assert.equal(page.body.comments[0].message, 'hello');
+ assert.match(calls[1].url, /parent=4/);
+ assert.match(calls[1].url, /sort=new/);
+ const added = await client.add(picture, token, { message: 'reply', parent_id: 4, attachments: [] });
+ assert.equal(added.body.comment.parent_id, 4);
+ 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);
+ 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; });
@@ -33,6 +33,10 @@ function hostClient(t) {
@@ -33,6 +33,10 @@ function hostClient(t) {
const same = { 'sec-fetch-site': 'same-origin' };
const same = { 'sec-fetch-site': 'same-origin' };
try {
try {
const image = (await t.ctx.pics.upload({requester: `user:${a.subject}`}, {buffer: await sharp({create:{width:120,height:90,channels:3,background:'#246'}}).png().toBuffer(), name:'forest.png'}, {title:'Forest'})).image;
const image = (await t.ctx.pics.upload({requester: `user:${a.subject}`}, {buffer: await sharp({create:{width:120,height:90,channels:3,background:'#246'}}).png().toBuffer(), name:'forest.png'}, {title:'Forest'})).image;
+ await check('Pics comment writes require a signed-in person', async () => {
+ const out = await get(`/api/v1/images/${image.slug}/comments`, { json: { message: 'hello' }, headers: same });
+ assert.equal(out.status, 401, out.text);
+ });
const pub = (as, json) => get(`/api/v1/images/${image.slug}/publication`, { as, json, headers: same });
const pub = (as, json) => get(`/api/v1/images/${image.slug}/publication`, { as, json, headers: same });
await check('resumable chunks enforce ownership, offsets and idempotent completion', async () => {
await check('resumable chunks enforce ownership, offsets and idempotent completion', async () => {
const bytes = await sharp({create:{width:31,height:41,channels:3,background:'#a42'}}).png().toBuffer();
const bytes = await sharp({create:{width:31,height:41,channels:3,background:'#a42'}}).png().toBuffer();
@@ -109,7 +109,7 @@ function startLive() {
@@ -109,7 +109,7 @@ function startLive() {
// Live's answer since T4 step 5: playback_url is the playlist, file_url the file.
// Live's answer since T4 step 5: playback_url is the playlist, file_url the file.
const hlsFirstVod = { ...secondVod, id: 105, title: 'Playlist first', playback_url: 'https://openvibe.media/o/med_105/master.m3u8',
const hlsFirstVod = { ...secondVod, id: 105, title: 'Playlist first', playback_url: 'https://openvibe.media/o/med_105/master.m3u8',
hls_url: 'https://openvibe.media/o/med_105/master.m3u8', file_url: 'https://openvibe.media/v/105' };
hls_url: 'https://openvibe.media/o/med_105/master.m3u8', file_url: 'https://openvibe.media/v/105' };
- const state = { down: false, chatDown: false };
+ const state = { down: false, chatDown: false, commentPosts: [] };
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://x');
const url = new URL(req.url, 'http://x');
const json = (status, body) => {
const json = (status, body) => {
@@ -219,6 +219,15 @@ function startLive() {
@@ -219,6 +219,15 @@ function startLive() {
}
}
const comments = /^\/api\/comments\/(vod|clip)\/(\d+)$/.exec(p);
const comments = /^\/api\/comments\/(vod|clip)\/(\d+)$/.exec(p);
if (comments) {
if (comments) {
+ if (req.method === 'POST') {
+ const chunks = [];
+ req.on('data', (chunk) => chunks.push(chunk));
+ req.on('end', () => {
+ state.commentPosts.push({ body: JSON.parse(Buffer.concat(chunks).toString('utf8')), authorization: req.headers.authorization });
+ json(201, { comment: { id: 99, message: 'a reply' } });
+ });
+ return;
+ }
if (comments[1] === 'vod' && comments[2] === '101') {
if (comments[1] === 'vod' && comments[2] === '101') {
return json(200, { comments: comments101, total: comments101.length, thread: { id: 'thr_vod_101', url: 'https://openvibe.community/t/vod-101' } });
return json(200, { comments: comments101, total: comments101.length, thread: { id: 'thr_vod_101', url: 'https://openvibe.community/t/vod-101' } });
}
}
@@ -239,27 +248,28 @@ function startLive() {
@@ -239,27 +248,28 @@ function startLive() {
url: `http://127.0.0.1:${server.address().port}`,
url: `http://127.0.0.1:${server.address().port}`,
setDown: (v) => { state.down = v; },
setDown: (v) => { state.down = v; },
setChatDown: (v) => { state.chatDown = v; },
setChatDown: (v) => { state.chatDown = v; },
+ commentPosts: state.commentPosts,
close: () => new Promise((r) => server.close(r)),
close: () => new Promise((r) => server.close(r)),
}));
}));
});
});
}
}
-function request(base, path, host) {
+function request(base, path, host, { method = 'GET', body = null, headers = {} } = {}) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
const u = new URL(base + path);
const u = new URL(base + path);
const req = http.request({
const req = http.request({
hostname: u.hostname,
hostname: u.hostname,
port: u.port,
port: u.port,
path: u.pathname + u.search,
path: u.pathname + u.search,
- method: 'GET',
- headers: { Host: host },
+ method,
+ headers: { Host: host, ...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) } : {}), ...headers },
}, (res) => {
}, (res) => {
const chunks = [];
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('data', (c) => chunks.push(c));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text: Buffer.concat(chunks).toString('utf8') }));
});
});
req.on('error', reject);
req.on('error', reject);
- req.end();
+ req.end(body);
});
});
}
}
@@ -285,6 +295,22 @@ function countOccurrences(haystack, needle) {
@@ -285,6 +295,22 @@ function countOccurrences(haystack, needle) {
const getVideo = (p) => request(t.base, p, 'openvibe.video');
const getVideo = (p) => request(t.base, p, 'openvibe.video');
const getDownload = (p) => request(t.base, p, 'openvibe.download');
const getDownload = (p) => request(t.base, p, 'openvibe.download');
+ await check('Video comment API reads the Live thread and requires sign-in for writes', async () => {
+ const read = await getVideo('/api/v1/video/comments/vod/101');
+ assert.strictEqual(read.status, 200, read.text);
+ assert.strictEqual(JSON.parse(read.text).comments[0].message, 'Nice <b>stream</b>!');
+ const write = await request(t.base, '/api/v1/video/comments/vod/101', 'openvibe.video', { method: 'POST', body: JSON.stringify({ message: 'hello' }) });
+ assert.strictEqual(write.status, 401, write.text);
+ const person = t.network.addUser('videocommenter');
+ const posted = await request(t.base, '/api/v1/video/comments/vod/101', 'openvibe.video', {
+ method: 'POST', body: JSON.stringify({ message: 'a reply', parent_id: 4, attachments: [] }),
+ headers: { Cookie: t.signIn(person), Origin: 'https://openvibe.video', 'Sec-Fetch-Site': 'same-origin' },
+ });
+ assert.strictEqual(posted.status, 201, posted.text);
+ assert.deepStrictEqual(live.commentPosts[0].body, { message: 'a reply', parent_id: 4, attachments: [] });
+ assert.match(live.commentPosts[0].authorization, /^Bearer /);
+ });
+
await check('Live being down renders a 502 page', async () => {
await check('Live being down renders a 502 page', async () => {
live.setDown(true);
live.setDown(true);
const r = await getVideo('/v/101');
const r = await getVideo('/v/101');