openvibers/openvibe.mediahub Commits

Connect Video and Pics to shared comments (#397)

OP OpenVibers committed 2 days ago · dfc1965174 · 10 files, +253 −23

Changes

ddiff --git a/public/js/pics-gallery.js b/public/js/pics-gallery.js
iindex 2c9dd6d..f384f17 100644
---- a/public/js/pics-gallery.js
++++ b/public/js/pics-gallery.js
@@@ -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; }
});
ddiff --git a/server/pics/api.js b/server/pics/api.js
iindex b548183..a17d192 100644
---- a/server/pics/api.js
++++ b/server/pics/api.js
@@@ -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;
ddiff --git a/server/pics/comments-client.js b/server/pics/comments-client.js
nnew file mode 100644
iindex 0000000..c47795d
---- /dev/null
++++ b/server/pics/comments-client.js
@@@ -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 };
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex 4f77d2f..b75cdca 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -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">
ddiff --git a/server/video/api.js b/server/video/api.js
iindex 95c3192..5bf0a22 100644
---- a/server/video/api.js
++++ b/server/video/api.js
@@@ -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;
ddiff --git a/server/video/live-client.js b/server/video/live-client.js
iindex c47ff1e..f6398e1 100644
---- a/server/video/live-client.js
++++ b/server/video/live-client.js
@@@ -134,15 +134,15 @@ function createLiveClient({ config, fetchImpl = globalThis.fetch, log = console
res = 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 = console
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,
isUsername: (u) => USERNAME_RE.test(String(u || '')),
};
ddiff --git a/server/video/pages.js b/server/video/pages.js
iindex 988343c..99fb2dd 100644
---- a/server/video/pages.js
++++ b/server/video/pages.js
@@@ -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 JSON
ddiff --git a/test/pics-comments-client.test.js b/test/pics-comments-client.test.js
nnew file mode 100644
iindex 0000000..fe0db1d
---- /dev/null
++++ b/test/pics-comments-client.test.js
@@@ -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; });
ddiff --git a/test/pics-gallery.test.js b/test/pics-gallery.test.js
iindex b0dec3d..8c6af67 100644
---- a/test/pics-gallery.test.js
++++ b/test/pics-gallery.test.js
@@@ -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();
ddiff --git a/test/video-pages.test.js b/test/video-pages.test.js
iindex 2a1ea52..a9d7098 100644
---- a/test/video-pages.test.js
++++ b/test/video-pages.test.js
@@@ -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');