openvibers/openvibe.live Commits
Carry VOD and clip comment files through Live (#818)
Changes
@@@ -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/${encodeURIComponenmodule.exports = {sameThread,-- CommentsError, TYPES, threadFor, readThread, addComment, getComment, editComment, deleteComment,++ CommentsError, TYPES, threadFor, readThread, addComment, uploadAttachment, getComment, editComment, deleteComment,hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl,};@@@ -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);@@@ -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');@@@ -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) });}@@@ -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
@@ -53,10 +53,11 @@ async function call(method, path, { body, act = {}, ip = null, timeoutMs = 5000,
@@ -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.subject) headers['X-OV-Subject'] = act.subject;
if (act.staff) headers['X-OV-Staff'] = '1';
if (act.staff) headers['X-OV-Staff'] = '1';
if (ip) headers['X-Forwarded-For'] = String(ip);
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;
let res;
try {
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) {
} catch (err) {
console.warn(`[Comments] Community unreachable (${method} ${path}): ${err.message}`);
console.warn(`[Comments] Community unreachable (${method} ${path}): ${err.message}`);
throw unavailable('unreachable');
throw unavailable('unreachable');
@@ -114,8 +115,15 @@ async function readThread(threadId, { subject = null, timeoutMs, ip, ...q } = {}
@@ -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 });
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. */
/** 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
@@ -167,6 +175,6 @@ const threadUrl = (accessId) => `${COMMUNITY_PUBLIC_URL()}/c/${encodeURIComponen
module.exports = {
module.exports = {
sameThread,
sameThread,
- CommentsError, TYPES, threadFor, readThread, addComment, getComment, editComment, deleteComment,
+ CommentsError, TYPES, threadFor, readThread, addComment, uploadAttachment, getComment, editComment, deleteComment,
hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl,
hideThreadOf, liveUserForSubject, subjectForLiveUser, threadUrl,
};
};
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
*/
*/
'use strict';
'use strict';
const express = require('express');
const express = require('express');
+const multer = require('multer');
const db = require('../db/database');
const db = require('../db/database');
const media = require('../media-client');
const media = require('../media-client');
const comments = require('../comments-client');
const comments = require('../comments-client');
@@ -30,6 +31,7 @@ const MAX_MESSAGE = 2000;
@@ -30,6 +31,7 @@ const MAX_MESSAGE = 2000;
const MAX_OFFSET = 1000;
const MAX_OFFSET = 1000;
const MAX_REPLIES = 500;
const MAX_REPLIES = 500;
const HIDDEN = { error: 'Comments are hidden here', code: 'comments_hidden' };
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) {
function truncatePreview(text, max = 120) {
const clean = String(text || '').replace(/\s+/g, ' ').trim();
const clean = String(text || '').replace(/\s+/g, ' ').trim();
@@ -99,6 +101,8 @@ async function shape(c, ctx) {
@@ -99,6 +101,8 @@ async function shape(c, ctx) {
user_id: u ? u.id : null,
user_id: u ? u.id : null,
subject: a ? a.subject || null : null,
subject: a ? a.subject || null : null,
parent_id: c.parent_id || 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,
message: c.deleted ? '' : c.message,
is_deleted: c.deleted ? 1 : 0,
is_deleted: c.deleted ? 1 : 0,
deleted: !!c.deleted,
deleted: !!c.deleted,
@@ -112,6 +116,7 @@ async function shape(c, ctx) {
@@ -112,6 +116,7 @@ async function shape(c, ctx) {
role: u && !c.deleted ? u.role : null,
role: u && !c.deleted ? u.role : null,
is_ai: !!(a && a.is_ai),
is_ai: !!(a && a.is_ai),
reply_count: c.reply_count || 0,
reply_count: c.reply_count || 0,
+ attachments: c.deleted ? [] : (c.attachments || []),
can_edit: !!(ctx.subject && a && a.subject === ctx.subject && !c.deleted),
can_edit: !!(ctx.subject && a && a.subject === ctx.subject && !c.deleted),
can_delete: !!await deleteRight(ctx.user, ctx.subject, c, ctx.target),
can_delete: !!await deleteRight(ctx.user, ctx.subject, c, ctx.target),
};
};
@@ -175,12 +180,13 @@ router.get('/:type/:id', optionalAuth, async (req, res) => {
@@ -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 thread = await comments.threadFor(contentType, contentId, target.row.title, { ip: req.ip });
const ctx = { type: contentType, id: contentId, user: req.user, subject, target };
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 = [];
const picked = [];
let skipped = 0, after = null, total = 0;
let skipped = 0, after = null, total = 0;
for (;;) {
for (;;) {
let page;
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; }
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).
// 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);
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) => {
@@ -206,6 +212,24 @@ router.get('/:type/:id', optionalAuth, async (req, res) => {
});
});
// ── Add Comment ──────────────────────────────────────────────
// ── 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) => {
router.post('/:type/:id', requireAuth, async (req, res) => {
try {
try {
const contentType = req.params.type;
const contentType = req.params.type;
@@ -233,7 +257,9 @@ router.post('/:type/:id', requireAuth, async (req, res) => {
@@ -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 comment = await shape(out.comment, { type: contentType, id: contentId, user: req.user, subject, target });
const actor = notificationActor(req.user);
const actor = notificationActor(req.user);
@@ -178,6 +178,8 @@ async function check(name, fn) {
@@ -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)');
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;
const page = (await call('GET', '/api/comments/vod/100?limit=1&offset=1')).json;
assert.deepStrictEqual(page.comments.map((c) => c.message), ['great stream']);
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');
assert.ok(!r.text.includes('legacy local row'), 'Live\'s own table is not read');
});
});
@@ -189,6 +191,28 @@ async function check(name, fn) {
@@ -189,6 +191,28 @@ async function check(name, fn) {
assert.strictEqual((await call('GET', '/api/comments/424242/replies')).status, 404);
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 () => {
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}`, 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');
assert.strictEqual((await call('PUT', `/api/comments/${carolTop.id}`, 1, { message: 'staff rewrite' })).status, 403, 'staff delete, they do not rewrite');
@@ -35,7 +35,7 @@ function startCommunityStub({ people = {} } = {}) {
@@ -35,7 +35,7 @@ function startCommunityStub({ people = {} } = {}) {
author: deleted ? null : personOf(c.author), anon_name: null,
author: deleted ? null : personOf(c.author), anon_name: null,
display_name: deleted ? null : ((people[c.author] || {}).display_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,
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,
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));
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 = {} } = {}) {
@@ -43,15 +43,21 @@ function startCommunityStub({ people = {} } = {}) {
}
}
const server = http.createServer((req, res) => {
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; }
if (state.down) { req.socket.destroy(); return; }
const url = new URL(req.url, 'http://x');
const url = new URL(req.url, 'http://x');
const p = url.pathname.replace(/^\/api\/v1\/comments/, '');
const p = url.pathname.replace(/^\/api\/v1\/comments/, '');
const subject = req.headers['x-ov-subject'] || null;
const subject = req.headers['x-ov-subject'] || null;
const staff = req.headers['x-ov-staff'] === '1';
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 });
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 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 });
const problem = (status, code, detail) => send(status, { type: 'about:blank', status, code, detail, error: detail });
@@ -59,6 +65,13 @@ function startCommunityStub({ people = {} } = {}) {
@@ -59,6 +65,13 @@ function startCommunityStub({ people = {} } = {}) {
const moderator = !subject || staff;
const moderator = !subject || staff;
let m;
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') {
if (req.method === 'POST' && p === '/threads/resolve') {
const ref = body.ref;
const ref = body.ref;
let t = threads.find((x) => x.ref.service === ref.service && x.ref.type === ref.type && x.ref.id === ref.id);
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 = {} } = {}) {
@@ -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');
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;
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);
comments.push(c);
return send(201, { comment: shapeComment(c, subject, false) });
return send(201, { comment: shapeComment(c, subject, false) });
}
}
@@ -93,12 +93,12 @@ wallet.credit = walletOp('credit');
@@ -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 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);
assert.strictEqual((await logRows('live:cp:follow:2:3')).length, 1);
const afterFollow = await cp();
const afterFollow = await cp();
+ const firstChatMinute = Math.floor(Date.now() / 60_000);
assert.ok(await coins.awardChat(2, streamId));
assert.ok(await coins.awardChat(2, streamId));
delete require.cache[require.resolve('../server/monetization/opencoins')];
delete require.cache[require.resolve('../server/monetization/opencoins')];
coins = require('../server/monetization/opencoins'); // a restart: the in-memory chat cooldown is gone
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);
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));
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);
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
const watched = await coins.awardWatch(2, streamId); // minute 5 of the default 5-minute interval