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
Changes
@@@ -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 summaryis 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` |@@@ -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",@@@ -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; }@@@ -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);@@@ -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,@@@ -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 || {})));@@@ -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);@@@ -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, viPOSTS_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, vireturn {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, viconst 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, viconst { 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, viawait 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 };nnew file mode 100644@@@ -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 };@@@ -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://…), > 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>nnew file mode 100644@@@ -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);++})();
@@ -329,6 +329,11 @@ member for now), `staff` (moderators only; looks missing to everyone else).
@@ -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
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.
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):
API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
| Route | What |
| Route | What |
@@ -336,6 +341,7 @@ API (`/api/v1/spaces`, `/api/v1/posts`, problem+json errors):
@@ -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` · `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 |
| `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/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) |
| `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/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` |
| `PUT /spaces/:space/threads/:slug/status` `{ status }` | Moderators: requests `open`/`planned`/`in_progress`/`done`/`declined`, roadmap items `planned`/`in_progress`/`done`/`paused` |
@@ -17,8 +17,8 @@
@@ -17,8 +17,8 @@
"summary": "First the network's own deployment tooling in the open, then simple hosting for community sites, bots and mods." },
"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",
{ "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." },
"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",
{ "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." },
"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",
{ "key": "your-data", "category": "features", "status": "in_progress", "title": "See, download and delete your data",
@@ -176,6 +176,8 @@ a.badge:hover { border-color: var(--accent); text-decoration: none; color: var(-
@@ -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-done { border-color: var(--success); color: var(--success); }
.status-declined, .status-paused { color: var(--text-muted, var(--text-secondary)); }
.status-declined, .status-paused { color: var(--text-muted, var(--text-secondary)); }
.thread-tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 6px 0 0; }
.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; }
.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 { 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; }
.chip:hover { border-color: var(--accent); text-decoration: none; }
@@ -124,7 +124,9 @@ function createApp(opts = {}) {
@@ -124,7 +124,9 @@ function createApp(opts = {}) {
});
});
// OpenVibe.VIP: members-only spaces and threads (fails closed without a client secret or VIP).
// OpenVibe.VIP: members-only spaces and threads (fails closed without a client secret or VIP).
const vip = opts.vip || createVipGate({ config, ...(opts.vipOptions || {}) });
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 community = config.pastesAuthority === 'community';
const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });
const comments = createCommentService({ db, network, pastesLocal: community, limits: opts.commentLimits });
seo.useForum(forum);
seo.useForum(forum);
@@ -245,6 +245,21 @@ CREATE TABLE IF NOT EXISTS thread_votes (
@@ -245,6 +245,21 @@ CREATE TABLE IF NOT EXISTS thread_votes (
-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────
-- ── Discord relay (server/relay; DISCORD_RELAY_ENABLED) ──────
-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never
-- webhook_url_ref is the NAME of an environment variable holding the webhook URL: secrets never
-- live in the database.
-- 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.
-- Categories inside a space (WS-J task 1): threads may carry one; the space page filters by them.
CREATE TABLE IF NOT EXISTS categories (
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -16,6 +16,8 @@
@@ -16,6 +16,8 @@
* PUT /spaces/:space/members-only { owner: 'usr_…' | null } moderators (OpenVibe.VIP gate)
* 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
* 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/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)
* GET /spaces/:space/categories the space's categories (?category=<slug> filters threads)
* PUT /spaces/:space/categories/:category { name, description?, position? } moderators
* PUT /spaces/:space/categories/:category { name, description?, position? } moderators
* DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)
* DELETE /spaces/:space/categories/:category moderators (threads keep their place, uncategorised)
@@ -30,6 +32,7 @@
@@ -30,6 +32,7 @@
* ({ reason, gate, members_only: { owner, owner_username, join_url } }).
* ({ reason, gate, members_only: { owner, owner_username, join_url } }).
*/
*/
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');
@@ -48,6 +51,12 @@ function createSpacesApi({ forum, viewers }) {
@@ -48,6 +51,12 @@ function createSpacesApi({ forum, viewers }) {
router.get('/:space', run((req) => forum.space(req.viewer, p(req).space)));
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/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)));
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.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.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 || {})));
router.put('/:space/threads/:slug/category', writeOrMod, jsonBody, run((req) => forum.setThreadCategory(req.viewer, p(req).space, p(req).slug, req.body || {})));
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
* visitor); an Origin header from somewhere else is refused as well.
* visitor); an Origin header from somewhere else is refused as well.
*/
*/
const express = require('express');
const express = require('express');
+const multer = require('multer');
const seo = require('../seo');
const seo = require('../seo');
const pages = require('../render/pages');
const pages = require('../render/pages');
const forumPages = require('../render/forum');
const forumPages = require('../render/forum');
@@ -27,6 +28,23 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -27,6 +28,23 @@ function createForumRoutes({ forum, viewers, config }) {
const router = express.Router();
const router = express.Router();
const withViewer = viewers.middleware({ services: false });
const withViewer = viewers.middleware({ services: false });
const form = express.urlencoded({ extended: false, limit: '256kb' });
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 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 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);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@ -76,15 +94,17 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -76,15 +94,17 @@ function createForumRoutes({ forum, viewers, config }) {
const probe = await forum.listThreads(req.viewer, req.params.space, { limit: 1 });
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');
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);
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); }
} 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) };
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 || {}).members_only === '1') values.members_only = true;
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
if ((req.body || {}).category) values.category = String(req.body.category).slice(0, 40);
try {
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);
const out = await forum.createThread(req.viewer, req.params.space, values);
seo.resetCaches();
seo.resetCaches();
res.redirect(303, out.thread.url);
res.redirect(303, out.thread.url);
@@ -93,7 +113,7 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -93,7 +113,7 @@ function createForumRoutes({ forum, viewers, config }) {
try {
try {
const { space } = await forum.space(req.viewer, req.params.space);
const { space } = await forum.space(req.viewer, req.params.space);
const { categories } = forum.categories(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); }
} catch (e) { failPage(req, res, e, next); }
}
}
}));
}));
@@ -101,17 +121,18 @@ function createForumRoutes({ forum, viewers, config }) {
@@ -101,17 +121,18 @@ function createForumRoutes({ forum, viewers, config }) {
async function renderThread(req, res, { status = 200, error = null, draft = '' } = {}) {
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 });
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.');
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) => {
router.get('/s/:space/t/:slug', withViewer, wrap(async (req, res, next) => {
try { await renderThread(req, res); } catch (err) { failPage(req, res, err, 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);
const draft = String((req.body || {}).body || '').slice(0, 40_000);
try {
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);
res.redirect(303, out.url);
} catch (err) {
} catch (err) {
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
if (!(err instanceof ApiError) || err.status === 401 || err.status === 404 || err.code === 'vip.members_only') return failPage(req, res, err, next);
@@ -36,6 +36,7 @@ const { createPersonLimiter } = require('../limits');
@@ -36,6 +36,7 @@ const { createPersonLimiter } = require('../limits');
const { discussionModerator } = require('../identity/capabilities');
const { discussionModerator } = require('../identity/capabilities');
const { renderMarkdown } = require('../render/markdown');
const { renderMarkdown } = require('../render/markdown');
const { isUserSubject } = require('../vip');
const { isUserSubject } = require('../vip');
+const { stripImageMetadata } = require('../media/strip-metadata');
const THREADS_PER_PAGE = 25;
const THREADS_PER_PAGE = 25;
/**
/**
@@ -50,16 +51,30 @@ const STATUSES = Object.freeze({
@@ -50,16 +51,30 @@ const STATUSES = Object.freeze({
});
});
const FIRST_STATUS = { request: 'open', roadmap: 'planned' };
const FIRST_STATUS = { request: 'open', roadmap: 'planned' };
const CATEGORY_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
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 POSTS_PER_PAGE = 50;
const TITLE_MIN = 3, TITLE_MAX = 200;
const TITLE_MIN = 3, TITLE_MAX = 200;
const BODY_MAX = 40_000;
const BODY_MAX = 40_000;
const THREADS_PER_DAY = 20;
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 authors = createAuthors({ db, network });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const threadLimiter = createPersonLimiter({ cooldownSec: 30, perMinute: 3, noun: 'threads', ...(limits.threads || {}) });
const postLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 6, noun: 'posts', ...(limits.posts || {}) });
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 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 threadsPerDay = limits.threadsPerDay != null ? limits.threadsPerDay : THREADS_PER_DAY;
const hook = (fn) => { try { fn(); } catch (err) { console.warn('[Forum] side effect failed:', err.message); } };
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
@@ -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 deleted = !!p.deleted_at;
const mine = person(v) && p.author_subject === v.subject;
const mine = person(v) && p.author_subject === v.subject;
return {
return {
+ attachments: deleted ? [] : ((attachments && attachments.get(p.id)) || []),
id: p.id, thread_id: p.thread_id, is_opening: !!p.is_opening, origin: p.origin,
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),
author: deleted ? null : authors.author(p.author_subject, p.origin, projections),
body_markdown: deleted ? null : p.body_markdown,
body_markdown: deleted ? null : p.body_markdown,
@@ -235,6 +276,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -235,6 +276,8 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
POSTS_PER_PAGE,
POSTS_PER_PAGE,
isModerator: moderator,
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). */
/** Spaces this viewer can open, with thread counts (members-only ones carry members_only). */
async listSpaces(v) {
async listSpaces(v) {
@@ -271,6 +314,35 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -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. */
/** The categories of a space. */
categories(v, spaceSlug) {
categories(v, spaceSlug) {
const space = spaceFor(v, spaceSlug);
const space = spaceFor(v, spaceSlug);
@@ -331,8 +403,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -331,8 +403,9 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
return {
return {
space: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),
space: shapeSpace(space, await membersOnly(space.members_only_owner, projections)),
thread: shapeThread(thread, space, v, projections, votes),
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),
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,
page, per_page: POSTS_PER_PAGE, pages: Math.max(Math.ceil(total / POSTS_PER_PAGE), 1), total,
viewer: {
viewer: {
signed_in: person(v),
signed_in: person(v),
@@ -362,6 +435,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -362,6 +435,7 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
const title = cleanTitle(body.title);
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`);
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 text = cleanBody(body.body != null ? body.body : body.body_markdown);
+ const images = claimable(w, body.attachments);
if (w.key) {
if (w.key) {
threadLimiter.check(w.key, title);
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)`);
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
@@ -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,
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 });
kind, status: FIRST_STATUS[kind] || null, category_id: category ? category.id : null });
threadLimiter.record(w.key, title);
threadLimiter.record(w.key, title);
+ attach(images, post.id);
if (pulse) hook(() => pulse.threadCreated(thread, space));
if (pulse) hook(() => pulse.threadCreated(thread, space));
if (relay) hook(() => relay.enqueueThread(thread, space));
if (relay) hook(() => relay.enqueueThread(thread, space));
const projections = await authors.projectionsFor([thread.author_subject, thread.members_only_owner]);
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 } */
/** Reply { body } → { post } */
@@ -383,15 +458,17 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
@@ -383,15 +458,17 @@ function createForumService({ db, network = null, pulse = null, relay = null, vi
await requireMembership(v, space, thread);
await requireMembership(v, space, thread);
if (thread.locked && !moderator(v)) fail(403, 'thread.locked', 'This thread is locked');
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 text = cleanBody(body.body != null ? body.body : body.body_markdown);
+ const images = claimable(w, body.attachments);
postLimiter.check(w.key, text);
postLimiter.check(w.key, text);
const post = store.addPost(db, { thread_id: thread.id, author_subject: w.author, origin: w.origin, body_markdown: 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);
postLimiter.record(w.key, text);
+ attach(images, post.id);
if (pulse) hook(() => pulse.postCreated(post, thread, space));
if (pulse) hook(() => pulse.postCreated(post, thread, space));
const projections = await authors.projectionsFor([post.author_subject]);
const projections = await authors.projectionsFor([post.author_subject]);
// Where the new post lands: its page in the thread (posts are numbered in id order).
// 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 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);
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. */
/** 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
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -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 };
@@ -132,8 +132,10 @@ function postHtml(p, n) {
@@ -132,8 +132,10 @@ function postHtml(p, n) {
return `<article class="post${p.is_opening ? ' post-opening' : ''}" id="post-${p.id}">
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>
<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>
<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>`;
</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) {
function voteForm(base, thread, viewer) {
if (!viewer.can_vote) return `<div class="vote"><span class="vote-score" title="Score">${num(thread.score)}</span></div>`;
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) {
@@ -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>`;
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 base = `/s/${space.slug}/t/${thread.slug}`;
const opening = posts.find((p) => p.is_opening) || null;
const opening = posts.find((p) => p.is_opening) || null;
const gated = !!(space.members_only || thread.members_only);
const gated = !!(space.members_only || thread.members_only);
const indexable = space.visibility === 'public' && !gated;
const indexable = space.visibility === 'public' && !gated;
const first = (page - 1) * perPage;
const first = (page - 1) * perPage;
const replyBlock = viewer.can_reply
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>` : ''}
${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>
<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://…), > quotes and lists.</span></div>
<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://…), > quotes and lists.</span></div>
</form>`
</form>`
: thread.locked ? '<p class="alert">This thread is locked.</p>'
: 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
@@ -206,14 +209,15 @@ function threadPage({ space, thread, posts, page, pages, perPage = 50, viewer, u
}
}
// ── /s/:space/new ────────────────────────────────────────────
// ── /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 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>` : ''}
${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>' : ''}
${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>
<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}
${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>
<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>
<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">
<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>
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Post thread</button>
new file mode 100644
new file mode 100644
@@ -0,0 +1,147 @@
@@ -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);
+})();