openvibers/openvibe.live Commits

Carry VOD and clip comment files through Live (#818)

OP OpenVibers committed 2 days ago · 877d2b7964 · 5 files, +87 −16

* Carry Live comment attachments and sort order

* Measure chat award minute before first award

* Pass exact reply targets through Live comments

Changes

ddiff --git a/server/comments-client.js b/server/comments-client.js
iindex a911c7b7..1f167436 100644
---- a/server/comments-client.js
++++ b/server/comments-client.js
@@@ -53,10 +53,11 @@ async function call(method, path, { body, act = {}, ip = null, timeoutMs = 5000,
if (act.subject) headers['X-OV-Subject'] = act.subject;
if (act.staff) headers['X-OV-Staff'] = '1';
if (ip) headers['X-Forwarded-For'] = String(ip);
-- if (body !== undefined) headers['Content-Type'] = 'application/json';
++ const multipart = body instanceof FormData;
++ if (body !== undefined && !multipart) headers['Content-Type'] = 'application/json';
let res;
try {
-- res = await fetch(`${COMMUNITY_URL()}/api/v1/comments${path}`, { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });
++ res = await fetch(`${COMMUNITY_URL()}/api/v1/comments${path}`, { method, headers, body: multipart ? body : body !== undefined ? JSON.stringify(body) : undefined, signal: AbortSignal.timeout(timeoutMs) });
} catch (err) {
console.warn(`[Comments] Community unreachable (${method} ${path}): ${err.message}`);
throw unavailable('unreachable');
@@@ -114,8 +115,15 @@ async function readThread(threadId, { subject = null, timeoutMs, ip, ...q } = {}
return await call('GET', `/threads/${encodeURIComponent(threadId)}${qs ? `?${qs}` : ''}`, { act: { subject }, timeoutMs, ip });
}
--async function addComment(threadId, subject, { message, parentId = null }, { ip } = {}) {
-- return await call('POST', `/threads/${encodeURIComponent(threadId)}/comments`, { act: { subject }, ip, body: { message, ...(parentId ? { parent_id: parentId } : {}) } });
++async function addComment(threadId, subject, { message, parentId = null, attachments = [] }, { ip } = {}) {
++ return await call('POST', `/threads/${encodeURIComponent(threadId)}/comments`, { act: { subject }, ip, body: { message, ...(parentId ? { parent_id: parentId } : {}), ...(attachments.length ? { attachments } : {}) } });
++}
++
++async function uploadAttachment(ref, subject, file, { ip } = {}) {
++ const form = new FormData();
++ form.set('ref', JSON.stringify(ref));
++ form.set('file', new Blob([file.buffer], { type: file.mimetype || 'application/octet-stream' }), file.originalname || 'file');
++ return await call('POST', '/attachments', { act: { subject }, ip, body: form, timeoutMs: 120000 });
}
/** One comment and its thread (with the ref). null when Community has no such (visible) comment. */
@@@ -167,6 +175,6 @@ const threadUrl = (accessId) => `${COMMUNITY_PUBLIC_URL()}/c/${encodeURIComponen
module.exports = {
sameThread,
-- CommentsError, TYPES, threadFor, readThread, addComment, getComment, editComment, deleteComment,
++ CommentsError, TYPES, threadFor, readThread, addComment, uploadAttachment, getComment, editComment, deleteComment,
hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl,
};
ddiff --git a/server/media-proxy/comments.js b/server/media-proxy/comments.js
iindex c46e861c..27e971d8 100644
---- a/server/media-proxy/comments.js
++++ b/server/media-proxy/comments.js
@@@ -17,6 +17,7 @@
*/
'use strict';
const express = require('express');
++const multer = require('multer');
const db = require('../db/database');
const media = require('../media-client');
const comments = require('../comments-client');
@@@ -30,6 +31,7 @@ const MAX_MESSAGE = 2000;
const MAX_OFFSET = 1000;
const MAX_REPLIES = 500;
const HIDDEN = { error: 'Comments are hidden here', code: 'comments_hidden' };
++const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024, files: 1, fields: 4 } }).single('file');
function truncatePreview(text, max = 120) {
const clean = String(text || '').replace(/\s+/g, ' ').trim();
@@@ -99,6 +101,8 @@ async function shape(c, ctx) {
user_id: u ? u.id : null,
subject: a ? a.subject || null : null,
parent_id: c.parent_id || null,
++ reply_to_id: c.reply_to_id || null,
++ reply_to: c.reply_to || null,
message: c.deleted ? '' : c.message,
is_deleted: c.deleted ? 1 : 0,
deleted: !!c.deleted,
@@@ -112,6 +116,7 @@ async function shape(c, ctx) {
role: u && !c.deleted ? u.role : null,
is_ai: !!(a && a.is_ai),
reply_count: c.reply_count || 0,
++ attachments: c.deleted ? [] : (c.attachments || []),
can_edit: !!(ctx.subject && a && a.subject === ctx.subject && !c.deleted),
can_delete: !!await deleteRight(ctx.user, ctx.subject, c, ctx.target),
};
@@@ -175,12 +180,13 @@ router.get('/:type/:id', optionalAuth, async (req, res) => {
const thread = await comments.threadFor(contentType, contentId, target.row.title, { ip: req.ip });
const ctx = { type: contentType, id: contentId, user: req.user, subject, target };
-- // Newest first, like before; Community pages by cursor, so an offset is walked.
++ // Community pages by cursor, so an offset is walked in the requested order.
++ const sort = req.query.sort === 'old' ? 'old' : 'new';
const picked = [];
let skipped = 0, after = null, total = 0;
for (;;) {
let page;
-- try { page = await comments.readThread(thread.id, { subject, sort: 'new', limit: 100, after, ip: req.ip }); }
++ try { page = await comments.readThread(thread.id, { subject, sort, limit: 100, after, ip: req.ip }); }
catch (err) { if (err.status === 404) return res.status(404).json(HIDDEN); throw err; }
// A thread Community's moderators hid is hidden here too (Live reads anonymous visitors' pages as itself, which sees it).
if (page.thread && page.thread.visibility === 'hidden' && !permissions.isStaff(req.user)) return res.status(404).json(HIDDEN);
@@@ -206,6 +212,24 @@ router.get('/:type/:id', optionalAuth, async (req, res) => {
});
// ── Add Comment ──────────────────────────────────────────────
++router.post('/:type/:id/attachments', requireAuth, (req, res, next) => upload(req, res, (err) => {
++ if (err instanceof multer.MulterError) return res.status(err.code === 'LIMIT_FILE_SIZE' ? 413 : 400).json({ error: 'Upload one file up to 16 MB' });
++ next(err);
++}), async (req, res) => {
++ try {
++ const type = req.params.type;
++ const id = Number(req.params.id);
++ if (!comments.TYPES.includes(type) || !Number.isSafeInteger(id) || id < 1) return res.status(400).json({ error: 'Invalid content type or ID' });
++ if (!req.file) return res.status(400).json({ error: 'Choose a file' });
++ const target = await visibleTarget(req.user, type, id);
++ if (!target) return res.status(404).json({ error: 'Content not found' });
++ const subject = await viewerSubject(req.user);
++ if (!subject) return res.status(409).json({ error: 'This account is not linked to an OpenVibe account yet — sign in again and retry.' });
++ const out = await comments.uploadAttachment({ service: 'live', type, id: String(id) }, subject, req.file, { ip: req.ip });
++ return res.status(201).json(out);
++ } catch (err) { return sendError(res, err, 'Failed to attach file'); }
++});
++
router.post('/:type/:id', requireAuth, async (req, res) => {
try {
const contentType = req.params.type;
@@@ -233,7 +257,9 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
}
}
-- const out = await comments.addComment(thread.id, subject, { message, parentId }, { ip: req.ip });
++ const attachments = req.body.attachments == null ? [] : req.body.attachments;
++ if (!Array.isArray(attachments) || attachments.length > 4) return res.status(400).json({ error: 'Attach up to four files' });
++ const out = await comments.addComment(thread.id, subject, { message, parentId, attachments }, { ip: req.ip });
const comment = await shape(out.comment, { type: contentType, id: contentId, user: req.user, subject, target });
const actor = notificationActor(req.user);
ddiff --git a/test/comments-community.test.js b/test/comments-community.test.js
iindex 7bf9de3c..e5afe407 100644
---- a/test/comments-community.test.js
++++ b/test/comments-community.test.js
@@@ -178,6 +178,8 @@ async function check(name, fn) {
assert.strictEqual(last('GET', /^\/threads\/cth_[^/]+$/).subject, SUB('B'), 'read as the viewer, by the thread\'s access id (C-14)');
const page = (await call('GET', '/api/comments/vod/100?limit=1&offset=1')).json;
assert.deepStrictEqual(page.comments.map((c) => c.message), ['great stream']);
++ const oldest = (await call('GET', '/api/comments/vod/100?sort=old')).json;
++ assert.deepStrictEqual(oldest.comments.map((c) => c.message), ['great stream', 'said on Community']);
assert.ok(!r.text.includes('legacy local row'), 'Live\'s own table is not read');
});
@@@ -189,6 +191,28 @@ async function check(name, fn) {
assert.strictEqual((await call('GET', '/api/comments/424242/replies')).status, 404);
});
++ await check('a VOD picture uploads through Live, then appears in its Community comment and Live read', async () => {
++ const form = new FormData();
++ form.set('file', new Blob([Buffer.from('picture bytes')], { type: 'image/png' }), 'look.png');
++ const url = `http://127.0.0.1:${server.address().port}/api/comments/vod/100/attachments`;
++ const response = await fetch(url, { method: 'POST', headers: { 'x-test-user': '5' }, body: form });
++ const uploaded = await response.json();
++ assert.strictEqual(response.status, 201, JSON.stringify(uploaded));
++ const sent = last('POST', /^\/attachments$/);
++ assert.deepStrictEqual(sent.body.ref, { service: 'live', type: 'vod', id: '100' });
++ assert.strictEqual(sent.subject, SUB('C'));
++ assert.strictEqual(sent.body.file.name, 'look.png');
++ const added = await call('POST', '/api/comments/vod/100', 5, { message: 'A picture', attachments: [uploaded.attachment] });
++ assert.strictEqual(added.status, 201, added.text);
++ assert.deepStrictEqual(added.json.comment.attachments, [uploaded.attachment]);
++ const read = await call('GET', '/api/comments/vod/100');
++ assert.deepStrictEqual(read.json.comments.find((c) => c.id === added.json.comment.id).attachments, [uploaded.attachment]);
++ const other = await fetch(`http://127.0.0.1:${server.address().port}/api/comments/vod/101/attachments`,
++ { method: 'POST', headers: { 'x-test-user': '5' }, body: form });
++ assert.strictEqual(other.status, 404, 'a non-owner cannot upload to a private VOD');
++ assert.strictEqual((await call('POST', '/api/comments/vod/100', 5, { message: 'Too many', attachments: Array(5).fill(uploaded.attachment) })).status, 400);
++ });
++
await check('edit: the author only (as their subject); edits show as edited', async () => {
assert.strictEqual((await call('PUT', `/api/comments/${carolTop.id}`, 7, { message: 'hijack' })).status, 403);
assert.strictEqual((await call('PUT', `/api/comments/${carolTop.id}`, 1, { message: 'staff rewrite' })).status, 403, 'staff delete, they do not rewrite');
ddiff --git a/test/community-stub.js b/test/community-stub.js
iindex e4f007fb..bac8b692 100644
---- a/test/community-stub.js
++++ b/test/community-stub.js
@@@ -35,7 +35,7 @@ function startCommunityStub({ people = {} } = {}) {
author: deleted ? null : personOf(c.author), anon_name: null,
display_name: deleted ? null : ((people[c.author] || {}).display_name || null),
message: deleted ? null : c.message, deleted, score: 0, upvotes: 0, downvotes: 0, my_vote: 0,
-- reply_count: replyCount(c), created_at: c.created_at, updated_at: c.edited_at || c.created_at, edited_at: deleted ? null : c.edited_at,
++ reply_count: replyCount(c), attachments: deleted ? [] : (c.attachments || []), created_at: c.created_at, updated_at: c.edited_at || c.created_at, edited_at: deleted ? null : c.edited_at,
can_edit: !deleted && !!subject && c.author === subject, can_delete: !deleted && !!subject && c.author === subject,
};
if (withReplies) out.replies = comments.filter((r) => r.parent_id === c.id && !r.deleted_at).sort((a, b) => a.id - b.id).slice(0, 20).map((r) => shapeComment(r, subject, false));
@@@ -43,15 +43,21 @@ function startCommunityStub({ people = {} } = {}) {
}
const server = http.createServer((req, res) => {
-- let raw = '';
-- req.on('data', (c) => { raw += c; });
-- req.on('end', () => {
++ const chunks = [];
++ req.on('data', (c) => { chunks.push(c); });
++ req.on('end', async () => {
if (state.down) { req.socket.destroy(); return; }
const url = new URL(req.url, 'http://x');
const p = url.pathname.replace(/^\/api\/v1\/comments/, '');
const subject = req.headers['x-ov-subject'] || null;
const staff = req.headers['x-ov-staff'] === '1';
-- const body = raw ? JSON.parse(raw) : undefined;
++ const raw = Buffer.concat(chunks);
++ let body;
++ if (/^multipart\/form-data/.test(req.headers['content-type'] || '')) {
++ const form = await new Request('http://local/attachments', { method: 'POST', headers: { 'content-type': req.headers['content-type'] }, body: raw }).formData();
++ const file = form.get('file');
++ body = { ref: JSON.parse(form.get('ref')), file: file && { name: file.name, type: file.type, size: file.size } };
++ } else body = raw.length ? JSON.parse(raw.toString('utf8')) : undefined;
calls.push({ method: req.method, path: p, query: url.search, subject, staff, body, auth: req.headers.authorization || null, xff: req.headers['x-forwarded-for'] || null });
const send = (status, obj) => { res.statusCode = status; res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(obj)); };
const problem = (status, code, detail) => send(status, { type: 'about:blank', status, code, detail, error: detail });
@@@ -59,6 +65,13 @@ function startCommunityStub({ people = {} } = {}) {
const moderator = !subject || staff;
let m;
++ if (req.method === 'POST' && p === '/attachments') {
++ if (!subject || !body.file) return problem(400, 'attachment.invalid', 'A person and file are required');
++ const id = `med_${'0'.repeat(24)}${String(calls.length).padStart(2, '0')}`;
++ return send(201, { attachment: { id, name: body.file.name, media_type: body.file.type, size: body.file.size,
++ storage: { provider: 'media', key: id }, access: { readers: [subject] }, role: 'input' } });
++ }
++
if (req.method === 'POST' && p === '/threads/resolve') {
const ref = body.ref;
let t = threads.find((x) => x.ref.service === ref.service && x.ref.type === ref.type && x.ref.id === ref.id);
@@@ -95,7 +108,7 @@ function startCommunityStub({ people = {} } = {}) {
if (!parent || parent.thread_id !== t.id || parent.deleted_at) return problem(400, 'comment.invalid_parent', 'Invalid parent comment');
parentId = parent.parent_id || parent.id;
}
-- const c = { id: comments.length + 1, thread_id: t.id, parent_id: parentId, author: subject, message: String(body.message).trim(), created_at: now(), edited_at: null, deleted_at: null };
++ const c = { id: comments.length + 1, thread_id: t.id, parent_id: parentId, author: subject, message: String(body.message).trim(), attachments: body.attachments || [], created_at: now(), edited_at: null, deleted_at: null };
comments.push(c);
return send(201, { comment: shapeComment(c, subject, false) });
}
ddiff --git a/test/loyalty-keys.test.js b/test/loyalty-keys.test.js
iindex 65c67996..12d2199f 100644
---- a/test/loyalty-keys.test.js
++++ b/test/loyalty-keys.test.js
@@@ -93,12 +93,12 @@ wallet.credit = walletOp('credit');
assert.strictEqual(await coins.awardFollow(2, 3), null, '…a second follow bonus is refused by its key');
assert.strictEqual((await logRows('live:cp:follow:2:3')).length, 1);
const afterFollow = await cp();
++ const firstChatMinute = Math.floor(Date.now() / 60_000);
assert.ok(await coins.awardChat(2, streamId));
delete require.cache[require.resolve('../server/monetization/opencoins')];
coins = require('../server/monetization/opencoins'); // a restart: the in-memory chat cooldown is gone
-- const minute = Math.floor(Date.now() / 60_000);
const secondChat = await coins.awardChat(2, streamId);
-- if (Math.floor(Date.now() / 60_000) === minute) assert.strictEqual(secondChat, null, 'the same chat minute earns once, even after a restart');
++ if (Math.floor(Date.now() / 60_000) === firstChatMinute) assert.strictEqual(secondChat, null, 'the same chat minute earns once, even after a restart');
assert.strictEqual(await cp(), afterFollow + 5 + (secondChat ? 5 : 0));
await raw.prepare('INSERT INTO watch_time (user_id, stream_id, minutes_watched) VALUES (2, ?, 4)').run(streamId);
const watched = await coins.awardWatch(2, streamId); // minute 5 of the default 5-minute interval