openvibers/openvibe.community Commits
Attach composer files to Community status posts (#367)
Changes
@@@ -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;@@@ -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));@@@ -1006,6 +1006,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vireturn { 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);@@@ -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,@@@ -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,@@@ -252,6 +252,31 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().reassert.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 });@@@ -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'],
@@ -330,7 +330,7 @@ async function createApp(opts = {}) {
@@ -330,7 +330,7 @@ async function createApp(opts = {}) {
// and these deliveries carry token cutoffs and account deletions.
// and these deliveries carry token cutoffs and account deletions.
app.use('/internal/events', pulseConsumer.router);
app.use('/internal/events', pulseConsumer.router);
app.use('/api/v1/spaces', createSpacesApi({ forum, viewers, limits }));
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/space-groups', createGroupsApi({ forum, viewers, limits }));
app.use('/api/v1/relay', createRelayApi({ relay, db, viewers, inbound: relayInbound, 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;
// OpenVibe.Services reads a person's pastes and spaces here (community.resource.read, a service token on loopback;
@@ -116,7 +116,7 @@ function createSpacesApi({ forum, viewers, limits }) {
@@ -116,7 +116,7 @@ function createSpacesApi({ forum, viewers, limits }) {
return router;
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();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.middleware());
router.use(viewers.middleware());
@@ -131,7 +131,7 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' })
@@ -131,7 +131,7 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' })
const v = req.viewer;
const v = req.viewer;
if (!v || v.kind !== 'user' || !v.subject) fail(401, 'post.sign_in_required', 'Sign in to post');
if (!v || v.kind !== 'user' || !v.subject) fail(401, 'post.sign_in_required', 'Sign in to post');
const body = req.body || {};
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() : '';
const text = typeof body.text === 'string' ? body.text.trim() : '';
if (!text) fail(400, 'post.invalid', 'Write something first');
if (!text) fail(400, 'post.invalid', 'Write something first');
if (text.length > 2000) fail(400, 'post.invalid', 'Text is at most 2000 characters');
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 = '' })
@@ -140,8 +140,33 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' })
for (const p of pictures) {
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 }');
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 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 })) });
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) {
if (pulse) {
await pulse.forgetLocal('thread', thread.id);
await pulse.forgetLocal('thread', thread.id);
const first = (post.attachments || [])[0];
const first = (post.attachments || [])[0];
@@ -149,7 +174,8 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' })
@@ -149,7 +174,8 @@ function createPostsApi({ forum, pulse = null, viewers, limits, baseUrl = '' })
}
}
return { id: thread.id, post_id: post.id, text,
return { id: thread.id, post_id: post.id, text,
url: `${String(baseUrl).replace(/\/$/, '')}/forum/status/${thread.slug}`,
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 };
created_at: thread.created_at };
}, 201));
}, 201));
@@ -1006,6 +1006,16 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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])) };
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 } */
/** Reply { body } → { post } */
async reply(v, spaceSlug, threadSlug, body = {}) {
async reply(v, spaceSlug, threadSlug, body = {}) {
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
const { space, thread } = await threadFor(v, spaceSlug, threadSlug);
@@ -611,13 +611,14 @@ route('delete', '/api/v1/spaces/{space}/moderators/{subject}', {
@@ -611,13 +611,14 @@ route('delete', '/api/v1/spaces/{space}/moderators/{subject}', {
// ── /api/v1/posts — one forum post (server/forum/api.js) ────────────────────────────────────
// ── /api/v1/posts — one forum post (server/forum/api.js) ────────────────────────────────────
route('post', '/api/v1/posts', {
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: {
requestBody: body({ type: 'object', required: ['text'], properties: {
text: str({ minLength: 1, maxLength: 2000 }),
text: str({ minLength: 1, maxLength: 2000 }),
pictures: { type: 'array', maxItems: 4, items: { type: 'object', required: ['media_id', 'alt'], properties: {
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}', {
route('put', '/api/v1/posts/{id}', {
operationId: 'editPost', summary: 'Edit a post (its author or a moderator)', tags: ['posts'], security: PERSON,
operationId: 'editPost', summary: 'Edit a post (its author or a moderator)', tags: ['posts'], security: PERSON,
@@ -221,7 +221,9 @@ function crosspostBlock(c, base) {
@@ -221,7 +221,9 @@ function crosspostBlock(c, base) {
/** A post's images. */
/** A post's images. */
function postImages(p) {
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,
* A post's ratings (Facepunch-style): the ratings given, as buttons that take yours back or switch to them,
@@ -252,6 +252,31 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
@@ -252,6 +252,31 @@ const at = (minsAgo) => new Date(Date.now() - minsAgo * 60_000).toISOString().re
assert.strictEqual(row.alt, 'Direct cat');
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 () => {
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);
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 });
let r = await json(alexJwt, '/api/v1/posts', { text: 'x', secret: 1 });
@@ -26,6 +26,7 @@ const INVENTORY = {
@@ -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/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/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/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/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/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'],
'server/identity/account-data.js': [2, 'Network /oauth/token and /internal/account-exports and /internal/account-deletions (fixed paths) with a service token'],