openvibers/openvibe.community Commits
Add attachments to Community comments (#350)
Changes
nnew file mode 100644@@@ -0,0 +1,17 @@++-- phase: expand++-- A comment can claim up to four file references uploaded through the owning service.++CREATE TABLE comment_attachments (++ media_id text COLLATE "C" PRIMARY KEY,++ ref_service text COLLATE "C" NOT NULL,++ ref_type text COLLATE "C" NOT NULL,++ ref_id text COLLATE "C" NOT NULL,++ comment_id bigint REFERENCES comments(id) ON DELETE CASCADE,++ owner_subject text COLLATE "C" NOT NULL,++ filename text COLLATE "C" NOT NULL,++ mime text COLLATE "C" NOT NULL,++ size_bytes bigint NOT NULL CHECK (size_bytes >= 0),++ visibility text COLLATE "C" NOT NULL DEFAULT 'unlisted' CHECK (visibility IN ('private', 'unlisted')),++ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()++);++CREATE INDEX comment_attachments_owner ON comment_attachments(owner_subject, created_at);++CREATE INDEX comment_attachments_comment ON comment_attachments(comment_id, media_id) WHERE comment_id IS NOT NULL;@@@ -261,7 +261,7 @@ async function createApp(opts = {}) {// Sharing (openvibe-sdk/share): the one access check for a paste, its members, transfers and the// gate. Created before comments so a thread on a paste nobody may see is never readable.const sharing = opts.sharing || createSharing({ db, media: pasteMedia, fetchImpl: opts.fetchImpl });-- const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p) });++ const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p), media: mediaObjects });// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });discovery.useForum(forum);@@@ -22,6 +22,7 @@* community.comment.moderate for visibility and moderation. Errors are problem+json.*/const express = require('express');++const multer = require('multer');const contracts = require('openvibe-contracts');const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');@@@ -37,10 +38,36 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter,// refused ones included, and every write that has no content limit.router.use(limits.reads('community.comment.read'));const moderate = limits('community.comment.moderate', { minute: 60, hour: 600 });++ const attachmentUpload = limits('community.comment.attachment.upload', { minute: 10, hour: 100 });++ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024, files: 1, fields: 5 } }).single('file');++ const parseUpload = (req, res, next) => upload(req, res, (err) => {++ if (!err) return next();++ if (err instanceof multer.MulterError) {++ const tooLarge = err.code === 'LIMIT_FILE_SIZE';++ return contracts.http.sendProblem(res, tooLarge ? 413 : 400, tooLarge ? 'attachment.too_large' : 'attachment.invalid_upload', {++ detail: tooLarge ? 'Files are limited to 16 MB' : 'Upload one file with its item reference', ctx: req.ov,++ });++ }++ return next(err);++ });const read = serviceAnyCap([WRITE, MOD]);const passAnon = (_req, _res, next) => next();++ router.post('/attachments', attachmentUpload, parseUpload, run(async (req) => {++ let ref = req.body && req.body.ref;++ if (typeof ref === 'string') { try { ref = JSON.parse(ref); } catch { ref = null; } }++ if (!ref && req.body) ref = { service: req.body.service, type: req.body.type, id: req.body.id };++ return await service.uploadAttachment(req.viewer, ref, req.file);++ }, 201));++++ router.post('/attachments', attachmentUpload, upload, run(async (req) => {++ let ref = req.body && req.body.ref;++ if (typeof ref === 'string') { try { ref = JSON.parse(ref); } catch { ref = null; } }++ if (!ref && req.body) ref = { service: req.body.service, type: req.body.type, id: req.body.id };++ return await service.uploadAttachment(req.viewer, ref, req.file);++ }, 201));++// Opening a thread writes a row the first time: a person opens at most one a second.router.post('/threads/resolve', serviceCap(WRITE), limits('community.thread.resolve', { minute: 60, hour: 1200 }), resolveLimiter || passAnon, jsonBody,run(async (req) => await service.resolve(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++++/** The attachment reference shape shared with Chat (ai.file@1). Bytes live in Media. */++const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;++const NAME_MAX = 255;++++function cleanReference(raw, subject) {++ const value = raw && typeof raw === 'object' ? raw : {};++ const id = String(value.id || '');++ if (!MEDIA_ID.test(id) || !value.storage || value.storage.provider !== 'media' || String(value.storage.key) !== id) {++ const err = new Error('Each attachment must be a stored Media object reference');++ err.status = 400; err.code = 'comment.attachment_invalid'; throw err;++ }++ const mediaType = String(value.media_type || 'application/octet-stream').split(';')[0].trim().slice(0, NAME_MAX) || 'application/octet-stream';++ const name = String(value.name || 'file').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, NAME_MAX) || 'file';++ return {++ id, name, media_type: mediaType, size: Math.max(0, Math.floor(Number(value.size) || 0)),++ storage: { provider: 'media', key: id },++ access: { readers: [subject] },++ read: { method: 'not_readable', reason: 'Attached; open the comment to read it.' },++ role: 'input',++ };++}++++function publicReference(row) {++ return {++ id: row.media_id, name: row.filename || 'file', media_type: row.mime || 'application/octet-stream',++ size: Number(row.size_bytes) || 0, storage: { provider: 'media', key: row.media_id },++ access: { readers: [row.owner_subject] }, read: { method: 'not_readable', reason: 'Attached to a comment.' },++ role: 'input',++ };++}++++module.exports = { MEDIA_ID, cleanReference, publicReference };@@@ -44,6 +44,7 @@ const { createAuthors } = require('../identity/authors');const { createPersonLimiter } = require('../limits');const { discussionModerator: moderator } = require('../identity/capabilities');const blocks = require('../identity/blocks');++const commentAttachments = require('./attachments');/*** The entity types a browser may open a thread for (services with community.comment.write: any).@@@ -70,7 +71,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;const PAGE = 30;const REPLY_PAGE = 20;--function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch } = {}) {++function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch, media = null } = {}) {const authors = createAuthors({ db, network });const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });@@@ -112,6 +113,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =edited_at: deleted ? null : isoTime(c.edited_at),can_edit: !deleted && !!(v && v.subject && c.author_subject === v.subject),can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),++ attachments: deleted ? [] : (c.attachments || []).map((row) => commentAttachments.publicReference(row)),};if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));return out;@@@ -224,6 +226,35 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =return { thread: shapeThread(thread, v), created };},++ /** Upload one attachment to Community, which owns Community paste comments. */++ async uploadAttachment(v, ref, file) {++ if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to attach a file');++ if (!ref || ref.service !== 'community' || ref.type !== 'paste') fail(403, 'attachment.owner_service', 'Upload this attachment through the service that owns the item');++ await checkCommunityRef(ref, v);++ if (!file || !Buffer.isBuffer(file.buffer) || !file.buffer.length) fail(400, 'attachment.missing', 'Choose a file');++ if (file.buffer.length > 16 * 1024 * 1024) fail(413, 'attachment.too_large', 'Files are limited to 16 MB');++ if (!media || !media.configured || typeof media.uploadImage !== 'function') fail(503, 'attachment.unavailable', 'Files cannot be attached right now');++ const filename = String(file.originalname || 'file').replace(/[\\/\\\\\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 255) || 'file';++ let uploaded;++ try {++ uploaded = await media.uploadImage({ buffer: file.buffer, filename, mime: file.mimetype || 'application/octet-stream', owner: v.subject,++ kind: 'file', visibility: 'unlisted', source: 'community.comment' });++ } catch (err) { fail(err.status === 413 ? 413 : 502, 'attachment.upload_failed', 'The file could not be stored. Try again.'); }++ let object;++ try { object = await media.object(uploaded.id); }++ catch (err) { try { await media.deleteObject(uploaded.id); } catch { /* the uploaded object remains private/unlisted */ } fail(502, 'attachment.invalid', 'The stored file could not be verified'); }++ if (!object || !object.owner || object.owner.subject !== v.subject || object.lifecycle_status !== 'ready') {++ try { await media.deleteObject(uploaded.id); } catch { /* best effort cleanup */ }++ fail(400, 'attachment.invalid', 'The stored file is not owned by you');++ }++ const row = await store.createAttachment(db, {++ media_id: uploaded.id, ref_service: ref.service, ref_type: ref.type, ref_id: String(ref.id), owner_subject: v.subject,++ filename: String((object.metadata && object.metadata.filename) || filename).slice(0, 255), mime: String(object.mime_type || file.mimetype || 'application/octet-stream'),++ size_bytes: Number(object.size_bytes) || file.buffer.length, visibility: 'unlisted',++ });++ return { attachment: commentAttachments.publicReference(row) };++ },++/*** The thread of an entity as the entity's own page shows it (Community's paste page): get() found by* ref, after the same checks as resolve; reading never opens a thread, so null while nobody has@@@ -299,7 +330,27 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =}const key = author ? `s:${author}` : null;commentLimiter.check(key, message);-- const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });++ const rawAttachments = body.attachments == null ? [] : (Array.isArray(body.attachments) ? body.attachments : [body.attachments]);++ if (rawAttachments.length > 4) fail(400, 'comment.attachments_too_many', 'At most four files can be attached to a comment');++ if (rawAttachments.length && !author) fail(401, 'auth.required', 'Sign in to attach a file');++ let attachmentIds = [];++ if (rawAttachments.length) {++ if (t.ref_service !== 'community' || t.ref_type !== 'paste') fail(403, 'attachment.owner_service', 'Upload attachments through the service that owns the item');++ const refs = rawAttachments.map((raw) => {++ try { return commentAttachments.cleanReference(raw, author); }++ catch (err) { fail(err.status || 400, err.code || 'comment.attachment_invalid', err.message); }++ });++ attachmentIds = refs.map((a) => a.id);++ if (new Set(attachmentIds).size !== attachmentIds.length) fail(400, 'comment.attachment_invalid', 'A file can only be attached once');++ const staged = await store.stagedAttachments(db, attachmentIds, author, { service: t.ref_service, type: t.ref_type, id: t.ref_id });++ if (!staged) fail(400, 'comment.attachment_invalid', 'Attach files uploaded through the service that owns this item');++ for (const row of staged) {++ let object;++ try { object = await media.object(row.media_id); } catch { fail(400, 'comment.attachment_invalid', 'One of the attached files is no longer available'); }++ if (!object || !object.owner || object.owner.subject !== author || object.lifecycle_status !== 'ready') fail(400, 'comment.attachment_invalid', 'One of the attached files is not yours');++ }++ }++ const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message, attachment_ids: attachmentIds });commentLimiter.record(key, message);return { comment: (await shapeComments([c], v, t))[0] };},@@@ -332,6 +383,12 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);if (!isAuthor && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');await store.softDeleteComment(db, c.id, v.subject || v.service || null);++ for (const a of c.attachments || []) {++ if (a.ref_service === 'community' && media && typeof media.deleteObject === 'function') {++ try { await media.deleteObject(a.media_id); }++ catch (err) { console.warn(`[Comments] removed attachment ${a.media_id} from comment; Media cleanup failed: ${err.message}`); }++ }++ }return { ok: true, id: c.id };},@@@ -49,7 +49,34 @@ async function setThreadVisibility(db, id, visibility) {}async function getComment(db, id) {-- return await db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;++ const row = await db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;++ if (row) row.attachments = await listAttachments(db, [row.id]).then((m) => m.get(row.id) || []);++ return row;++}++++/** References staged by the owning service, grouped by comment id. */++async function listAttachments(db, commentIds) {++ const out = new Map();++ const ids = [...new Set((commentIds || []).map(Number).filter(Number.isSafeInteger))];++ for (const id of ids) out.set(id, []);++ if (!ids.length) return out;++ const rows = await db.prepare(`SELECT * FROM comment_attachments WHERE comment_id IN (${ids.map(() => '?').join(',')}) ORDER BY comment_id, created_at, media_id`).all(...ids);++ for (const row of rows) if (out.has(row.comment_id)) out.get(row.comment_id).push(row);++ return out;++}++++async function createAttachment(db, row) {++ await db.prepare(`INSERT INTO comment_attachments (media_id, ref_service, ref_type, ref_id, owner_subject, filename, mime, size_bytes, visibility)++ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)++ .run(row.media_id, row.ref_service, row.ref_type, row.ref_id, row.owner_subject, row.filename, row.mime, row.size_bytes, row.visibility);++ return await db.prepare('SELECT * FROM comment_attachments WHERE media_id = ?').get(row.media_id);++}++++async function stagedAttachments(db, ids, subject, ref) {++ if (!ids.length) return [];++ const rows = await db.prepare(`SELECT * FROM comment_attachments WHERE media_id IN (${ids.map(() => '?').join(',')})++ AND comment_id IS NULL AND owner_subject = ? AND ref_service = ? AND ref_type = ? AND ref_id = ?`).all(...ids, subject, ref.service, ref.type, String(ref.id));++ return rows.length === ids.length ? rows : null;}/**@@@ -71,10 +98,17 @@ async function refIsPublic(db, t) {}/** Insert a comment and keep the thread's and parent's counters in step. */--async function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {++async function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message, attachment_ids = [] }) {return await db.tx(async () => {++ const thread = await getThread(db, thread_id);++ const staged = await stagedAttachments(db, attachment_ids, author_subject, thread && { service: thread.ref_service, type: thread.ref_type, id: thread.ref_id });++ if (!staged) { const err = new Error('Use attachments uploaded for this item'); err.status = 400; err.code = 'comment.attachment_invalid'; throw err; }const info = await db.prepare('INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?) RETURNING id').run(thread_id, parent_id, author_subject, anon_name, origin, message);++ for (const row of staged) {++ const claimed = await db.prepare('UPDATE comment_attachments SET comment_id = ? WHERE media_id = ? AND comment_id IS NULL').run(info.lastInsertRowid, row.media_id);++ if (!claimed.changes) { const err = new Error('This attachment was already used'); err.status = 400; err.code = 'comment.attachment_invalid'; throw err; }++ }await db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = ov_now() WHERE id = ?').run(thread_id);if (parent_id) await db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);const comment = await getComment(db, info.lastInsertRowid);@@@ -96,6 +130,7 @@ async function softDeleteComment(db, id, deletedBy = null) {const c = await getComment(db, id);if (!c || c.deleted_at) return 0;await db.prepare("UPDATE comments SET deleted_at = ov_now(), deleted_by = ?, message = '', updated_at = ov_now() WHERE id = ?").run(deletedBy, id);++ await db.prepare('DELETE FROM comment_attachments WHERE comment_id = ?').run(id);await db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = ov_now() WHERE id = ?').run(c.thread_id, c.thread_id);if (c.parent_id) await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);return 1;@@@ -120,6 +155,9 @@ async function listTopLevel(db, threadId, { after = null, sort = 'old', limit =if (hasMore) rows.pop();const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');for (const r of rows) r.replies = await replies.all(r.id, replyLimit);++ const all = rows.concat(rows.flatMap((r) => r.replies || []));++ const attachments = await listAttachments(db, all.map((r) => r.id));++ for (const row of all) row.attachments = attachments.get(row.id) || [];return { rows, hasMore };}@@@ -129,6 +167,8 @@ async function listReplies(db, parentId, { after = null, limit = 50 } = {}) {.all(...(after != null ? [parentId, after] : [parentId]), limit + 1);const hasMore = rows.length > limit;if (hasMore) rows.pop();++ const attachments = await listAttachments(db, rows.map((r) => r.id));++ for (const row of rows) row.attachments = attachments.get(row.id) || [];return { rows, hasMore };}@@@ -140,7 +180,10 @@ async function listNewest(db, threadId, { limit = 50, offset = 0 } = {}) {const top = await db.prepare('SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?').all(threadId, limit, offset);const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC');for (const c of top) { c.replies = await replies.all(c.id); c.reply_count = c.replies.length; }++ const all = top.concat(top.flatMap((r) => r.replies || []));++ const attachments = await listAttachments(db, all.map((r) => r.id));++ for (const row of all) row.attachments = attachments.get(row.id) || [];return top;}--module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, editComment, softDeleteComment, listTopLevel, listReplies, listNewest };++module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, listAttachments, createAttachment, stagedAttachments, insertComment, editComment, softDeleteComment, listTopLevel, listReplies, listNewest };@@@ -35,6 +35,7 @@ const inList = (xs) => `(${xs.map(() => '?').join(',')})`;const EXPORTS = [['pastes.json', 'pastes', 'owner_subject'], ['paste_comments.json', 'paste_comments', 'author_subject'], ['comments.json', 'comments', 'author_subject'],['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],++ ['comment_attachments.json', 'comment_attachments', 'owner_subject'],['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],['post_reactions.json', 'post_reactions', 'subject_id'], ['section_reactions.json', 'thread_section_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],@@@ -142,7 +143,10 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {['comments', 'author_subject = NULL, anon_name = NULL, message = ?, deleted_at = COALESCE(deleted_at, ?)']]) {if (!await hasTable(db, table)) continue;const { keep, drop } = await splitByReplies(db, table, subjects);-- for (const id of keep) await db.prepare(`UPDATE ${table} SET ${tomb} WHERE id = ?`).run(TOMBSTONE, now, id);++ for (const id of keep) {++ if (table === 'comments' && await hasTable(db, 'comment_attachments')) await db.prepare('DELETE FROM comment_attachments WHERE comment_id = ?').run(id);++ await db.prepare(`UPDATE ${table} SET ${tomb} WHERE id = ?`).run(TOMBSTONE, now, id);++ }for (const id of drop) await db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);add(erased, table, drop.length);add(retained, 'tombstones', keep.length);@@@ -165,6 +169,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();}if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);++ if (await hasTable(db, 'comment_attachments')) add(erased, 'comment_attachments', (await db.prepare(`DELETE FROM comment_attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);// Their submissions go (Pulse items with them, below); a decision they made stays, unsigned.if (await hasTable(db, 'submissions')) {for (const r of await db.prepare(`SELECT slug FROM submissions WHERE author_subject IN ${S}`).all(...subjects)) {@@@ -268,6 +268,14 @@ route('post', '/api/v1/submissions/{slug}/review', {});// ── /api/v1/comments — the comment threads other products embed (server/comments/api.js) ─────++route('post', '/api/v1/comments/attachments', {++ operationId: 'uploadCommentAttachment', summary: 'Upload one comment attachment through the item owner', tags: ['comments'], security: PERSON,++ requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['ref', 'file'], properties: {++ ref: { type: 'string', description: 'JSON EntityRef, for example {"service":"community","type":"paste","id":"…"}' },++ file: { type: 'string', format: 'binary' },++ } } } } },++ responses: { 201: json('A staged ai.file@1 reference to pass in the comment write', { type: 'object', properties: { attachment: { type: 'object' } } }), ...errors([400, 401, 403, 413, 429, 502, 503]) },++});route('post', '/api/v1/comments/threads/resolve', {operationId: 'resolveThread', summary: 'Open the thread for an item (creating it the first time)', tags: ['comments'], security: PERSON,description: 'community.thread.resolve. Idempotent: 201 when it created the thread, 200 when it already existed.',@@@ -290,6 +298,7 @@ route('post', '/api/v1/comments/threads/{id}/comments', {description: 'community.comment.create. Anonymous visitors may comment on public threads with anon_name.',requestBody: body({ type: 'object', required: ['message'], properties: {message: str(), parent_id: nullable('integer', { minimum: 1 }), anon_name: str({ maxLength: 32 }),++ attachments: { type: 'array', maxItems: 4, items: ref('AiFile') },} }),responses: { 201: json('The comment', { type: 'object', properties: { comment: ref('Comment') } }), ...errors() },});@@@ -809,7 +818,14 @@ function openapi() {id: { type: 'integer' }, thread_id: str(), parent_id: nullable('integer'), origin: str(), author: nullable('object'),anon_name: nullable('string'), display_name: nullable('string'), message: nullable('string'), deleted: { type: 'boolean' },score: { type: 'integer' }, upvotes: { type: 'integer' }, downvotes: { type: 'integer' }, my_vote: { type: 'integer' },-- created_at: str(), updated_at: str(), replies: arrayOf({ type: 'object' }),++ created_at: str(), updated_at: str(), replies: arrayOf({ type: 'object' }), attachments: arrayOf(ref('AiFile')),++ } },++ AiFile: { type: 'object', required: ['id', 'name', 'media_type', 'size', 'storage', 'access', 'read'], properties: {++ id: { type: 'string', pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' }, name: str(), media_type: str(), size: { type: 'integer', minimum: 0 },++ storage: { type: 'object', required: ['provider', 'key'], properties: { provider: { const: 'media' }, key: str() } },++ access: { type: 'object', required: ['readers'], properties: { readers: arrayOf(str()) } },++ read: { type: 'object', required: ['method'], properties: { method: { type: 'string', enum: ['text', 'pages', 'image', 'audio_transcript', 'not_readable'] }, content: str(), pages: arrayOf(str()), reason: str() } },++ role: { type: 'string', enum: ['input', 'artifact'] },} },Space: { type: 'object', required: ['slug', 'name'], properties: {slug: str(), name: str(), description: nullable('string'), visibility: { type: 'string', enum: ['public', 'members', 'staff'] },@@@ -313,6 +313,7 @@ function createPasteService({ db, network = null, media = null, config = {}, limid: c.id, paste_id: p.id, user_id: c.author_subject || null, author_subject: c.author_subject || null,parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.deleted_at ? 1 : 0,created_at: c.created_at, updated_at: c.updated_at,++ attachments: c.deleted_at ? [] : (c.attachments || []).map((a) => ({ id: a.media_id, name: a.filename || 'file', media_type: a.mime, size: Number(a.size_bytes) || 0, storage: { provider: 'media', key: a.media_id }, access: { readers: [a.owner_subject] }, read: { method: 'not_readable', reason: 'Attached to a comment.' }, role: 'input' })),...authorFields(c.author_subject, c.origin, projections),};if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, p, projections)); out.reply_count = c.reply_count; }@@@ -39,10 +39,24 @@ function commentHtml(c) {return `<article class="post comment" id="comment-${c.id}"><header class="post-head">${who(c.author || (c.anon_name ? { display_name: c.anon_name } : null))} <span class="sep">·</span> <a class="muted small" href="#comment-${c.id}">${timeTag(c.created_at)}</a>${edited}</header><p class="comment-text">${esc(c.message)}</p>++ ${attachmentsHtml(c.attachments)}${repliesHtml(c)}</article>`;}++function attachmentsHtml(items) {++ const refs = Array.isArray(items) ? items : [];++ if (!refs.length) return '';++ return `<ul class="comment-attachments">${refs.map((a) => {++ const url = `${config.mediaUrl}/o/${encodeURIComponent(String(a.id || ''))}`;++ if (!/^https:\/\//i.test(url)) return '';++ const name = esc(a.name || 'file');++ const type = String(a.media_type || '');++ const body = type.startsWith('image/') ? `<img src="${esc(url)}" alt="${name}" loading="lazy">` : name;++ return `<li><a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${body}</a></li>`;++ }).join('')}</ul>`;++}++function repliesHtml(c) {if (!c.replies || !c.replies.length) return '';const more = c.reply_count > c.replies.length ? `<p class="muted small">${num(c.reply_count - c.replies.length)} more ${c.reply_count - c.replies.length === 1 ? 'reply' : 'replies'} on the original page.</p>` : '';@@@ -45,6 +45,8 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;await db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (3, ?)').run(DANA);await db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'vod', '9')").run();await db.prepare("INSERT INTO comments (id, thread_id, parent_id, author_subject, message, score, upvotes, downvotes, reply_count) OVERRIDING SYSTEM VALUE VALUES (1, 1, NULL, ?, 'answered', 0, 0, 0, 1), (2, 1, 1, ?, 'reply', 1, 1, 0, 0), (3, 1, NULL, ?, 'alone', 0, 0, 0, 0)").run(DANA, OTTO, DANA);++ await db.prepare(`INSERT INTO comment_attachments (media_id, ref_service, ref_type, ref_id, comment_id, owner_subject, filename, mime, size_bytes)++ VALUES ('med_01JAB2C3D4E5F6G7H8J9K0MNP6', 'live', 'vod', '9', 1, ?, 'reply.png', 'image/png', 17)`).run(DANA);await db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (2, ?, 1)').run(DANA);await db.prepare("INSERT INTO spaces (id, slug, name, created_by) OVERRIDING SYSTEM VALUE VALUES (901, 'dana-space', 'Dana space', ?)").run(DANA);await db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) OVERRIDING SYSTEM VALUE VALUES (1, 901, 't1', 'busy', ?, 0), (2, 901, 't2', 'quiet', ?, 1)").run(DANA, DANA);@@@ -82,6 +84,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;assert.deepStrictEqual(files['pastes.json'].map((p) => p.slug).sort(), ['p1', 'p2'], 'her pastes, with their content');assert.strictEqual(files['pastes.json'].find((p) => p.slug === 'p1').content, 'secret words');assert.ok(files['comments.json'] && files['posts.json'] && files['blocks.json'] && files['game_progress.json']);++ assert.strictEqual(files['comment_attachments.json'][0].media_id, 'med_01JAB2C3D4E5F6G7H8J9K0MNP6');r = await post(exp);assert.strictEqual(r.json.outcome, 'unchanged');assert.strictEqual(sent.length, 1);@@@ -104,6 +107,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [{ id: 1, a: null, message: '[deleted]', score: 0, reply_count: 1 }, { id: 2, a: OTTO, message: 'reply', score: 0, reply_count: 0 }], 'votes gone and the score recomputed');++ assert.strictEqual((await all('SELECT * FROM comment_attachments')).length, 0, 'comment attachments are removed from a deleted account');assert.deepStrictEqual(await all('SELECT id, author_subject AS a, title FROM threads ORDER BY id'), [{ id: 1, a: null, title: '[deleted]' }], 'the busy thread a tombstone, the quiet one gone');assert.deepStrictEqual(await all('SELECT id, author_subject AS a, body_markdown AS b FROM posts ORDER BY id'), [{ id: 1, a: null, b: '[deleted]' }, { id: 2, a: OTTO, b: 'otto answers' }]);assert.strictEqual((await all('SELECT * FROM post_reactions')).length + (await all('SELECT * FROM paste_likes')).length, 0);nnew file mode 100644@@@ -0,0 +1,25 @@++'use strict';++++const assert = require('node:assert/strict');++const { cleanReference, publicReference } = require('../server/comments/attachments');++const { createCommentsApi } = require('../server/comments/api');++const { commentPanel } = require('../server/render/comments');++++const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const ID = 'med_01JAB2C3D4E5F6G7H8J9K0MNP6';++++const input = { id: ID, name: 'look.png', media_type: 'image/png', size: 12,++ storage: { provider: 'media', key: ID }, access: { readers: [SUBJECT] },++ read: { method: 'not_readable', reason: 'Attached; open the comment to read it.' }, role: 'input' };++assert.deepEqual(cleanReference(input, SUBJECT), input);++assert.throws(() => cleanReference({ ...input, storage: { provider: 'media', key: 'different' } }, SUBJECT), { code: 'comment.attachment_invalid' });++const output = publicReference({ media_id: ID, filename: 'look.png', mime: 'image/png', size_bytes: 12, owner_subject: SUBJECT });++assert.deepEqual(Object.keys(output).sort(), Object.keys(input).sort());++assert.equal(output.id, input.id);++assert.equal(output.storage.key, input.storage.key);++const html = commentPanel({ page: { thread: { ref: { service: 'community', type: 'paste', id: 'paste-1' }, comment_count: 1 },++ comments: [{ id: 1, message: 'Picture', created_at: null, author: { display_name: 'Owner' }, attachments: [output] }],++ viewer: { can_comment: false } }, user: null, base: '/p/paste-1' });++assert.ok(html.includes(`<img src="https://openvibe.media/o/${ID}" alt="look.png" loading="lazy">`));++assert.ok(html.includes('rel="noopener noreferrer"'));++assert.equal(typeof createCommentsApi, 'function');@@@ -180,6 +180,42 @@ const { checkCapability } = require('../server/identity/capabilities');assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}?after=abc`)).status, 400);});++ await check('comment attachments upload through Community, are owner-validated, render on both paste and thread reads, and clear on removal', async () => {++ const fileOwner = net.addUser({ network_user_id: 27, username: 'fileowner', display_name: 'File Owner' });++ const fileOwnerJwt = net.sign({ id: 27, subject_id: fileOwner.subject_id, username: 'fileowner', display_name: 'File Owner', role: 'user' });++ const stranger = net.addUser({ network_user_id: 28, username: 'attachmentstranger', display_name: 'Attachment Stranger' });++ const strangerJwt = net.sign({ id: 28, subject_id: stranger.subject_id, username: 'attachmentstranger', display_name: 'Attachment Stranger', role: 'user' });++ const slug = (await t.app.locals.pastes.createText({ kind: 'user', subject: fileOwner.subject_id, origin: 'user' }, { content: 'comment file', visibility: 'public' })).slug;++ const ref = { service: 'community', type: 'paste', id: slug };++ const form = new FormData();++ form.set('ref', JSON.stringify(ref));++ form.set('file', new Blob([Buffer.from('picture bytes')], { type: 'image/png' }), 'look.png');++ const uploaded = await t.get('/api/v1/comments/attachments', { method: 'POST', headers: { authorization: `Bearer ${fileOwnerJwt}` }, body: form });++ assert.strictEqual(uploaded.status, 201, uploaded.text);++ const attachment = uploaded.json().attachment;++ assert.strictEqual(attachment.name, 'look.png');++ assert.strictEqual(attachment.storage.provider, 'media');++ assert.deepStrictEqual(attachment.access.readers, [fileOwner.subject_id]);++++ const thread = (await resolve(ref)).json().thread;++ const added = await post(thread.id, { message: 'with picture', attachments: [attachment] }, { cookie: fileOwnerJwt });++ assert.strictEqual(added.status, 201, added.text);++ assert.deepStrictEqual(added.json().comment.attachments.map((a) => a.id), [attachment.id]);++ const paste = (await call(`/api/pastes/${slug}/comments`)).json();++ assert.deepStrictEqual(paste.comments[0].attachments.map((a) => a.id), [attachment.id]);++ const html = await t.get(`/c/${thread.id}`);++ assert.match(html.text, new RegExp(`<img src="https://openvibe\\.media/o/${attachment.id}"`));++++ const stolen = await post(thread.id, { message: 'stolen', attachments: [attachment] }, { cookie: strangerJwt });++ assert.strictEqual(stolen.status, 400);++ const removed = await call(`/api/v1/comments/${added.json().comment.id}`, { method: 'DELETE', cookie: fileOwnerJwt });++ assert.strictEqual(removed.status, 200);++ const after = (await call(`/api/v1/comments/threads/${thread.id}`)).json();++ assert.deepStrictEqual(after.comments, []);++ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM comment_attachments WHERE media_id = ?').get(attachment.id)).n, 0);++ assert.ok(t.media.deletes.includes(attachment.id), 'removing the comment retires its Community Media object');++ });++await check('section reactions: one thread per post, the section id is the key; people only', async () => {const ref = { service: 'blog', type: 'post', id: 'pst_1' };const thread = (await resolve(ref, { cookie: alexJwt })).json().thread;@@@ -63,7 +63,7 @@ function start({ publicPem, issuer }) {if (!v2 && !normalize && (req.method !== 'POST' || req.url !== '/api/v1/community/files')) return json(404, { error: 'not found' });const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });if (!v.ok) return json(401, { code: v.code, error: v.reason });-- const verb = v2 && req.method === 'GET' && v2[2] === 'download' ? 'media.object.read' : 'media.object.upload';++ const verb = v2 && req.method === 'GET' ? 'media.object.read' : req.method === 'DELETE' ? 'media.object.delete' : 'media.object.upload';if (!(v.claims.cap || []).includes(verb)) return json(403, { code: 'capability.denied', error: 'not granted' });const body = Buffer.concat(chunks);if (normalize) {@@@ -77,6 +77,18 @@ function start({ publicPem, issuer }) {// Private objects answer a short-lived URL of their own; the shape is what Community reads.return json(200, { url: `http://127.0.0.1:${server.address().port}/o/${o.key}`, public: o.visibility !== 'private' });}++ if (v2 && req.method === 'GET' && v2[1] && !v2[2]) {++ const o = uploads.find((u) => u.key === v2[1]);++ if (!o) return json(404, { code: 'media.object.not_found' });++ return json(200, { id: o.key, owner: { subject: o.owner || null }, lifecycle_status: 'ready', mime_type: o.mime,++ size_bytes: o.bytes.length, metadata: { filename: o.name }, public_url: `http://127.0.0.1:${server.address().port}/o/${o.key}` });++ }++ if (v2 && req.method === 'DELETE' && v2[1]) {++ const i = uploads.findIndex((u) => u.key === v2[1]);++ if (i < 0) return json(404, { code: 'media.object.not_found' });++ uploads.splice(i, 1); deletes.push(v2[1]);++ return json(200, { id: v2[1], lifecycle_status: 'deleted' });++ }if (v2) {if (req.method === 'POST' && !v2[1]) {if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }@@@ -23,7 +23,7 @@ const assert = require('assert');const { boot, check, done } = require('./helpers/app');const TABLES = ['pastes', 'paste_versions', 'paste_comments', 'paste_likes', 'spaces', 'space_groups', 'categories', 'threads', 'posts', 'post_versions',-- 'post_reactions', 'post_pastes', 'thread_votes', 'attachments', 'comment_threads', 'comments', 'comment_votes', 'space_chat_rooms', 'relay_mappings', 'relay_deliveries', 'pulse_items'];++ 'post_reactions', 'post_pastes', 'thread_votes', 'attachments', 'comment_attachments', 'comment_threads', 'comments', 'comment_votes', 'space_chat_rooms', 'relay_mappings', 'relay_deliveries', 'pulse_items'];const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');const PASTE_CAPS = ['community.paste.create', 'community.paste.write'];const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+-- phase: expand
+-- A comment can claim up to four file references uploaded through the owning service.
+CREATE TABLE comment_attachments (
+ media_id text COLLATE "C" PRIMARY KEY,
+ ref_service text COLLATE "C" NOT NULL,
+ ref_type text COLLATE "C" NOT NULL,
+ ref_id text COLLATE "C" NOT NULL,
+ comment_id bigint REFERENCES comments(id) ON DELETE CASCADE,
+ owner_subject text COLLATE "C" NOT NULL,
+ filename text COLLATE "C" NOT NULL,
+ mime text COLLATE "C" NOT NULL,
+ size_bytes bigint NOT NULL CHECK (size_bytes >= 0),
+ visibility text COLLATE "C" NOT NULL DEFAULT 'unlisted' CHECK (visibility IN ('private', 'unlisted')),
+ created_at text COLLATE "C" NOT NULL DEFAULT ov_now()
+);
+CREATE INDEX comment_attachments_owner ON comment_attachments(owner_subject, created_at);
+CREATE INDEX comment_attachments_comment ON comment_attachments(comment_id, media_id) WHERE comment_id IS NOT NULL;
@@ -261,7 +261,7 @@ async function createApp(opts = {}) {
@@ -261,7 +261,7 @@ async function createApp(opts = {}) {
// Sharing (openvibe-sdk/share): the one access check for a paste, its members, transfers and the
// Sharing (openvibe-sdk/share): the one access check for a paste, its members, transfers and the
// gate. Created before comments so a thread on a paste nobody may see is never readable.
// gate. Created before comments so a thread on a paste nobody may see is never readable.
const sharing = opts.sharing || createSharing({ db, media: pasteMedia, fetchImpl: opts.fetchImpl });
const sharing = opts.sharing || createSharing({ db, media: pasteMedia, fetchImpl: opts.fetchImpl });
- const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p) });
+ const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p), media: mediaObjects });
// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.
// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.
const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });
const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });
discovery.useForum(forum);
discovery.useForum(forum);
@@ -22,6 +22,7 @@
@@ -22,6 +22,7 @@
* community.comment.moderate for visibility and moderation. Errors are problem+json.
* community.comment.moderate for visibility and moderation. Errors are problem+json.
*/
*/
const express = require('express');
const express = require('express');
+const multer = require('multer');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
@@ -37,10 +38,36 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter,
@@ -37,10 +38,36 @@ function createCommentsApi({ service, viewers, anonWriteLimiter, resolveLimiter,
// refused ones included, and every write that has no content limit.
// refused ones included, and every write that has no content limit.
router.use(limits.reads('community.comment.read'));
router.use(limits.reads('community.comment.read'));
const moderate = limits('community.comment.moderate', { minute: 60, hour: 600 });
const moderate = limits('community.comment.moderate', { minute: 60, hour: 600 });
+ const attachmentUpload = limits('community.comment.attachment.upload', { minute: 10, hour: 100 });
+ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024, files: 1, fields: 5 } }).single('file');
+ const parseUpload = (req, res, next) => upload(req, res, (err) => {
+ if (!err) return next();
+ if (err instanceof multer.MulterError) {
+ const tooLarge = err.code === 'LIMIT_FILE_SIZE';
+ return contracts.http.sendProblem(res, tooLarge ? 413 : 400, tooLarge ? 'attachment.too_large' : 'attachment.invalid_upload', {
+ detail: tooLarge ? 'Files are limited to 16 MB' : 'Upload one file with its item reference', ctx: req.ov,
+ });
+ }
+ return next(err);
+ });
const read = serviceAnyCap([WRITE, MOD]);
const read = serviceAnyCap([WRITE, MOD]);
const passAnon = (_req, _res, next) => next();
const passAnon = (_req, _res, next) => next();
+ router.post('/attachments', attachmentUpload, parseUpload, run(async (req) => {
+ let ref = req.body && req.body.ref;
+ if (typeof ref === 'string') { try { ref = JSON.parse(ref); } catch { ref = null; } }
+ if (!ref && req.body) ref = { service: req.body.service, type: req.body.type, id: req.body.id };
+ return await service.uploadAttachment(req.viewer, ref, req.file);
+ }, 201));
+
+ router.post('/attachments', attachmentUpload, upload, run(async (req) => {
+ let ref = req.body && req.body.ref;
+ if (typeof ref === 'string') { try { ref = JSON.parse(ref); } catch { ref = null; } }
+ if (!ref && req.body) ref = { service: req.body.service, type: req.body.type, id: req.body.id };
+ return await service.uploadAttachment(req.viewer, ref, req.file);
+ }, 201));
+
// Opening a thread writes a row the first time: a person opens at most one a second.
// Opening a thread writes a row the first time: a person opens at most one a second.
router.post('/threads/resolve', serviceCap(WRITE), limits('community.thread.resolve', { minute: 60, hour: 1200 }), resolveLimiter || passAnon, jsonBody,
router.post('/threads/resolve', serviceCap(WRITE), limits('community.thread.resolve', { minute: 60, hour: 1200 }), resolveLimiter || passAnon, jsonBody,
run(async (req) => await service.resolve(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
run(async (req) => await service.resolve(req.viewer, req.body || {}), (out) => (out.created ? 201 : 200)));
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+
+/** The attachment reference shape shared with Chat (ai.file@1). Bytes live in Media. */
+const MEDIA_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
+const NAME_MAX = 255;
+
+function cleanReference(raw, subject) {
+ const value = raw && typeof raw === 'object' ? raw : {};
+ const id = String(value.id || '');
+ if (!MEDIA_ID.test(id) || !value.storage || value.storage.provider !== 'media' || String(value.storage.key) !== id) {
+ const err = new Error('Each attachment must be a stored Media object reference');
+ err.status = 400; err.code = 'comment.attachment_invalid'; throw err;
+ }
+ const mediaType = String(value.media_type || 'application/octet-stream').split(';')[0].trim().slice(0, NAME_MAX) || 'application/octet-stream';
+ const name = String(value.name || 'file').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, NAME_MAX) || 'file';
+ return {
+ id, name, media_type: mediaType, size: Math.max(0, Math.floor(Number(value.size) || 0)),
+ storage: { provider: 'media', key: id },
+ access: { readers: [subject] },
+ read: { method: 'not_readable', reason: 'Attached; open the comment to read it.' },
+ role: 'input',
+ };
+}
+
+function publicReference(row) {
+ return {
+ id: row.media_id, name: row.filename || 'file', media_type: row.mime || 'application/octet-stream',
+ size: Number(row.size_bytes) || 0, storage: { provider: 'media', key: row.media_id },
+ access: { readers: [row.owner_subject] }, read: { method: 'not_readable', reason: 'Attached to a comment.' },
+ role: 'input',
+ };
+}
+
+module.exports = { MEDIA_ID, cleanReference, publicReference };
@@ -44,6 +44,7 @@ const { createAuthors } = require('../identity/authors');
@@ -44,6 +44,7 @@ const { createAuthors } = require('../identity/authors');
const { createPersonLimiter } = require('../limits');
const { createPersonLimiter } = require('../limits');
const { discussionModerator: moderator } = require('../identity/capabilities');
const { discussionModerator: moderator } = require('../identity/capabilities');
const blocks = require('../identity/blocks');
const blocks = require('../identity/blocks');
+const commentAttachments = require('./attachments');
/**
/**
* The entity types a browser may open a thread for (services with community.comment.write: any).
* The entity types a browser may open a thread for (services with community.comment.write: any).
@@ -70,7 +71,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
@@ -70,7 +71,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
const PAGE = 30;
const REPLY_PAGE = 20;
const REPLY_PAGE = 20;
-function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch } = {}) {
+function createCommentService({ db, network = null, limits = {}, canViewPaste = null, fetchImpl = globalThis.fetch, media = null } = {}) {
const authors = createAuthors({ db, network });
const authors = createAuthors({ db, network });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
@@ -112,6 +113,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -112,6 +113,7 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
edited_at: deleted ? null : isoTime(c.edited_at),
edited_at: deleted ? null : isoTime(c.edited_at),
can_edit: !deleted && !!(v && v.subject && c.author_subject === v.subject),
can_edit: !deleted && !!(v && v.subject && c.author_subject === v.subject),
can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
can_delete: !deleted && (moderator(v) || !!(v && v.subject && c.author_subject === v.subject)),
+ attachments: deleted ? [] : (c.attachments || []).map((row) => commentAttachments.publicReference(row)),
};
};
if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));
if (c.replies) out.replies = c.replies.map((r) => shapeComment(r, v, projections, votes, threadId));
return out;
return out;
@@ -224,6 +226,35 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -224,6 +226,35 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
return { thread: shapeThread(thread, v), created };
return { thread: shapeThread(thread, v), created };
},
},
+ /** Upload one attachment to Community, which owns Community paste comments. */
+ async uploadAttachment(v, ref, file) {
+ if (!(v && v.subject)) fail(401, 'auth.required', 'Sign in to attach a file');
+ if (!ref || ref.service !== 'community' || ref.type !== 'paste') fail(403, 'attachment.owner_service', 'Upload this attachment through the service that owns the item');
+ await checkCommunityRef(ref, v);
+ if (!file || !Buffer.isBuffer(file.buffer) || !file.buffer.length) fail(400, 'attachment.missing', 'Choose a file');
+ if (file.buffer.length > 16 * 1024 * 1024) fail(413, 'attachment.too_large', 'Files are limited to 16 MB');
+ if (!media || !media.configured || typeof media.uploadImage !== 'function') fail(503, 'attachment.unavailable', 'Files cannot be attached right now');
+ const filename = String(file.originalname || 'file').replace(/[\\/\\\\\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 255) || 'file';
+ let uploaded;
+ try {
+ uploaded = await media.uploadImage({ buffer: file.buffer, filename, mime: file.mimetype || 'application/octet-stream', owner: v.subject,
+ kind: 'file', visibility: 'unlisted', source: 'community.comment' });
+ } catch (err) { fail(err.status === 413 ? 413 : 502, 'attachment.upload_failed', 'The file could not be stored. Try again.'); }
+ let object;
+ try { object = await media.object(uploaded.id); }
+ catch (err) { try { await media.deleteObject(uploaded.id); } catch { /* the uploaded object remains private/unlisted */ } fail(502, 'attachment.invalid', 'The stored file could not be verified'); }
+ if (!object || !object.owner || object.owner.subject !== v.subject || object.lifecycle_status !== 'ready') {
+ try { await media.deleteObject(uploaded.id); } catch { /* best effort cleanup */ }
+ fail(400, 'attachment.invalid', 'The stored file is not owned by you');
+ }
+ const row = await store.createAttachment(db, {
+ media_id: uploaded.id, ref_service: ref.service, ref_type: ref.type, ref_id: String(ref.id), owner_subject: v.subject,
+ filename: String((object.metadata && object.metadata.filename) || filename).slice(0, 255), mime: String(object.mime_type || file.mimetype || 'application/octet-stream'),
+ size_bytes: Number(object.size_bytes) || file.buffer.length, visibility: 'unlisted',
+ });
+ return { attachment: commentAttachments.publicReference(row) };
+ },
+
/**
/**
* The thread of an entity as the entity's own page shows it (Community's paste page): get() found by
* The thread of an entity as the entity's own page shows it (Community's paste page): get() found by
* ref, after the same checks as resolve; reading never opens a thread, so null while nobody has
* ref, after the same checks as resolve; reading never opens a thread, so null while nobody has
@@ -299,7 +330,27 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -299,7 +330,27 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
}
}
const key = author ? `s:${author}` : null;
const key = author ? `s:${author}` : null;
commentLimiter.check(key, message);
commentLimiter.check(key, message);
- const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message });
+ const rawAttachments = body.attachments == null ? [] : (Array.isArray(body.attachments) ? body.attachments : [body.attachments]);
+ if (rawAttachments.length > 4) fail(400, 'comment.attachments_too_many', 'At most four files can be attached to a comment');
+ if (rawAttachments.length && !author) fail(401, 'auth.required', 'Sign in to attach a file');
+ let attachmentIds = [];
+ if (rawAttachments.length) {
+ if (t.ref_service !== 'community' || t.ref_type !== 'paste') fail(403, 'attachment.owner_service', 'Upload attachments through the service that owns the item');
+ const refs = rawAttachments.map((raw) => {
+ try { return commentAttachments.cleanReference(raw, author); }
+ catch (err) { fail(err.status || 400, err.code || 'comment.attachment_invalid', err.message); }
+ });
+ attachmentIds = refs.map((a) => a.id);
+ if (new Set(attachmentIds).size !== attachmentIds.length) fail(400, 'comment.attachment_invalid', 'A file can only be attached once');
+ const staged = await store.stagedAttachments(db, attachmentIds, author, { service: t.ref_service, type: t.ref_type, id: t.ref_id });
+ if (!staged) fail(400, 'comment.attachment_invalid', 'Attach files uploaded through the service that owns this item');
+ for (const row of staged) {
+ let object;
+ try { object = await media.object(row.media_id); } catch { fail(400, 'comment.attachment_invalid', 'One of the attached files is no longer available'); }
+ if (!object || !object.owner || object.owner.subject !== author || object.lifecycle_status !== 'ready') fail(400, 'comment.attachment_invalid', 'One of the attached files is not yours');
+ }
+ }
+ const c = await store.insertComment(db, { thread_id: t.id, parent_id: parentId, author_subject: author, anon_name: anonName, origin, message, attachment_ids: attachmentIds });
commentLimiter.record(key, message);
commentLimiter.record(key, message);
return { comment: (await shapeComments([c], v, t))[0] };
return { comment: (await shapeComments([c], v, t))[0] };
},
},
@@ -332,6 +383,12 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
@@ -332,6 +383,12 @@ function createCommentService({ db, network = null, limits = {}, canViewPaste =
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
if (!isAuthor && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');
if (!isAuthor && !moderator(v)) fail(403, 'comment.not_yours', 'Not authorized to delete this comment');
await store.softDeleteComment(db, c.id, v.subject || v.service || null);
await store.softDeleteComment(db, c.id, v.subject || v.service || null);
+ for (const a of c.attachments || []) {
+ if (a.ref_service === 'community' && media && typeof media.deleteObject === 'function') {
+ try { await media.deleteObject(a.media_id); }
+ catch (err) { console.warn(`[Comments] removed attachment ${a.media_id} from comment; Media cleanup failed: ${err.message}`); }
+ }
+ }
return { ok: true, id: c.id };
return { ok: true, id: c.id };
},
},
@@ -49,7 +49,34 @@ async function setThreadVisibility(db, id, visibility) {
@@ -49,7 +49,34 @@ async function setThreadVisibility(db, id, visibility) {
}
}
async function getComment(db, id) {
async function getComment(db, id) {
- return await db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;
+ const row = await db.prepare('SELECT * FROM comments WHERE id = ?').get(id) || null;
+ if (row) row.attachments = await listAttachments(db, [row.id]).then((m) => m.get(row.id) || []);
+ return row;
+}
+
+/** References staged by the owning service, grouped by comment id. */
+async function listAttachments(db, commentIds) {
+ const out = new Map();
+ const ids = [...new Set((commentIds || []).map(Number).filter(Number.isSafeInteger))];
+ for (const id of ids) out.set(id, []);
+ if (!ids.length) return out;
+ const rows = await db.prepare(`SELECT * FROM comment_attachments WHERE comment_id IN (${ids.map(() => '?').join(',')}) ORDER BY comment_id, created_at, media_id`).all(...ids);
+ for (const row of rows) if (out.has(row.comment_id)) out.get(row.comment_id).push(row);
+ return out;
+}
+
+async function createAttachment(db, row) {
+ await db.prepare(`INSERT INTO comment_attachments (media_id, ref_service, ref_type, ref_id, owner_subject, filename, mime, size_bytes, visibility)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
+ .run(row.media_id, row.ref_service, row.ref_type, row.ref_id, row.owner_subject, row.filename, row.mime, row.size_bytes, row.visibility);
+ return await db.prepare('SELECT * FROM comment_attachments WHERE media_id = ?').get(row.media_id);
+}
+
+async function stagedAttachments(db, ids, subject, ref) {
+ if (!ids.length) return [];
+ const rows = await db.prepare(`SELECT * FROM comment_attachments WHERE media_id IN (${ids.map(() => '?').join(',')})
+ AND comment_id IS NULL AND owner_subject = ? AND ref_service = ? AND ref_type = ? AND ref_id = ?`).all(...ids, subject, ref.service, ref.type, String(ref.id));
+ return rows.length === ids.length ? rows : null;
}
}
/**
/**
@@ -71,10 +98,17 @@ async function refIsPublic(db, t) {
@@ -71,10 +98,17 @@ async function refIsPublic(db, t) {
}
}
/** Insert a comment and keep the thread's and parent's counters in step. */
/** Insert a comment and keep the thread's and parent's counters in step. */
-async function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message }) {
+async function insertComment(db, { thread_id, parent_id = null, author_subject = null, anon_name = null, origin = 'user', message, attachment_ids = [] }) {
return await db.tx(async () => {
return await db.tx(async () => {
+ const thread = await getThread(db, thread_id);
+ const staged = await stagedAttachments(db, attachment_ids, author_subject, thread && { service: thread.ref_service, type: thread.ref_type, id: thread.ref_id });
+ if (!staged) { const err = new Error('Use attachments uploaded for this item'); err.status = 400; err.code = 'comment.attachment_invalid'; throw err; }
const info = await db.prepare('INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?) RETURNING id')
const info = await db.prepare('INSERT INTO comments (thread_id, parent_id, author_subject, anon_name, origin, message) VALUES (?, ?, ?, ?, ?, ?) RETURNING id')
.run(thread_id, parent_id, author_subject, anon_name, origin, message);
.run(thread_id, parent_id, author_subject, anon_name, origin, message);
+ for (const row of staged) {
+ const claimed = await db.prepare('UPDATE comment_attachments SET comment_id = ? WHERE media_id = ? AND comment_id IS NULL').run(info.lastInsertRowid, row.media_id);
+ if (!claimed.changes) { const err = new Error('This attachment was already used'); err.status = 400; err.code = 'comment.attachment_invalid'; throw err; }
+ }
await db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = ov_now() WHERE id = ?').run(thread_id);
await db.prepare('UPDATE comment_threads SET comment_count = comment_count + 1, updated_at = ov_now() WHERE id = ?').run(thread_id);
if (parent_id) await db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
if (parent_id) await db.prepare('UPDATE comments SET reply_count = reply_count + 1 WHERE id = ?').run(parent_id);
const comment = await getComment(db, info.lastInsertRowid);
const comment = await getComment(db, info.lastInsertRowid);
@@ -96,6 +130,7 @@ async function softDeleteComment(db, id, deletedBy = null) {
@@ -96,6 +130,7 @@ async function softDeleteComment(db, id, deletedBy = null) {
const c = await getComment(db, id);
const c = await getComment(db, id);
if (!c || c.deleted_at) return 0;
if (!c || c.deleted_at) return 0;
await db.prepare("UPDATE comments SET deleted_at = ov_now(), deleted_by = ?, message = '', updated_at = ov_now() WHERE id = ?").run(deletedBy, id);
await db.prepare("UPDATE comments SET deleted_at = ov_now(), deleted_by = ?, message = '', updated_at = ov_now() WHERE id = ?").run(deletedBy, id);
+ await db.prepare('DELETE FROM comment_attachments WHERE comment_id = ?').run(id);
await db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = ov_now() WHERE id = ?').run(c.thread_id, c.thread_id);
await db.prepare('UPDATE comment_threads SET comment_count = (SELECT COUNT(*) FROM comments WHERE thread_id = ? AND deleted_at IS NULL), updated_at = ov_now() WHERE id = ?').run(c.thread_id, c.thread_id);
if (c.parent_id) await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);
if (c.parent_id) await db.prepare('UPDATE comments SET reply_count = (SELECT COUNT(*) FROM comments WHERE parent_id = ? AND deleted_at IS NULL) WHERE id = ?').run(c.parent_id, c.parent_id);
return 1;
return 1;
@@ -120,6 +155,9 @@ async function listTopLevel(db, threadId, { after = null, sort = 'old', limit =
@@ -120,6 +155,9 @@ async function listTopLevel(db, threadId, { after = null, sort = 'old', limit =
if (hasMore) rows.pop();
if (hasMore) rows.pop();
const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');
const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC LIMIT ?');
for (const r of rows) r.replies = await replies.all(r.id, replyLimit);
for (const r of rows) r.replies = await replies.all(r.id, replyLimit);
+ const all = rows.concat(rows.flatMap((r) => r.replies || []));
+ const attachments = await listAttachments(db, all.map((r) => r.id));
+ for (const row of all) row.attachments = attachments.get(row.id) || [];
return { rows, hasMore };
return { rows, hasMore };
}
}
@@ -129,6 +167,8 @@ async function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
@@ -129,6 +167,8 @@ async function listReplies(db, parentId, { after = null, limit = 50 } = {}) {
.all(...(after != null ? [parentId, after] : [parentId]), limit + 1);
.all(...(after != null ? [parentId, after] : [parentId]), limit + 1);
const hasMore = rows.length > limit;
const hasMore = rows.length > limit;
if (hasMore) rows.pop();
if (hasMore) rows.pop();
+ const attachments = await listAttachments(db, rows.map((r) => r.id));
+ for (const row of rows) row.attachments = attachments.get(row.id) || [];
return { rows, hasMore };
return { rows, hasMore };
}
}
@@ -140,7 +180,10 @@ async function listNewest(db, threadId, { limit = 50, offset = 0 } = {}) {
@@ -140,7 +180,10 @@ async function listNewest(db, threadId, { limit = 50, offset = 0 } = {}) {
const top = await db.prepare('SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?').all(threadId, limit, offset);
const top = await db.prepare('SELECT * FROM comments WHERE thread_id = ? AND parent_id IS NULL AND deleted_at IS NULL ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?').all(threadId, limit, offset);
const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC');
const replies = db.prepare('SELECT * FROM comments WHERE parent_id = ? AND deleted_at IS NULL ORDER BY id ASC');
for (const c of top) { c.replies = await replies.all(c.id); c.reply_count = c.replies.length; }
for (const c of top) { c.replies = await replies.all(c.id); c.reply_count = c.replies.length; }
+ const all = top.concat(top.flatMap((r) => r.replies || []));
+ const attachments = await listAttachments(db, all.map((r) => r.id));
+ for (const row of all) row.attachments = attachments.get(row.id) || [];
return top;
return top;
}
}
-module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, insertComment, editComment, softDeleteComment, listTopLevel, listReplies, listNewest };
+module.exports = { getThread, getThreadByAccessId, getThreadByRef, resolveThread, setThreadVisibility, getComment, listAttachments, createAttachment, stagedAttachments, insertComment, editComment, softDeleteComment, listTopLevel, listReplies, listNewest };
@@ -35,6 +35,7 @@ const inList = (xs) => `(${xs.map(() => '?').join(',')})`;
@@ -35,6 +35,7 @@ const inList = (xs) => `(${xs.map(() => '?').join(',')})`;
const EXPORTS = [
const EXPORTS = [
['pastes.json', 'pastes', 'owner_subject'], ['paste_comments.json', 'paste_comments', 'author_subject'], ['comments.json', 'comments', 'author_subject'],
['pastes.json', 'pastes', 'owner_subject'], ['paste_comments.json', 'paste_comments', 'author_subject'], ['comments.json', 'comments', 'author_subject'],
['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],
['threads.json', 'threads', 'author_subject'], ['posts.json', 'posts', 'author_subject'], ['attachments.json', 'attachments', 'owner_subject'],
+ ['comment_attachments.json', 'comment_attachments', 'owner_subject'],
['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],
['paste_likes.json', 'paste_likes', 'subject_id'], ['comment_votes.json', 'comment_votes', 'subject_id'], ['thread_votes.json', 'thread_votes', 'subject_id'],
['post_reactions.json', 'post_reactions', 'subject_id'], ['section_reactions.json', 'thread_section_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
['post_reactions.json', 'post_reactions', 'subject_id'], ['section_reactions.json', 'thread_section_reactions', 'subject_id'], ['game_progress.json', 'game_progress', 'subject_id'], ['blocks.json', 'network_blocks', 'blocker_subject'],
['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],
['activity.json', 'pulse_items', 'actor_subject'], ['submissions.json', 'submissions', 'author_subject'],
@@ -142,7 +143,10 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -142,7 +143,10 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
['comments', 'author_subject = NULL, anon_name = NULL, message = ?, deleted_at = COALESCE(deleted_at, ?)']]) {
['comments', 'author_subject = NULL, anon_name = NULL, message = ?, deleted_at = COALESCE(deleted_at, ?)']]) {
if (!await hasTable(db, table)) continue;
if (!await hasTable(db, table)) continue;
const { keep, drop } = await splitByReplies(db, table, subjects);
const { keep, drop } = await splitByReplies(db, table, subjects);
- for (const id of keep) await db.prepare(`UPDATE ${table} SET ${tomb} WHERE id = ?`).run(TOMBSTONE, now, id);
+ for (const id of keep) {
+ if (table === 'comments' && await hasTable(db, 'comment_attachments')) await db.prepare('DELETE FROM comment_attachments WHERE comment_id = ?').run(id);
+ await db.prepare(`UPDATE ${table} SET ${tomb} WHERE id = ?`).run(TOMBSTONE, now, id);
+ }
for (const id of drop) await db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
for (const id of drop) await db.prepare(`DELETE FROM ${table} WHERE id = ?`).run(id);
add(erased, table, drop.length);
add(erased, table, drop.length);
add(retained, 'tombstones', keep.length);
add(retained, 'tombstones', keep.length);
@@ -165,6 +169,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
@@ -165,6 +169,7 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();
if (await hasColumn(db, 'threads', 'reply_count')) await db.prepare("UPDATE threads SET reply_count = (SELECT COUNT(*) FROM posts p WHERE p.thread_id = threads.id AND p.is_opening = 0)").run();
}
}
if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);
if (await hasTable(db, 'attachments')) add(erased, 'attachments', (await db.prepare(`DELETE FROM attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);
+ if (await hasTable(db, 'comment_attachments')) add(erased, 'comment_attachments', (await db.prepare(`DELETE FROM comment_attachments WHERE owner_subject IN ${S}`).run(...subjects)).changes);
// Their submissions go (Pulse items with them, below); a decision they made stays, unsigned.
// Their submissions go (Pulse items with them, below); a decision they made stays, unsigned.
if (await hasTable(db, 'submissions')) {
if (await hasTable(db, 'submissions')) {
for (const r of await db.prepare(`SELECT slug FROM submissions WHERE author_subject IN ${S}`).all(...subjects)) {
for (const r of await db.prepare(`SELECT slug FROM submissions WHERE author_subject IN ${S}`).all(...subjects)) {
@@ -268,6 +268,14 @@ route('post', '/api/v1/submissions/{slug}/review', {
@@ -268,6 +268,14 @@ route('post', '/api/v1/submissions/{slug}/review', {
});
});
// ── /api/v1/comments — the comment threads other products embed (server/comments/api.js) ─────
// ── /api/v1/comments — the comment threads other products embed (server/comments/api.js) ─────
+route('post', '/api/v1/comments/attachments', {
+ operationId: 'uploadCommentAttachment', summary: 'Upload one comment attachment through the item owner', tags: ['comments'], security: PERSON,
+ requestBody: { required: true, content: { 'multipart/form-data': { schema: { type: 'object', required: ['ref', 'file'], properties: {
+ ref: { type: 'string', description: 'JSON EntityRef, for example {"service":"community","type":"paste","id":"…"}' },
+ file: { type: 'string', format: 'binary' },
+ } } } } },
+ responses: { 201: json('A staged ai.file@1 reference to pass in the comment write', { type: 'object', properties: { attachment: { type: 'object' } } }), ...errors([400, 401, 403, 413, 429, 502, 503]) },
+});
route('post', '/api/v1/comments/threads/resolve', {
route('post', '/api/v1/comments/threads/resolve', {
operationId: 'resolveThread', summary: 'Open the thread for an item (creating it the first time)', tags: ['comments'], security: PERSON,
operationId: 'resolveThread', summary: 'Open the thread for an item (creating it the first time)', tags: ['comments'], security: PERSON,
description: 'community.thread.resolve. Idempotent: 201 when it created the thread, 200 when it already existed.',
description: 'community.thread.resolve. Idempotent: 201 when it created the thread, 200 when it already existed.',
@@ -290,6 +298,7 @@ route('post', '/api/v1/comments/threads/{id}/comments', {
@@ -290,6 +298,7 @@ route('post', '/api/v1/comments/threads/{id}/comments', {
description: 'community.comment.create. Anonymous visitors may comment on public threads with anon_name.',
description: 'community.comment.create. Anonymous visitors may comment on public threads with anon_name.',
requestBody: body({ type: 'object', required: ['message'], properties: {
requestBody: body({ type: 'object', required: ['message'], properties: {
message: str(), parent_id: nullable('integer', { minimum: 1 }), anon_name: str({ maxLength: 32 }),
message: str(), parent_id: nullable('integer', { minimum: 1 }), anon_name: str({ maxLength: 32 }),
+ attachments: { type: 'array', maxItems: 4, items: ref('AiFile') },
} }),
} }),
responses: { 201: json('The comment', { type: 'object', properties: { comment: ref('Comment') } }), ...errors() },
responses: { 201: json('The comment', { type: 'object', properties: { comment: ref('Comment') } }), ...errors() },
});
});
@@ -809,7 +818,14 @@ function openapi() {
@@ -809,7 +818,14 @@ function openapi() {
id: { type: 'integer' }, thread_id: str(), parent_id: nullable('integer'), origin: str(), author: nullable('object'),
id: { type: 'integer' }, thread_id: str(), parent_id: nullable('integer'), origin: str(), author: nullable('object'),
anon_name: nullable('string'), display_name: nullable('string'), message: nullable('string'), deleted: { type: 'boolean' },
anon_name: nullable('string'), display_name: nullable('string'), message: nullable('string'), deleted: { type: 'boolean' },
score: { type: 'integer' }, upvotes: { type: 'integer' }, downvotes: { type: 'integer' }, my_vote: { type: 'integer' },
score: { type: 'integer' }, upvotes: { type: 'integer' }, downvotes: { type: 'integer' }, my_vote: { type: 'integer' },
- created_at: str(), updated_at: str(), replies: arrayOf({ type: 'object' }),
+ created_at: str(), updated_at: str(), replies: arrayOf({ type: 'object' }), attachments: arrayOf(ref('AiFile')),
+ } },
+ AiFile: { type: 'object', required: ['id', 'name', 'media_type', 'size', 'storage', 'access', 'read'], properties: {
+ id: { type: 'string', pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' }, name: str(), media_type: str(), size: { type: 'integer', minimum: 0 },
+ storage: { type: 'object', required: ['provider', 'key'], properties: { provider: { const: 'media' }, key: str() } },
+ access: { type: 'object', required: ['readers'], properties: { readers: arrayOf(str()) } },
+ read: { type: 'object', required: ['method'], properties: { method: { type: 'string', enum: ['text', 'pages', 'image', 'audio_transcript', 'not_readable'] }, content: str(), pages: arrayOf(str()), reason: str() } },
+ role: { type: 'string', enum: ['input', 'artifact'] },
} },
} },
Space: { type: 'object', required: ['slug', 'name'], properties: {
Space: { type: 'object', required: ['slug', 'name'], properties: {
slug: str(), name: str(), description: nullable('string'), visibility: { type: 'string', enum: ['public', 'members', 'staff'] },
slug: str(), name: str(), description: nullable('string'), visibility: { type: 'string', enum: ['public', 'members', 'staff'] },
@@ -313,6 +313,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -313,6 +313,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
id: c.id, paste_id: p.id, user_id: c.author_subject || null, author_subject: c.author_subject || null,
id: c.id, paste_id: p.id, user_id: c.author_subject || null, author_subject: c.author_subject || null,
parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.deleted_at ? 1 : 0,
parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.deleted_at ? 1 : 0,
created_at: c.created_at, updated_at: c.updated_at,
created_at: c.created_at, updated_at: c.updated_at,
+ attachments: c.deleted_at ? [] : (c.attachments || []).map((a) => ({ id: a.media_id, name: a.filename || 'file', media_type: a.mime, size: Number(a.size_bytes) || 0, storage: { provider: 'media', key: a.media_id }, access: { readers: [a.owner_subject] }, read: { method: 'not_readable', reason: 'Attached to a comment.' }, role: 'input' })),
...authorFields(c.author_subject, c.origin, projections),
...authorFields(c.author_subject, c.origin, projections),
};
};
if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, p, projections)); out.reply_count = c.reply_count; }
if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, p, projections)); out.reply_count = c.reply_count; }
@@ -39,10 +39,24 @@ function commentHtml(c) {
@@ -39,10 +39,24 @@ function commentHtml(c) {
return `<article class="post comment" id="comment-${c.id}">
return `<article class="post comment" id="comment-${c.id}">
<header class="post-head">${who(c.author || (c.anon_name ? { display_name: c.anon_name } : null))} <span class="sep">·</span> <a class="muted small" href="#comment-${c.id}">${timeTag(c.created_at)}</a>${edited}</header>
<header class="post-head">${who(c.author || (c.anon_name ? { display_name: c.anon_name } : null))} <span class="sep">·</span> <a class="muted small" href="#comment-${c.id}">${timeTag(c.created_at)}</a>${edited}</header>
<p class="comment-text">${esc(c.message)}</p>
<p class="comment-text">${esc(c.message)}</p>
+ ${attachmentsHtml(c.attachments)}
${repliesHtml(c)}
${repliesHtml(c)}
</article>`;
</article>`;
}
}
+function attachmentsHtml(items) {
+ const refs = Array.isArray(items) ? items : [];
+ if (!refs.length) return '';
+ return `<ul class="comment-attachments">${refs.map((a) => {
+ const url = `${config.mediaUrl}/o/${encodeURIComponent(String(a.id || ''))}`;
+ if (!/^https:\/\//i.test(url)) return '';
+ const name = esc(a.name || 'file');
+ const type = String(a.media_type || '');
+ const body = type.startsWith('image/') ? `<img src="${esc(url)}" alt="${name}" loading="lazy">` : name;
+ return `<li><a href="${esc(url)}" target="_blank" rel="noopener noreferrer">${body}</a></li>`;
+ }).join('')}</ul>`;
+}
+
function repliesHtml(c) {
function repliesHtml(c) {
if (!c.replies || !c.replies.length) return '';
if (!c.replies || !c.replies.length) return '';
const more = c.reply_count > c.replies.length ? `<p class="muted small">${num(c.reply_count - c.replies.length)} more ${c.reply_count - c.replies.length === 1 ? 'reply' : 'replies'} on the original page.</p>` : '';
const more = c.reply_count > c.replies.length ? `<p class="muted small">${num(c.reply_count - c.replies.length)} more ${c.reply_count - c.replies.length === 1 ? 'reply' : 'replies'} on the original page.</p>` : '';
@@ -45,6 +45,8 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
@@ -45,6 +45,8 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
await db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (3, ?)').run(DANA);
await db.prepare('INSERT INTO paste_likes (paste_id, subject_id) VALUES (3, ?)').run(DANA);
await db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'vod', '9')").run();
await db.prepare("INSERT INTO comment_threads (id, ref_service, ref_type, ref_id) OVERRIDING SYSTEM VALUE VALUES (1, 'live', 'vod', '9')").run();
await db.prepare("INSERT INTO comments (id, thread_id, parent_id, author_subject, message, score, upvotes, downvotes, reply_count) OVERRIDING SYSTEM VALUE VALUES (1, 1, NULL, ?, 'answered', 0, 0, 0, 1), (2, 1, 1, ?, 'reply', 1, 1, 0, 0), (3, 1, NULL, ?, 'alone', 0, 0, 0, 0)").run(DANA, OTTO, DANA);
await db.prepare("INSERT INTO comments (id, thread_id, parent_id, author_subject, message, score, upvotes, downvotes, reply_count) OVERRIDING SYSTEM VALUE VALUES (1, 1, NULL, ?, 'answered', 0, 0, 0, 1), (2, 1, 1, ?, 'reply', 1, 1, 0, 0), (3, 1, NULL, ?, 'alone', 0, 0, 0, 0)").run(DANA, OTTO, DANA);
+ await db.prepare(`INSERT INTO comment_attachments (media_id, ref_service, ref_type, ref_id, comment_id, owner_subject, filename, mime, size_bytes)
+ VALUES ('med_01JAB2C3D4E5F6G7H8J9K0MNP6', 'live', 'vod', '9', 1, ?, 'reply.png', 'image/png', 17)`).run(DANA);
await db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (2, ?, 1)').run(DANA);
await db.prepare('INSERT INTO comment_votes (comment_id, subject_id, value) VALUES (2, ?, 1)').run(DANA);
await db.prepare("INSERT INTO spaces (id, slug, name, created_by) OVERRIDING SYSTEM VALUE VALUES (901, 'dana-space', 'Dana space', ?)").run(DANA);
await db.prepare("INSERT INTO spaces (id, slug, name, created_by) OVERRIDING SYSTEM VALUE VALUES (901, 'dana-space', 'Dana space', ?)").run(DANA);
await db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) OVERRIDING SYSTEM VALUE VALUES (1, 901, 't1', 'busy', ?, 0), (2, 901, 't2', 'quiet', ?, 1)").run(DANA, DANA);
await db.prepare("INSERT INTO threads (id, space_id, slug, title, author_subject, score) OVERRIDING SYSTEM VALUE VALUES (1, 901, 't1', 'busy', ?, 0), (2, 901, 't2', 'quiet', ?, 1)").run(DANA, DANA);
@@ -82,6 +84,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
@@ -82,6 +84,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
assert.deepStrictEqual(files['pastes.json'].map((p) => p.slug).sort(), ['p1', 'p2'], 'her pastes, with their content');
assert.deepStrictEqual(files['pastes.json'].map((p) => p.slug).sort(), ['p1', 'p2'], 'her pastes, with their content');
assert.strictEqual(files['pastes.json'].find((p) => p.slug === 'p1').content, 'secret words');
assert.strictEqual(files['pastes.json'].find((p) => p.slug === 'p1').content, 'secret words');
assert.ok(files['comments.json'] && files['posts.json'] && files['blocks.json'] && files['game_progress.json']);
assert.ok(files['comments.json'] && files['posts.json'] && files['blocks.json'] && files['game_progress.json']);
+ assert.strictEqual(files['comment_attachments.json'][0].media_id, 'med_01JAB2C3D4E5F6G7H8J9K0MNP6');
r = await post(exp);
r = await post(exp);
assert.strictEqual(r.json.outcome, 'unchanged');
assert.strictEqual(r.json.outcome, 'unchanged');
assert.strictEqual(sent.length, 1);
assert.strictEqual(sent.length, 1);
@@ -104,6 +107,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
@@ -104,6 +107,7 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');
{ id: 1, a: OTTO, message: 'nice' }, { id: 2, a: null, message: '[deleted]' }, { id: 3, a: OTTO, message: 'hi dana' }], 'her answered comment a tombstone; the other gone');
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, message, score, reply_count FROM comments ORDER BY id'), [
{ id: 1, a: null, message: '[deleted]', score: 0, reply_count: 1 }, { id: 2, a: OTTO, message: 'reply', score: 0, reply_count: 0 }], 'votes gone and the score recomputed');
{ id: 1, a: null, message: '[deleted]', score: 0, reply_count: 1 }, { id: 2, a: OTTO, message: 'reply', score: 0, reply_count: 0 }], 'votes gone and the score recomputed');
+ assert.strictEqual((await all('SELECT * FROM comment_attachments')).length, 0, 'comment attachments are removed from a deleted account');
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, title FROM threads ORDER BY id'), [{ id: 1, a: null, title: '[deleted]' }], 'the busy thread a tombstone, the quiet one gone');
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, title FROM threads ORDER BY id'), [{ id: 1, a: null, title: '[deleted]' }], 'the busy thread a tombstone, the quiet one gone');
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, body_markdown AS b FROM posts ORDER BY id'), [{ id: 1, a: null, b: '[deleted]' }, { id: 2, a: OTTO, b: 'otto answers' }]);
assert.deepStrictEqual(await all('SELECT id, author_subject AS a, body_markdown AS b FROM posts ORDER BY id'), [{ id: 1, a: null, b: '[deleted]' }, { id: 2, a: OTTO, b: 'otto answers' }]);
assert.strictEqual((await all('SELECT * FROM post_reactions')).length + (await all('SELECT * FROM paste_likes')).length, 0);
assert.strictEqual((await all('SELECT * FROM post_reactions')).length + (await all('SELECT * FROM paste_likes')).length, 0);
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+'use strict';
+
+const assert = require('node:assert/strict');
+const { cleanReference, publicReference } = require('../server/comments/attachments');
+const { createCommentsApi } = require('../server/comments/api');
+const { commentPanel } = require('../server/render/comments');
+
+const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const ID = 'med_01JAB2C3D4E5F6G7H8J9K0MNP6';
+
+const input = { id: ID, name: 'look.png', media_type: 'image/png', size: 12,
+ storage: { provider: 'media', key: ID }, access: { readers: [SUBJECT] },
+ read: { method: 'not_readable', reason: 'Attached; open the comment to read it.' }, role: 'input' };
+assert.deepEqual(cleanReference(input, SUBJECT), input);
+assert.throws(() => cleanReference({ ...input, storage: { provider: 'media', key: 'different' } }, SUBJECT), { code: 'comment.attachment_invalid' });
+const output = publicReference({ media_id: ID, filename: 'look.png', mime: 'image/png', size_bytes: 12, owner_subject: SUBJECT });
+assert.deepEqual(Object.keys(output).sort(), Object.keys(input).sort());
+assert.equal(output.id, input.id);
+assert.equal(output.storage.key, input.storage.key);
+const html = commentPanel({ page: { thread: { ref: { service: 'community', type: 'paste', id: 'paste-1' }, comment_count: 1 },
+ comments: [{ id: 1, message: 'Picture', created_at: null, author: { display_name: 'Owner' }, attachments: [output] }],
+ viewer: { can_comment: false } }, user: null, base: '/p/paste-1' });
+assert.ok(html.includes(`<img src="https://openvibe.media/o/${ID}" alt="look.png" loading="lazy">`));
+assert.ok(html.includes('rel="noopener noreferrer"'));
+assert.equal(typeof createCommentsApi, 'function');
@@ -180,6 +180,42 @@ const { checkCapability } = require('../server/identity/capabilities');
@@ -180,6 +180,42 @@ const { checkCapability } = require('../server/identity/capabilities');
assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}?after=abc`)).status, 400);
assert.strictEqual((await call(`/api/v1/comments/threads/${th.id}?after=abc`)).status, 400);
});
});
+ await check('comment attachments upload through Community, are owner-validated, render on both paste and thread reads, and clear on removal', async () => {
+ const fileOwner = net.addUser({ network_user_id: 27, username: 'fileowner', display_name: 'File Owner' });
+ const fileOwnerJwt = net.sign({ id: 27, subject_id: fileOwner.subject_id, username: 'fileowner', display_name: 'File Owner', role: 'user' });
+ const stranger = net.addUser({ network_user_id: 28, username: 'attachmentstranger', display_name: 'Attachment Stranger' });
+ const strangerJwt = net.sign({ id: 28, subject_id: stranger.subject_id, username: 'attachmentstranger', display_name: 'Attachment Stranger', role: 'user' });
+ const slug = (await t.app.locals.pastes.createText({ kind: 'user', subject: fileOwner.subject_id, origin: 'user' }, { content: 'comment file', visibility: 'public' })).slug;
+ const ref = { service: 'community', type: 'paste', id: slug };
+ const form = new FormData();
+ form.set('ref', JSON.stringify(ref));
+ form.set('file', new Blob([Buffer.from('picture bytes')], { type: 'image/png' }), 'look.png');
+ const uploaded = await t.get('/api/v1/comments/attachments', { method: 'POST', headers: { authorization: `Bearer ${fileOwnerJwt}` }, body: form });
+ assert.strictEqual(uploaded.status, 201, uploaded.text);
+ const attachment = uploaded.json().attachment;
+ assert.strictEqual(attachment.name, 'look.png');
+ assert.strictEqual(attachment.storage.provider, 'media');
+ assert.deepStrictEqual(attachment.access.readers, [fileOwner.subject_id]);
+
+ const thread = (await resolve(ref)).json().thread;
+ const added = await post(thread.id, { message: 'with picture', attachments: [attachment] }, { cookie: fileOwnerJwt });
+ assert.strictEqual(added.status, 201, added.text);
+ assert.deepStrictEqual(added.json().comment.attachments.map((a) => a.id), [attachment.id]);
+ const paste = (await call(`/api/pastes/${slug}/comments`)).json();
+ assert.deepStrictEqual(paste.comments[0].attachments.map((a) => a.id), [attachment.id]);
+ const html = await t.get(`/c/${thread.id}`);
+ assert.match(html.text, new RegExp(`<img src="https://openvibe\\.media/o/${attachment.id}"`));
+
+ const stolen = await post(thread.id, { message: 'stolen', attachments: [attachment] }, { cookie: strangerJwt });
+ assert.strictEqual(stolen.status, 400);
+ const removed = await call(`/api/v1/comments/${added.json().comment.id}`, { method: 'DELETE', cookie: fileOwnerJwt });
+ assert.strictEqual(removed.status, 200);
+ const after = (await call(`/api/v1/comments/threads/${thread.id}`)).json();
+ assert.deepStrictEqual(after.comments, []);
+ assert.strictEqual((await t.db.prepare('SELECT COUNT(*) AS n FROM comment_attachments WHERE media_id = ?').get(attachment.id)).n, 0);
+ assert.ok(t.media.deletes.includes(attachment.id), 'removing the comment retires its Community Media object');
+ });
+
await check('section reactions: one thread per post, the section id is the key; people only', async () => {
await check('section reactions: one thread per post, the section id is the key; people only', async () => {
const ref = { service: 'blog', type: 'post', id: 'pst_1' };
const ref = { service: 'blog', type: 'post', id: 'pst_1' };
const thread = (await resolve(ref, { cookie: alexJwt })).json().thread;
const thread = (await resolve(ref, { cookie: alexJwt })).json().thread;
@@ -63,7 +63,7 @@ function start({ publicPem, issuer }) {
@@ -63,7 +63,7 @@ function start({ publicPem, issuer }) {
if (!v2 && !normalize && (req.method !== 'POST' || req.url !== '/api/v1/community/files')) return json(404, { error: 'not found' });
if (!v2 && !normalize && (req.method !== 'POST' || req.url !== '/api/v1/community/files')) return json(404, { error: 'not found' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
if (!v.ok) return json(401, { code: v.code, error: v.reason });
if (!v.ok) return json(401, { code: v.code, error: v.reason });
- const verb = v2 && req.method === 'GET' && v2[2] === 'download' ? 'media.object.read' : 'media.object.upload';
+ const verb = v2 && req.method === 'GET' ? 'media.object.read' : req.method === 'DELETE' ? 'media.object.delete' : 'media.object.upload';
if (!(v.claims.cap || []).includes(verb)) return json(403, { code: 'capability.denied', error: 'not granted' });
if (!(v.claims.cap || []).includes(verb)) return json(403, { code: 'capability.denied', error: 'not granted' });
const body = Buffer.concat(chunks);
const body = Buffer.concat(chunks);
if (normalize) {
if (normalize) {
@@ -77,6 +77,18 @@ function start({ publicPem, issuer }) {
@@ -77,6 +77,18 @@ function start({ publicPem, issuer }) {
// Private objects answer a short-lived URL of their own; the shape is what Community reads.
// Private objects answer a short-lived URL of their own; the shape is what Community reads.
return json(200, { url: `http://127.0.0.1:${server.address().port}/o/${o.key}`, public: o.visibility !== 'private' });
return json(200, { url: `http://127.0.0.1:${server.address().port}/o/${o.key}`, public: o.visibility !== 'private' });
}
}
+ if (v2 && req.method === 'GET' && v2[1] && !v2[2]) {
+ const o = uploads.find((u) => u.key === v2[1]);
+ if (!o) return json(404, { code: 'media.object.not_found' });
+ return json(200, { id: o.key, owner: { subject: o.owner || null }, lifecycle_status: 'ready', mime_type: o.mime,
+ size_bytes: o.bytes.length, metadata: { filename: o.name }, public_url: `http://127.0.0.1:${server.address().port}/o/${o.key}` });
+ }
+ if (v2 && req.method === 'DELETE' && v2[1]) {
+ const i = uploads.findIndex((u) => u.key === v2[1]);
+ if (i < 0) return json(404, { code: 'media.object.not_found' });
+ uploads.splice(i, 1); deletes.push(v2[1]);
+ return json(200, { id: v2[1], lifecycle_status: 'deleted' });
+ }
if (v2) {
if (v2) {
if (req.method === 'POST' && !v2[1]) {
if (req.method === 'POST' && !v2[1]) {
if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
@@ -23,7 +23,7 @@ const assert = require('assert');
@@ -23,7 +23,7 @@ const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { boot, check, done } = require('./helpers/app');
const TABLES = ['pastes', 'paste_versions', 'paste_comments', 'paste_likes', 'spaces', 'space_groups', 'categories', 'threads', 'posts', 'post_versions',
const TABLES = ['pastes', 'paste_versions', 'paste_comments', 'paste_likes', 'spaces', 'space_groups', 'categories', 'threads', 'posts', 'post_versions',
- 'post_reactions', 'post_pastes', 'thread_votes', 'attachments', 'comment_threads', 'comments', 'comment_votes', 'space_chat_rooms', 'relay_mappings', 'relay_deliveries', 'pulse_items'];
+ 'post_reactions', 'post_pastes', 'thread_votes', 'attachments', 'comment_attachments', 'comment_threads', 'comments', 'comment_votes', 'space_chat_rooms', 'relay_mappings', 'relay_deliveries', 'pulse_items'];
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
const PASTE_CAPS = ['community.paste.create', 'community.paste.write'];
const PASTE_CAPS = ['community.paste.create', 'community.paste.write'];
const FORUM_CAPS = ['community.post.create', 'community.comment.write'];
const FORUM_CAPS = ['community.post.create', 'community.comment.write'];