openvibers/openvibe.community Commits

Attach composer files to Community status posts (#367)

OP OpenVibers committed 2 days ago · 2e964a2df2 · 7 files, +74 −9

* Attach Media composer files to Community status posts

* Return attachment gateway failures as service errors

* Account for Media keep call in SSRF inventory

Changes

ddiff --git a/server/app.js b/server/app.js
iindex 5b1fdfa..a621500 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -330,7 +330,7 @@ async function createApp(opts = {}) {
// and these deliveries carry token cutoffs and account deletions.
app.use('/internal/events', pulseConsumer.router);
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers, limits }));
-- app.use('/api/v1/posts', createPostsApi({ forum, pulse, viewers, limits, baseUrl: config.baseUrl }));
++ app.use('/api/v1/posts', createPostsApi({ forum, pulse, viewers, limits, baseUrl: config.baseUrl, mediaUrl: config.mediaUrl }));
app.use('/api/v1/space-groups', createGroupsApi({ forum, viewers, limits }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, limits }));
// OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 13d53a6..40de699 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -116,7 +116,7 @@ function createSpacesApi({ forum, viewers, limits }) {
return router;
}
--function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' }) {
++function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '', mediaUrl = 'https://openvibe.media' }) {
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
@@@ -131,7 +131,7 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' })
const v = req.viewer;
if (!v || v.kind !== 'user' || !v.subject) fail(401, 'post.sign_in_required', 'Sign in to post');
const body = req.body || {};
-- if (Object.keys(body).some((k) => !['text', 'pictures'].includes(k))) fail(400, 'post.invalid', 'Only text and pictures are accepted');
++ if (Object.keys(body).some((k) => !['text', 'pictures', 'attachments'].includes(k))) fail(400, 'post.invalid', 'Only text, pictures and attachments are accepted');
const text = typeof body.text === 'string' ? body.text.trim() : '';
if (!text) fail(400, 'post.invalid', 'Write something first');
if (text.length > 2000) fail(400, 'post.invalid', 'Text is at most 2000 characters');
@@@ -140,8 +140,33 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' })
for (const p of pictures) {
if (!p || typeof p.media_id !== 'string' || typeof p.alt !== 'string') fail(400, 'post.invalid_pictures', 'Every picture is { media_id, alt }');
}
++ const fileIds = body.attachments == null ? [] : body.attachments;
++ if (!Array.isArray(fileIds) || fileIds.length > 10 || fileIds.some((id) => !/^att_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) || new Set(fileIds).size !== fileIds.length) {
++ fail(400, 'post.invalid_attachments', 'attachments is an array of up to ten Media composer attachment ids');
++ }
++ const files = [];
++ const mediaOrigin = new URL(mediaUrl).origin;
++ for (const id of fileIds) {
++ let kept;
++ try {
++ kept = await fetch(`${mediaOrigin}/api/v1/attachments/${id}/keep`, {
++ method: 'POST', headers: { Authorization: String(req.get('authorization') || ''), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ message_id: `community:post:${id}` }), signal: AbortSignal.timeout(10000),
++ });
++ } catch { fail(502, 'post.attachment_unavailable', 'Media did not answer'); }
++ if (!kept.ok) fail(kept.status === 404 ? 400 : 502, 'post.attachment_unavailable', 'A file could not be attached');
++ const f = await kept.json();
++ let url;
++ try { url = new URL(String(f.url || ''), mediaOrigin); } catch { fail(502, 'post.attachment_invalid', 'Media returned an invalid attachment'); }
++ if (f.kind === 'image' || /^image\//.test(String(f.mime || ''))) fail(400, 'post.picture_alt_required', 'Pictures need alt text in pictures');
++ if (f.id !== id || url.origin !== mediaOrigin || !url.pathname.startsWith(`/a/${id}/`) || !Number.isFinite(Number(f.size)) || Number(f.size) < 0) {
++ fail(502, 'post.attachment_invalid', 'Media returned an invalid attachment');
++ }
++ files.push({ id, name: String(f.name || 'File').slice(0, 255), mime: String(f.mime || 'application/octet-stream').slice(0, 120), size: Number(f.size), url: url.href });
++ }
const title = `Status: ${text.replace(/https?:\/\/[^\s<>]+/gi, '').trim().slice(0, 110) || 'Shared a picture'}`;
const { thread, post } = await forum.createThread(v, 'status', { title, body: text, pictures: pictures.map((p) => ({ media_id: p.media_id, alt: p.alt })) });
++ if (files.length) await forum.attachComposerFiles(v, post.id, files);
if (pulse) {
await pulse.forgetLocal('thread', thread.id);
const first = (post.attachments || [])[0];
@@@ -149,7 +174,8 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' })
}
return { id: thread.id, post_id: post.id, text,
url: `${String(baseUrl).replace(/\/$/, '')}/forum/status/${thread.slug}`,
-- pictures: (post.attachments || []).map((a) => ({ media_id: a.media_id, alt: a.alt || null, url: a.url })),
++ pictures: (post.attachments || []).filter((a) => /^image\//.test(a.mime)).map((a) => ({ media_id: a.media_id, alt: a.alt || null, url: a.url })),
++ attachments: files,
created_at: thread.created_at };
}, 201));
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex fc67d60..93cbaea 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -1006,6 +1006,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return { thread: await shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, await attachmentsOf([post.id]), await pastesOf([post.id])) };
},
++ /** Media's composer files have already been checked and kept for this person by the posts route. */
++ async attachComposerFiles(v, postId, files) {
++ if (!person(v) || !v.subject) fail(401, 'post.sign_in_required', 'Sign in to attach files');
++ const start = await db.prepare('SELECT COALESCE(MAX(position), -1) AS position FROM attachments WHERE post_id = ?').get(postId);
++ for (const [index, f] of files.entries()) {
++ await db.prepare(`INSERT INTO attachments (media_id, owner_subject, post_id, position, filename, mime, size_bytes, url)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(f.id, v.subject, postId, Number(start.position) + index + 1, f.name, f.mime, f.size, f.url);
++ }
++ },
++
/** Reply { body } → { post } */
async reply(v, spaceSlug, threadSlug, body = {}) {
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 117a225..e159247 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -611,13 +611,14 @@ route('delete', '/api/v1/spaces/{space}/moderators/{subject}', {
// ── /api/v1/posts — one forum post (server/forum/api.js) ────────────────────────────────────
route('post', '/api/v1/posts', {
-- operationId: 'createPost', summary: 'Post a status with pictures, in the Feed at once', tags: ['posts'], security: PERSON,
-- description: 'People only. Pictures are Media objects the person owns, each with required alt text; the post is a thread in the Status space.',
++ operationId: 'createPost', summary: 'Post a status with pictures and files, in the Feed at once', tags: ['posts'], security: PERSON,
++ description: 'People only. Pictures are Media objects the person owns, each with required alt text. Other files are composer attachments from Media; the post is a thread in the Status space.',
requestBody: body({ type: 'object', required: ['text'], properties: {
text: str({ minLength: 1, maxLength: 2000 }),
pictures: { type: 'array', maxItems: 4, items: { type: 'object', required: ['media_id', 'alt'], properties: {
-- media_id: str({ pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' }), alt: str({ minLength: 1, maxLength: 300 }) } } } } }),
-- responses: { 201: json('The post and its pictures'), ...errors([400, 401, 409, 429]) },
++ media_id: str({ pattern: '^med_[0-9A-HJKMNP-TV-Z]{26}$' }), alt: str({ minLength: 1, maxLength: 300 }) } } },
++ attachments: { type: 'array', maxItems: 10, items: str({ pattern: '^att_[0-9A-HJKMNP-TV-Z]{26}$' }) } } }),
++ responses: { 201: json('The post, its pictures and files'), ...errors([400, 401, 409, 429, 502]) },
});
route('put', '/api/v1/posts/{id}', {
operationId: 'editPost', summary: 'Edit a post (its author or a moderator)', tags: ['posts'], security: PERSON,
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex 64894a7..81c60f1 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -221,7 +221,9 @@ function crosspostBlock(c, base) {
/** A post's images. */
function postImages(p) {
-- return `${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.alt || a.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}`;
++ return `${(p.attachments || []).length ? `<div class="post-images">${p.attachments.map((a) => /^image\//.test(a.mime || '')
++ ? `<a href="${esc(a.url)}" target="_blank" rel="noopener"><img src="${esc(a.url)}" alt="${esc(a.alt || a.filename || 'Image')}" loading="lazy" decoding="async"></a>`
++ : `<a href="${esc(a.url)}" target="_blank" rel="noopener">${esc(a.filename || 'Download file')}</a>`).join('')}</div>` : ''}`;
}
/**
* A post's ratings (Facepunch-style): the ratings given, as buttons that take yours back or switch to them,
ddiff --git a/test/feed-api.test.js b/test/feed-api.test.js
iindex b2bc414..4f94089 100644
---- a/test/feed-api.test.js
++++ b/test/feed-api.test.js
@@@ -252,6 +252,31 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(row.alt, 'Direct cat');
});
++ await check('a post keeps a PDF composer attachment and renders it as a download', async () => {
++ const id = 'att_01ABCDEFGHJKMNPQRSTVWXYZ01';
++ const original = global.fetch;
++ let kept = false;
++ global.fetch = (url, options) => {
++ if (String(url).endsWith(`/api/v1/attachments/${id}/keep`)) {
++ kept = options.method === 'POST' && options.headers.Authorization === `Bearer ${alexJwt}`;
++ return Promise.resolve(new Response(JSON.stringify({ id, kind: 'file', name: 'notes.pdf', mime: 'application/pdf', size: 123, url: `https://openvibe.media/a/${id}/notes.pdf` }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
++ }
++ return original(url, options);
++ };
++ try {
++ const r = await json(alexJwt, '/api/v1/posts', { text: 'Here are my notes', attachments: [id] });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.ok(kept, 'Media keeps the file using the person token');
++ assert.strictEqual(r.json().attachments[0].id, id);
++ const row = await db.prepare('SELECT filename, mime, url FROM attachments WHERE media_id = ?').get(id);
++ assert.strictEqual(row.filename, 'notes.pdf');
++ assert.strictEqual(row.mime, 'application/pdf');
++ assert.ok(row.url.endsWith('/notes.pdf'));
++ const page = await t.get(new URL(r.json().url).pathname);
++ assert.ok(page.text.includes('notes.pdf'), 'the post page links the file');
++ } finally { global.fetch = original; }
++ });
++
await check('a signed-out post, a text with extra fields and more than four pictures are refused', async () => {
assert.strictEqual((await t.get('/api/v1/posts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ text: 'x' }) })).status, 401);
let r = await json(alexJwt, '/api/v1/posts', { text: 'x', secret: 1 });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 00bd294..26ee9e5 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -26,6 +26,7 @@ const INVENTORY = {
'server/auth/routes.js': [1, 'createAuthClient(config): Network\'s keys (openvibe-sdk/auth, OV_NETWORK_INTERNAL_URL); the OAuth token/revoke calls are openvibe-sdk/sso\'s now'],
'server/chat-rooms.js': [1, 'OV_CHAT_INTERNAL_URL /api/chat/rooms/<slug>/attachments; the slug must match ROOM_SLUG and is encoded'],
'server/events.js': [3, 'service token (Network) and the outbox publisher: EVENTS_URL'],
++ 'server/forum/api.js': [1, 'Media composer attachment keep at the configured mediaUrl origin and a fixed path; the attachment id is validated as att_ plus a ULID'],
'server/identity/network.js': [3, 'Network /internal/identity/resolve-batch and /internal/identity/resolve?username= with a service token (configured base, never a user URL)'],
'server/render/pages.js': [1, 'not a call: the home page shows a fetch() sample as text (never executed)'],
'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],