openvibers/openvibe.community Commits

Images on forum posts as med_ references (WS-J task 2): up to 4 PNG/JPEG/GIF/WebP images (by content, 8 MB, metadata stripped) per thread or reply, stored through OpenVibe.Media's Object API v2 (init, content, complete) as public objects the person owns; POST /api/v1/spaces/:space/attachments then attachments: [media_id]; the no-JS forms upload and attach in one step; the roadmap marks it done

OP OpenVibers committed 25 Sept 2026 · 87086722d5 · 11 files, +364 −19


Changes

ddiff --git a/README.md b/README.md
iindex c9b4eef..6680a32 100644
---- a/README.md
++++ b/README.md
@@@ -329,6 +329,11 @@ member for now), `staff` (moderators only; looks missing to everyone else).
every boot syncs it (`server/forum/roadmap.js`), one thread per item, matched by key. A changed summary
is an edit of the item's opening post, so the history stays, and replies and votes stay with the item.
++- **Images on posts.** Up to 4 per thread or reply: PNG, JPEG, GIF or WebP by content, 8 MB each, with
++ metadata stripped. Each is stored in OpenVibe.Media's Object API as a public `med_` object owned by the
++ person (tenant `community`, Community's service token; `server/media/objects.js`). `attachments` holds
++ the reference until the post naming it is saved. The no-JS forms upload and attach in one step.
++
API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| Route | What |
@@@ -336,6 +341,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| `GET /spaces` · `GET /spaces/:space` | Spaces the caller can open · one space |
| `GET /spaces/:space/threads?sort=&page=&limit=&category=&status=` | Threads (server pagination: `page`, `pages`, `total`), the space's `categories`, optionally one category or status |
| `POST /spaces/:space/threads` `{ title, body, category?, members_only? }` | New thread (`community.post.create` for services); `members_only: true` gates it to the author's VIP members |
++| `POST /spaces/:space/attachments` (multipart `file`) | An image for a new thread or reply → `{ attachment: { media_id, url, … } }`; name it in `attachments: [media_id]` when posting |
| `GET /spaces/:space/categories` · `PUT`/`DELETE /spaces/:space/categories/:category` `{ name, description?, position? }` | Categories · moderators manage them (a deleted category's threads stay, uncategorised) |
| `PUT /spaces/:space/threads/:slug/category` `{ category: slug \| null }` | The author or moderators |
| `PUT /spaces/:space/threads/:slug/status` `{ status }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` |
ddiff --git a/docs/roadmap/public.json b/docs/roadmap/public.json
iindex f0191b0..d72fd7e 100644
---- a/docs/roadmap/public.json
++++ b/docs/roadmap/public.json
@@@ -17,8 +17,8 @@
"summary": "First the network's own deployment tooling in the open, then simple hosting for community sites, bots and mods." },
{ "key": "chat-calls", "category": "features", "status": "planned", "title": "Voice and video calls in OpenVibe.Chat",
"summary": "Calls move from Live into OpenVibe.Chat, so rooms and direct messages can turn into a call on any site." },
-- { "key": "community-attachments", "category": "features", "status": "planned", "title": "Images and files on Community posts",
-- "summary": "Attach images and files to threads and replies, stored in OpenVibe.Media like every other upload." },
++ { "key": "community-attachments", "category": "features", "status": "done", "title": "Images on Community posts",
++ "summary": "Attach up to four images to a thread or a reply, with or without JavaScript. They are stored in OpenVibe.Media like every other upload, with location and camera details removed first." },
{ "key": "feedback-and-roadmap", "category": "features", "status": "done", "title": "Categories, feature requests and this roadmap",
"summary": "Feedback has categories (ideas, bugs, questions), requests you can vote on, and a status staff keep up to date. This Roadmap space shows what is being built, with a thread for every item." },
{ "key": "your-data", "category": "features", "status": "in_progress", "title": "See, download and delete your data",
ddiff --git a/public/css/community.css b/public/css/community.css
iindex 1fe1bdd..28b2fd0 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -176,6 +176,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
.status-done { border-color: var(--success); color: var(--success); }
.status-declined, .status-paused { color: var(--text-muted, var(--text-secondary)); }
.thread-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 0; }
++.post-images { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 8px; margin-top: 10px; }
++.post-images img { display: block; width: 100%; max-height: 320px; object-fit: contain; border-radius: 10px; border: 1px solid var(--border-light); background: var(--bg-secondary, transparent); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 10px; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 999px; border: 1px solid var(--border-light); color: var(--text-secondary); font-size: .85rem; font-weight: 600; }
.chip:hover { border-color: var(--accent); text-decoration: none; }
ddiff --git a/server/app.js b/server/app.js
iindex 92ff128..c8382a0 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -124,7 +124,9 @@ function createApp(opts = {}) {
});
// OpenVibe.VIP: members-only spaces and threads (fails closed without a client secret or VIP).
const vip = opts.vip || createVipGate({ config, ...(opts.vipOptions || {}) });
-- const forum = createForumService({ db, network, pulse, relay, vip, limits: opts.forumLimits });
++ // Images on posts go to OpenVibe.Media's Object API as med_ objects (media/objects.js).
++ const mediaObjects = opts.mediaObjects || require('./media/objects').createMediaObjects({ config });
++ const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, limits: opts.forumLimits });
const community = config.pastesAuthority === 'community';
const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });
seo.useForum(forum);
ddiff --git a/server/db.js b/server/db.js
iindex f57fcc4..4e7497f 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -245,6 +245,21 @@ CREATE TABLE IF NOT EXISTS thread_votes (
-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────
-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never
-- live in the database.
++-- Images attached to forum posts (WS-J task 2): the bytes are a Media object (med_…, tenant community,
++-- owned by the person); an upload waits here with no post until the thread or reply naming it is saved.
++CREATE TABLE IF NOT EXISTS attachments (
++ media_id TEXT PRIMARY KEY,
++ owner_subject TEXT NOT NULL,
++ post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
++ position INTEGER NOT NULL DEFAULT 0,
++ filename TEXT,
++ mime TEXT NOT NULL,
++ size_bytes INTEGER NOT NULL,
++ url TEXT NOT NULL,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
++);
++CREATE INDEX IF NOT EXISTS idx_attachments_post ON attachments(post_id, position) WHERE post_id IS NOT NULL;
++
-- Categories inside a space (WS-J task 1): threads may carry one; the space page filters by them.
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ddiff --git a/server/forum/api.js b/server/forum/api.js
iindex 0d77e16..14d9db9 100644
---- a/server/forum/api.js
++++ b/server/forum/api.js
@@@ -16,6 +16,8 @@
* PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)
* PUT /spaces/:space/threads/:slug/members-only { owner | true | null } the author (own members) or moderators
* POST /spaces/:space/threads { …, members_only: true | { owner } } start a members-only thread
++ * POST /spaces/:space/attachments (multipart `file`) an image for a new thread or reply → { attachment: { media_id, url, … } };
++ * then POST …/threads or …/posts with { attachments: [media_id] } (at most 4)
* GET /spaces/:space/categories the space's categories (?category=<slug> filters threads)
* PUT /spaces/:space/categories/:category { name, description?, position? } moderators
* DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)
@@@ -30,6 +32,7 @@
* ({ reason, gate, members_only: { owner, owner_username, join_url } }).
*/
const express = require('express');
++const multer = require('multer');
const contracts = require('openvibe-contracts');
const { run, serviceCap, serviceAnyCap, jsonBody } = require('../http/v1');
@@@ -48,6 +51,12 @@ function createSpacesApi({ forum, viewers }) {
router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));
router.get('/:space/threads', run((req) => forum.listThreads(req.viewer, p(req).space, req.query)));
router.get('/:space/categories', run((req) => forum.categories(req.viewer, p(req).space)));
++ // An image to attach: multipart `file`; then name its media_id in `attachments` when posting.
++ const one = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 1, fields: 5 } }).single('file');
++ const withFile = (req, res, next) => one(req, res, (err) => (err
++ ? contracts.http.sendProblem(res, err.code === 'LIMIT_FILE_SIZE' ? 413 : 400, err.code === 'LIMIT_FILE_SIZE' ? 'attachments.too_large' : 'attachments.invalid', { detail: err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : 'Send one image as multipart field `file`', ctx: req.ov })
++ : next()));
++ router.post('/:space/attachments', write, withFile, run((req) => forum.uploadAttachment(req.viewer, p(req).space, req.file), 201));
router.put('/:space/categories/:category', serviceCap(MOD), jsonBody, run((req) => forum.putCategory(req.viewer, p(req).space, p(req).category, req.body || {})));
router.delete('/:space/categories/:category', serviceCap(MOD), run((req) => forum.deleteCategory(req.viewer, p(req).space, p(req).category)));
router.put('/:space/threads/:slug/category', writeOrMod, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
ddiff --git a/server/forum/routes.js b/server/forum/routes.js
iindex c9920bb..d591286 100644
---- a/server/forum/routes.js
++++ b/server/forum/routes.js
@@@ -18,6 +18,7 @@
* visitor); an Origin header from somewhere else is refused as well.
*/
const express = require('express');
++const multer = require('multer');
const seo = require('../seo');
const pages = require('../render/pages');
const forumPages = require('../render/forum');
@@@ -27,6 +28,23 @@ function createForumRoutes({ forum, viewers, config }) {
const router = express.Router();
const withViewer = viewers.middleware({ services: false });
const form = express.urlencoded({ extended: false, limit: '256kb' });
++ // New threads and replies may carry up to 4 images (multipart `attachments`); a urlencoded form passes through.
++ const imageParser = multer({ storage: multer.memoryStorage(), limits: { fileSize: 8 * 1024 * 1024, files: 4, fields: 20, fieldSize: 64 * 1024 } }).array('attachments', 4);
++ const withImages = (req, res, next) => imageParser(req, res, (err) => {
++ if (err) req.uploadError = err.code === 'LIMIT_FILE_SIZE' ? 'Images are limited to 8 MB' : (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') ? 'At most 4 images per post' : 'The images could not be read';
++ next();
++ });
++ /** Store the form's images in Media first. → their media ids (the post then names them) */
++ async function uploadImages(req) {
++ if (req.uploadError) throw new ApiError(400, 'attachments.invalid', req.uploadError);
++ const ids = [];
++ for (const f of req.files || []) {
++ if (!f || !f.size) continue;
++ ids.push((await forum.uploadAttachment(req.viewer, req.params.space, f)).attachment.media_id);
++ }
++ return ids;
++ }
++ const worthUploading = (title, body) => (title == null || String(title).trim().length >= 3) && String(body || '').trim().length > 0;
const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
const login = (res, next) => res.redirect(303, `/auth/login?next=${encodeURIComponent(next)}`);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@@ -76,15 +94,17 @@ function createForumRoutes({ forum, viewers, config }) {
const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
if (!probe.viewer.can_start) throw new ApiError(403, 'space.staff_threads', 'Roadmap items are added by staff. Reply to one, or suggest something in Feedback');
const { space } = await forum.space(req.viewer, req.params.space);
-- html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories }));
++ html(res, forumPages.newThreadPage({ space, user: req.user, categories: probe.categories, attachments: forum.attachmentsEnabled() }));
} catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/s/:space/new', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/s/:space/new', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
const values = { title: String((req.body || {}).title || '').slice(0, 200), body: String((req.body || {}).body || '').slice(0, 40_000) };
if ((req.body || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
try {
++ // Images go to Media only when the rest can be saved (no strays from an empty form).
++ if (worthUploading(values.title, values.body)) { const ids = await uploadImages(req); if (ids.length) values.attachments = ids; }
const out = await forum.createThread(req.viewer, req.params.space, values);
seo.resetCaches();
res.redirect(303, out.thread.url);
@@@ -93,7 +113,7 @@ function createForumRoutes({ forum, viewers, config }) {
try {
const { space } = await forum.space(req.viewer, req.params.space);
const { categories } = forum.categories(req.viewer, req.params.space);
-- html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message, categories }), err.status);
++ html(res, forumPages.newThreadPage({ space, user: req.user, values, error: err.message, categories, attachments: forum.attachmentsEnabled() }), err.status);
} catch (e) { failPage(req, res, e, next); }
}
}));
@@@ -101,17 +121,18 @@ function createForumRoutes({ forum, viewers, config }) {
async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {
const out = await forum.getThread(req.viewer, req.params.space, req.params.slug, { page: req.query.page });
if (out.page > out.pages) throw new ApiError(404, 'page.not_found', 'There is no page with that number.');
-- html(res, forumPages.threadPage({ ...out, perPage: out.per_page, user: req.user, error, draft }), status);
++ html(res, forumPages.threadPage({ ...out, perPage: out.per_page, user: req.user, error, draft, attachmentsEnabled: forum.attachmentsEnabled() }), status);
}
router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {
try { await renderThread(req, res); } catch (err) { failPage(req, res, err, next); }
}));
-- router.post('/s/:space/t/:slug/reply', withViewer, sameOrigin, form, wrap(async (req, res, next) => {
++ router.post('/s/:space/t/:slug/reply', withViewer, sameOrigin, withImages, form, wrap(async (req, res, next) => {
const draft = String((req.body || {}).body || '').slice(0, 40_000);
try {
-- const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft });
++ const attachments = worthUploading(null, draft) ? await uploadImages(req) : [];
++ const out = await forum.reply(req.viewer, req.params.space, req.params.slug, { body: draft, attachments: attachments.length ? attachments : undefined });
res.redirect(303, out.url);
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
ddiff --git a/server/forum/service.js b/server/forum/service.js
iindex 169ebf8..1fdc22c 100644
---- a/server/forum/service.js
++++ b/server/forum/service.js
@@@ -36,6 +36,7 @@ const { createPersonLimiter } = require('../limits');
const { discussionModerator } = require('../identity/capabilities');
const { renderMarkdown } = require('../render/markdown');
const { isUserSubject } = require('../vip');
++const { stripImageMetadata } = require('../media/strip-metadata');
const THREADS_PER_PAGE = 25;
/**
@@@ -50,16 +51,30 @@ const STATUSES = Object.freeze({
});
const FIRST_STATUS = { request: 'open', roadmap: 'planned' };
const CATEGORY_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
++// Attachments (WS-J task 2): images only, stored in OpenVibe.Media as med_ objects (media/objects.js).
++const ATTACH_MAX = 4;
++const ATTACH_BYTES = 8 * 1024 * 1024;
++const MED_ID = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++/** The image type from its first bytes (never the name or the declared type), or null. */
++function sniffImage(b) {
++ if (!Buffer.isBuffer(b) || b.length < 12) return null;
++ if (b[0] === 0x89 && b.toString('latin1', 1, 4) === 'PNG') return 'image/png';
++ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg';
++ if (b.toString('latin1', 0, 4) === 'GIF8') return 'image/gif';
++ if (b.toString('latin1', 0, 4) === 'RIFF' && b.toString('latin1', 8, 12) === 'WEBP') return 'image/webp';
++ return null;
++}
const POSTS_PER_PAGE = 50;
const TITLE_MIN = 3, TITLE_MAX = 200;
const BODY_MAX = 40_000;
const THREADS_PER_DAY = 20;
--function createForumService({ db, network = null, pulse = null, relay = null, vip = null, limits = {} } = {}) {
++function createForumService({ db, network = null, pulse = null, relay = null, vip = null, media = null, limits = {} } = {}) {
const authors = createAuthors({ db, network });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
++ const uploadLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 12, duplicate: false, noun: 'uploads', ...(limits.uploads || {}) });
const threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;
const hook = (fn) => { try { fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };
@@@ -177,10 +192,36 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
};
}
-- function shapePost(p, v, projections) {
++ const shapeAttachment = (a) => ({ media_id: a.media_id, url: a.url, filename: a.filename || null, mime: a.mime, size_bytes: a.size_bytes });
++ /** post id → [attachment], in order. */
++ function attachmentsOf(postIds) {
++ const out = new Map();
++ if (!postIds.length) return out;
++ const rows = db.prepare(`SELECT * FROM attachments WHERE post_id IN (${postIds.map(() => '?').join(', ')}) ORDER BY post_id, position`).all(...postIds);
++ for (const r of rows) { if (!out.has(r.post_id)) out.set(r.post_id, []); out.get(r.post_id).push(shapeAttachment(r)); }
++ return out;
++ }
++
++ /** The attachments a write names: the writer's own uploads, not yet on a post, at most ATTACH_MAX. → rows */
++ function claimable(w, ids) {
++ if (ids === undefined || ids === null) return [];
++ const list = Array.isArray(ids) ? ids.map(String) : [String(ids)];
++ if (list.length > ATTACH_MAX) fail(400, 'attachments.too_many', `At most ${ATTACH_MAX} images per post`);
++ if (!w.author && list.length) fail(403, 'attachments.person_only', 'Only people attach images');
++ const rows = list.map((id) => (MED_ID.test(id) ? db.prepare('SELECT * FROM attachments WHERE media_id = ?').get(id) : null));
++ if (rows.some((r) => !r || r.owner_subject !== w.author || r.post_id != null)) fail(400, 'attachments.invalid', 'Attach images you uploaded for this post');
++ return rows;
++ }
++ function attach(rows, postId) {
++ const set = db.prepare('UPDATE attachments SET post_id = ?, position = ? WHERE media_id = ? AND post_id IS NULL');
++ rows.forEach((r, i) => set.run(postId, i, r.media_id));
++ }
++
++ function shapePost(p, v, projections, attachments = null) {
const deleted = !!p.deleted_at;
const mine = person(v) && p.author_subject === v.subject;
return {
++ attachments: deleted ? [] : ((attachments && attachments.get(p.id)) || []),
id: p.id, thread_id: p.thread_id, is_opening: !!p.is_opening, origin: p.origin,
author: deleted ? null : authors.author(p.author_subject, p.origin, projections),
body_markdown: deleted ? null : p.body_markdown,
@@@ -235,6 +276,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
POSTS_PER_PAGE,
isModerator: moderator,
++ /** Whether images can be attached (Media and the service principal are configured). */
++ attachmentsEnabled: () => !!(media && media.configured),
/** Spaces this viewer can open, with thread counts (members-only ones carry members_only). */
async listSpaces(v) {
@@@ -271,6 +314,35 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
};
},
++ /**
++ * Upload an image to attach to a new thread or reply in this space { buffer, originalname } → { attachment }.
++ * People only; PNG, JPEG, GIF or WebP by content, at most 8 MB; metadata (EXIF, GPS) stripped; stored in
++ * OpenVibe.Media as a public med_ object the person owns. Name it in `attachments` when posting.
++ */
++ async uploadAttachment(v, spaceSlug, file) {
++ if (!media || !media.configured) fail(503, 'attachments.unavailable', 'Images cannot be attached right now');
++ const space = spaceFor(v, spaceSlug);
++ if (!person(v)) fail(401, 'auth.required', 'Sign in with your OpenVibe account to attach images');
++ mayPostIn(v, space);
++ await requireMembership(v, space);
++ if (!file || !Buffer.isBuffer(file.buffer) || !file.buffer.length) fail(400, 'attachments.missing', 'Choose an image');
++ if (file.buffer.length > ATTACH_BYTES) fail(413, 'attachments.too_large', 'Images are limited to 8 MB');
++ const mime = sniffImage(file.buffer);
++ if (!mime) fail(415, 'attachments.unsupported', 'Attach a PNG, JPEG, GIF or WebP image');
++ uploadLimiter.check(`s:${v.subject}`);
++ const buffer = stripImageMetadata(file.buffer, mime);
++ const filename = String(file.originalname || 'image').replace(/[^\w.\- ]+/g, '_').slice(0, 120) || 'image';
++ let stored;
++ try { stored = await media.uploadImage({ buffer, mime, filename, owner: v.subject }); } catch (err) {
++ console.warn('[Forum] attachment upload failed:', err.message);
++ fail(err.status === 413 || err.status === 415 ? err.status : 502, 'attachments.upload_failed', 'The image could not be stored. Try again.');
++ }
++ uploadLimiter.record(`s:${v.subject}`);
++ db.prepare('INSERT INTO attachments (media_id, owner_subject, filename, mime, size_bytes, url) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(stored.id, v.subject, filename, mime, buffer.length, stored.url);
++ return { attachment: shapeAttachment({ media_id: stored.id, url: stored.url, filename, mime, size_bytes: buffer.length }) };
++ },
++
/** The categories of a space. */
categories(v, spaceSlug) {
const space = spaceFor(v, spaceSlug);
@@@ -331,8 +403,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return {
space: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),
thread: shapeThread(thread, space, v, projections, votes),
-- posts: rows.map((p) => shapePost(p, v, projections)),
++ posts: (() => { const att = attachmentsOf(rows.map((p) => p.id)); return rows.map((p) => shapePost(p, v, projections, att)); })(),
categories: store.listCategories(db, space.id).map(shapeCategory),
++ attachments: { enabled: !!(media && media.configured), max: ATTACH_MAX, max_bytes: ATTACH_BYTES },
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
viewer: {
signed_in: person(v),
@@@ -362,6 +435,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const title = cleanTitle(body.title);
if (title.length < TITLE_MIN || title.length > TITLE_MAX) fail(400, 'thread.invalid_title', `Titles are ${TITLE_MIN} to ${TITLE_MAX} characters`);
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
++ const images = claimable(w, body.attachments);
if (w.key) {
threadLimiter.check(w.key, title);
if (threadsPerDay > 0 && store.countThreadsSince(db, w.author, '-1 day') >= threadsPerDay) fail(429, 'request.rate_limited', `Daily thread limit reached (${threadsPerDay}/day)`);
@@@ -369,10 +443,11 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const { thread, post } = store.createThread(db, { space_id: space.id, title, author_subject: w.author, origin: w.origin, body_markdown: text, members_only_owner: gate,
kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });
threadLimiter.record(w.key, title);
++ attach(images, post.id);
if (pulse) hook(() => pulse.threadCreated(thread, space));
if (relay) hook(() => relay.enqueueThread(thread, space));
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
-- return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections) };
++ return { thread: shapeThread(thread, space, v, projections, null), post: shapePost(post, v, projections, attachmentsOf([post.id])) };
},
/** Reply { body } → { post } */
@@@ -383,15 +458,17 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
await requireMembership(v, space, thread);
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
const text = cleanBody(body.body != null ? body.body : body.body_markdown);
++ const images = claimable(w, body.attachments);
postLimiter.check(w.key, text);
const post = store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: text });
postLimiter.record(w.key, text);
++ attach(images, post.id);
if (pulse) hook(() => pulse.postCreated(post, thread, space));
const projections = await authors.projectionsFor([post.author_subject]);
// Where the new post lands: its page in the thread (posts are numbered in id order).
const position = db.prepare('SELECT COUNT(*) AS c FROM posts WHERE thread_id = ? AND id <= ?').get(thread.id, post.id).c;
const page = Math.max(Math.ceil(position / POSTS_PER_PAGE), 1);
-- return { post: shapePost(post, v, projections), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
++ return { post: shapePost(post, v, projections, attachmentsOf([post.id])), page, url: `${threadUrl(space, thread)}${page > 1 ? `?page=${page}` : ''}#post-${post.id}` };
},
/** Edit { body } — the author (not on a locked thread) or a moderator. */
@@@ -507,4 +584,4 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
};
}
--module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES };
++module.exports = { createForumService, THREADS_PER_PAGE, POSTS_PER_PAGE, STATUSES, ATTACH_MAX, sniffImage };
ddiff --git a/server/media/objects.js b/server/media/objects.js
nnew file mode 100644
iindex 0000000..ef2f550
---- /dev/null
++++ b/server/media/objects.js
@@@ -0,0 +1,62 @@
++'use strict';
++
++/**
++ * OpenVibe.Media Object API v2 client — attachments on forum posts (WS-J task 2). Community keeps a
++ * `med_` reference and the public URL; the bytes live in Media under the `community` tenant, owned by
++ * the person who attached them (X-OV-Subject), with Community's service token (media.object.upload,
++ * namespace community):
++ *
++ * POST /api/v2/community/objects init { kind: 'file', visibility: 'public', size_bytes, mime_type, filename, content_hash }
++ * PUT /api/v2/community/objects/:id/content the bytes
++ * POST /api/v2/community/objects/:id/complete Media checks size, hash and type → ready
++ *
++ * Public bytes are served at <media>/o/:id.
++ */
++const crypto = require('crypto');
++const { serviceAuth } = require('openvibe-contracts');
++
++function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
++ const configured = !!(config.oauth && config.oauth.clientSecret);
++ const tokens = configured ? serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.media',
++ scope: 'media.object.upload',
++ fetchImpl,
++ }) : null;
++ const base = `${config.mediaInternalUrl}/api/v2/community/objects`;
++
++ async function call(method, path, { json, body, headers = {} } = {}, retried = false) {
++ const h = { Accept: 'application/json', ...headers, ...(await tokens.authHeaders()) };
++ if (json !== undefined) h['Content-Type'] = 'application/json';
++ const res = await fetchImpl(`${base}${path}`, { method, headers: h, body: json !== undefined ? JSON.stringify(json) : body, signal: AbortSignal.timeout(60_000) });
++ if (res.status === 401 && !retried) { tokens.invalidate(); return call(method, path, { json, body, headers }, true); }
++ const data = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error(`Media ${method} ${path || '/'} ${res.status}: ${(data && (data.detail || data.error)) || 'failed'}`);
++ err.status = res.status; err.code = data && data.code;
++ throw err;
++ }
++ return data || {};
++ }
++
++ /** Store one image for `owner` (usr_…). → { id (med_…), url, size_bytes, mime } */
++ async function uploadImage({ buffer, mime, filename, owner }) {
++ if (!configured) { const e = new Error('Attachments need Community\'s service principal (OV_OAUTH_CLIENT_SECRET)'); e.status = 503; throw e; }
++ const sha256 = crypto.createHash('sha256').update(buffer).digest('hex');
++ const init = await call('POST', '', {
++ json: { kind: 'file', visibility: 'public', size_bytes: buffer.length, mime_type: mime, filename, content_hash: sha256, metadata: { source: 'community.attachment' } },
++ headers: { 'X-OV-Subject': owner },
++ });
++ const id = init.id || (init.object && init.object.id);
++ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(id || ''))) throw Object.assign(new Error('Media returned no object id'), { status: 502 });
++ await call('PUT', `/${id}/content`, { body: buffer, headers: { 'Content-Type': mime } });
++ await call('POST', `/${id}/complete`, { json: { content_hash: sha256 } });
++ return { id, url: `${config.mediaUrl}/o/${id}`, size_bytes: buffer.length, mime };
++ }
++
++ return { configured, uploadImage };
++}
++
++module.exports = { createMediaObjects };
ddiff --git a/server/render/forum.js b/server/render/forum.js
iindex e3dbaf4..c501ee1 100644
---- a/server/render/forum.js
++++ b/server/render/forum.js
@@@ -132,8 +132,10 @@ function postHtml(p, n) {
return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
<header class="post-head">${who(p.author)} <span class="sep">·</span> <a class="muted small" href="#post-${p.id}">${timeTag(p.created_at)}</a>${p.revision > 1 ? ` <span class="sep">·</span> <span class="muted small" title="Edited ${esc(fmtDate(p.updated_at))}">edited</span>` : ''}<span class="post-num muted small">#${n}</span></header>
<div class="md">${p.body_html}</div>
++ ${(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.filename || 'Image')}" loading="lazy" decoding="async"></a>`).join('')}</div>` : ''}
</article>`;
}
++const imageField = (on) => (on ? '<label class="field"><span>Images (optional, up to 4; PNG, JPEG, GIF or WebP, 8 MB each)</span><input type="file" name="attachments" accept="image/png,image/jpeg,image/gif,image/webp" multiple></label>' : '');
function voteForm(base, thread, viewer) {
if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;
@@@ -144,16 +146,17 @@ function voteForm(base, thread, viewer) {
return `<form class="vote" method="post" action="${esc(base)}/vote">${btn(1, 'fa-arrow-up', 'Upvote')}<span class="vote-score" title="Score">${num(thread.score)}</span>${btn(-1, 'fa-arrow-down', 'Downvote')}</form>`;
}
--function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [] }) {
++function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, user, error = null, draft = '', categories = [], attachmentsEnabled = false }) {
const base = `/s/${space.slug}/t/${thread.slug}`;
const opening = posts.find((p) => p.is_opening) || null;
const gated = !!(space.members_only || thread.members_only);
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
-- ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply">
++ ? `<form class="paste-form reply-form" method="post" action="${esc(base)}/reply" id="reply"${attachmentsEnabled ? ' enctype="multipart/form-data"' : ''}>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span>Reply (Markdown)</span><textarea name="body" rows="7" maxlength="40000" required placeholder="Say it, own it.">${esc(draft)}</textarea></label>
++ ${imageField(attachmentsEnabled)}
<div class="form-actions"><button class="btn btn-primary" type="submit"><i class="fa-solid fa-reply" aria-hidden="true"></i> Reply</button><span class="muted small">**bold**, *italic*, \`code\`, \`\`\` blocks, [links](https://…), &gt; quotes and lists.</span></div>
</form>`
: thread.locked ? '<p class="alert">This thread is locked.</p>'
@@@ -206,14 +209,15 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
}
// ── /s/:space/new ────────────────────────────────────────────
--function newThreadPage({ space, user, values = {}, error = null, categories = [] }) {
++function newThreadPage({ space, user, values = {}, error = null, categories = [], attachments = false }) {
const categoryField = categories.length ? `<label class="field"><span>Category</span><select name="category"><option value="">None</option>${categories.map((c) => `<option value="${esc(c.slug)}"${values.category === c.slug ? ' selected' : ''}>${esc(c.name)}${c.description ? ` — ${esc(c.description)}` : ''}</option>`).join('')}</select></label>` : '';
-- const form = user ? `<form class="paste-form" method="post" action="/s/${esc(space.slug)}/new">
++ const form = user ? `<form class="paste-form" method="post" action="/s/${esc(space.slug)}/new"${attachments ? ' enctype="multipart/form-data"' : ''}>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
${space.thread_kind === 'request' ? '<p class="muted small">A request is open to votes. Search first: if it is already here, vote for it instead.</p>' : ''}
<label class="field"><span>Title</span><input type="text" name="title" minlength="3" maxlength="200" required value="${esc(values.title || '')}" placeholder="What is it about?"></label>
${categoryField}
<label class="field"><span>Post (Markdown)</span><textarea name="body" rows="14" maxlength="40000" required placeholder="Say it, own it.">${esc(values.body || '')}</textarea></label>
++ ${imageField(attachments)}
<label class="check"><input type="checkbox" name="members_only" value="1"${values.members_only ? ' checked' : ''}> Only my OpenVibe.VIP members can read and reply</label>
<div class="form-actions">
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
nnew file mode 100644
iindex 0000000..f52f1ac
---- /dev/null
++++ b/test/attachments.test.js
@@@ -0,0 +1,147 @@
++'use strict';
++/**
++ * Images on forum posts (WS-J task 2): an upload goes to OpenVibe.Media's Object API v2 as a med_ object
++ * the person owns (init → content → complete, Community's service token, X-OV-Subject), metadata stripped
++ * and the type taken from the bytes; a thread or reply names at most 4 of the person's own unattached
++ * uploads; posts show them; the no-JS forms upload and attach in one step; someone else's upload, a
++ * non-image, an anonymous upload and Media being unconfigured are refused.
++ */
++const assert = require('assert');
++const http = require('http');
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++const { createMediaObjects } = require('../server/media/objects');
++const { sniffImage } = require('../server/forum/service');
++
++// A 1×1 PNG with a tEXt chunk (metadata that must not reach Media), with real lengths and CRCs.
++const zlib = require('zlib');
++const chunk = (type, data) => {
++ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
++ const td = Buffer.concat([Buffer.from(type, 'latin1'), data]);
++ const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32(td));
++ return Buffer.concat([len, td, crc]);
++};
++const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(1, 0); ihdr.writeUInt32BE(1, 4); ihdr[8] = 8; ihdr[9] = 6;
++const PNG = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), chunk('IHDR', ihdr), chunk('tEXt', Buffer.from('Comment\0secret', 'latin1')),
++ chunk('IDAT', zlib.deflateSync(Buffer.from([0, 0, 0, 0, 0]))), chunk('IEND', Buffer.alloc(0))]);
++
++(async () => {
++ // ── A stub Media (Object API v2 for tenant community) ──
++ const objects = new Map();
++ const media = http.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => {
++ const body = Buffer.concat(chunks);
++ const send = (status, obj) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(obj)); };
++ if (req.url === '/oauth/token') return send(200, { access_token: 'svc-community', token_type: 'Bearer', expires_in: 300 });
++ if (req.headers.authorization !== 'Bearer svc-community') return send(401, { code: 'auth.required' });
++ if (req.method === 'POST' && req.url === '/api/v2/community/objects') {
++ const b = JSON.parse(body);
++ const id = ids.newId('media');
++ objects.set(id, { ...b, owner: req.headers['x-ov-subject'], status: 'uploading' });
++ return send(201, { id, object: { id } });
++ }
++ let m = req.url.match(/^\/api\/v2\/community\/objects\/(med_[0-9A-Z]+)\/content$/);
++ if (req.method === 'PUT' && m) { const o = objects.get(m[1]); o.bytes = body; o.content_type = req.headers['content-type']; return send(200, { id: m[1], size_bytes: body.length }); }
++ m = req.url.match(/^\/api\/v2\/community\/objects\/(med_[0-9A-Z]+)\/complete$/);
++ if (req.method === 'POST' && m) {
++ const o = objects.get(m[1]);
++ const hash = crypto.createHash('sha256').update(o.bytes).digest('hex');
++ if (hash !== JSON.parse(body).content_hash || o.bytes.length !== o.size_bytes) return send(400, { code: 'media.object.hash_mismatch' });
++ o.status = 'ready';
++ return send(200, { id: m[1], lifecycle_status: 'ready' });
++ }
++ send(404, {});
++ });
++ });
++ await new Promise((r) => media.listen(0, '127.0.0.1', r));
++ const mediaBase = `http://127.0.0.1:${media.address().port}`;
++ const mediaObjects = createMediaObjects({ config: { oauth: { clientId: 'community', clientSecret: 'x'.repeat(40) }, networkInternalUrl: mediaBase, mediaInternalUrl: mediaBase, mediaUrl: 'https://openvibe.media' } });
++
++ const t = await boot({ authority: 'community', appOpts: { mediaObjects, forumLimits: { threads: { cooldownSec: 0, perMinute: 1000 }, posts: { cooldownSec: 0, perMinute: 1000 }, threadsPerDay: 1000 } } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex' });
++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user' });
++ const samJwt = net.sign({ id: 9, subject_id: sam.subject_id, username: 'sam', display_name: 'Sam', role: 'user' });
++ const upload = (cookie, buf = PNG, name = 'cat.png') => {
++ const fd = new FormData();
++ fd.append('file', new Blob([buf], { type: 'image/png' }), name);
++ return t.get('/api/v1/spaces/general/attachments', { method: 'POST', body: fd, cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++ const json = (cookie, path, obj) => t.get(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(obj), cookies: [`ov_token=${cookie}`] });
++
++ await check('sniffing goes by the bytes', async () => {
++ assert.strictEqual(sniffImage(PNG), 'image/png');
++ assert.strictEqual(sniffImage(Buffer.from('GIF89a......')), 'image/gif');
++ assert.strictEqual(sniffImage(Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"></svg>')), null);
++ });
++
++ let first;
++ await check('upload: a med_ object owned by the person, metadata stripped, the public URL', async () => {
++ const r = await upload(alexJwt);
++ assert.strictEqual(r.status, 201, r.text);
++ first = r.json().attachment;
++ assert.match(first.media_id, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.strictEqual(first.url, `https://openvibe.media/o/${first.media_id}`);
++ const o = objects.get(first.media_id);
++ assert.strictEqual(o.owner, alex.subject_id); assert.strictEqual(o.visibility, 'public'); assert.strictEqual(o.status, 'ready');
++ assert.ok(!o.bytes.includes(Buffer.from('secret')), 'the tEXt chunk never reached Media');
++ assert.ok(PNG.includes(Buffer.from('secret')));
++ });
++
++ await check('refused: signed out, not an image, too many, someone else\'s upload', async () => {
++ assert.strictEqual((await upload(null)).status, 401);
++ const svg = await upload(alexJwt, Buffer.from('<svg xmlns="http://www.w3.org/2000/svg"><script>x</script></svg>'), 'x.svg');
++ assert.strictEqual(svg.status, 415);
++ let r = await json(samJwt, '/api/v1/spaces/general/threads', { title: 'Stolen', body: 'mine now', attachments: [first.media_id] });
++ assert.strictEqual(r.status, 400); assert.strictEqual(r.json().code, 'attachments.invalid');
++ r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'Too many', body: 'x', attachments: [1, 2, 3, 4, 5].map(() => first.media_id) });
++ assert.strictEqual(r.status, 400); assert.strictEqual(r.json().code, 'attachments.too_many');
++ });
++
++ let thread;
++ await check('a thread names its images; the post shows them; an attached upload cannot be reused', async () => {
++ let r = await json(alexJwt, '/api/v1/spaces/general/threads', { title: 'My cat', body: 'Look.', attachments: [first.media_id] });
++ assert.strictEqual(r.status, 201, r.text);
++ thread = r.json().thread;
++ assert.deepStrictEqual(r.json().post.attachments.map((a) => a.media_id), [first.media_id]);
++ const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
++ assert.strictEqual(page.posts[0].attachments[0].url, first.url);
++ assert.strictEqual(page.attachments.enabled, true);
++ r = await json(alexJwt, `/api/v1/spaces/general/threads/${thread.slug}/posts`, { body: 'again', attachments: [first.media_id] });
++ assert.strictEqual(r.status, 400, 'already on a post');
++ const html = (await t.get(`/s/general/t/${thread.slug}`, { cookies: [`ov_token=${samJwt}`] })).text;
++ assert.ok(html.includes(`src="${first.url}"`) && html.includes('enctype="multipart/form-data"') && html.includes('name="attachments"'));
++ });
++
++ await check('no-JS reply form: the images are uploaded and attached in one step', async () => {
++ const fd = new FormData();
++ fd.append('body', 'Two more');
++ fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'a.png');
++ fd.append('attachments', new Blob([PNG], { type: 'image/png' }), 'b.png');
++ const r = await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: fd, cookies: [`ov_token=${samJwt}`] });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
++ const page = (await t.get(`/api/v1/spaces/general/threads/${thread.slug}`)).json();
++ const reply = page.posts.find((p) => p.body_markdown === 'Two more');
++ assert.deepStrictEqual(reply.attachments.map((a) => a.filename), ['a.png', 'b.png']);
++ assert.ok(reply.attachments.every((a) => objects.get(a.media_id).owner === sam.subject_id));
++ const empty = new FormData();
++ empty.append('body', '');
++ empty.append('attachments', new Blob([PNG], { type: 'image/png' }), 'c.png');
++ const before = objects.size;
++ await t.get(`/s/general/t/${thread.slug}/reply`, { method: 'POST', body: empty, cookies: [`ov_token=${samJwt}`] });
++ assert.strictEqual(objects.size, before, 'an empty reply uploads nothing');
++ });
++
++ await check('without the service principal, attaching is refused and the forms have no file field', async () => {
++ const off = createMediaObjects({ config: { oauth: {}, networkInternalUrl: mediaBase, mediaInternalUrl: mediaBase, mediaUrl: 'https://openvibe.media' } });
++ assert.strictEqual(off.configured, false);
++ await assert.rejects(off.uploadImage({ buffer: PNG, mime: 'image/png', filename: 'x.png', owner: alex.subject_id }), /service principal/);
++ });
++
++ media.close();
++ await done(t);
++})();