openvibers/openvibe.wiki Commits
Media attachments across authors and editors: a person attaches an OpenVibe.Media object only if they can read it there (public, unlisted or their own; missing, deleted and other people's private objects get one media.not_readable answer), private objects are never attached because every reader of a page sees its media, and a Media outage attaches nothing (503). wiki_attachment_origins records who attached each object and the visibility checked then; later editors revise, revert and publish without re-attaching. The check marks an object Media made private broken ('forbidden', shown as no longer shared publicly) until Media shares it again. test/media-handoff.test.js: A attaches, B revises and keeps it, B is refused A's private object, Media deletes one and hides the other, the public page shows both states
Changes
@@@ -43,7 +43,8 @@ The ten authority tables of §15.13, in Wiki's own SQLite database (`WIKI_DB_PATThe packages add their helper tables under the same prefix (`wiki_page_drafts`, `wiki_page_reviews`,`wiki_page_attachments`, `wiki_schedule_jobs`, `wiki_discussion_refs`, `wiki_index_revisions`, the--purge audit tables); the SDK outbox keeps `wiki_event_outbox`.++purge audit tables); the SDK outbox keeps `wiki_event_outbox`. Wiki's own `wiki_attachment_origins`++records who attached each Media object and what Media said about it at that moment.## Does not own@@@ -83,6 +84,17 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.- **Media**: attachments by Media object id; a check against Media marks missing or deleted objects`broken` and the page shows an explicit "no longer available" placeholder instead of an image; anoutage changes nothing (`check_failed`). A periodic check runs when Media is configured.++- **Media across authors and editors**: Media owns read rights (public and unlisted objects are++ readable by anyone, a private one only by its owner). A person attaches an object only if they can++ read it in Media — missing, deleted and other people's private objects get one answer++ (`media.not_readable`), so an id is never confirmed — and only public or unlisted objects are++ attached at all (every reader of a page sees its media; Wiki never re-shares a private object under++ its own authority). The attachment belongs to the page, with who attached it and the Media++ visibility checked then (`wiki_attachment_origins`); later editors revise, revert and publish++ without re-attaching and need no rights on it. When Media deletes an object or makes it private,++ the next check shows "deleted" or "no longer shared publicly" instead of the image, until Media++ shares it again. With Media unreachable or not configured nothing is attached (503). The model is++ documented at `attachMedia` in [server/wiki/service.js](server/wiki/service.js).- **Discussion**: public, published pages get a Community thread (resolved once, stored as areference); comments render server-side; signed-in people comment through a form (posted toCommunity as that person). Failures show an explicit "could not be loaded" state.@@@ -208,6 +220,9 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit- `/feed.atom` is a valid Atom feed with zero entries while nothing is listable (`feeds.test.js`)- a public page is useful with JavaScript disabled, and so is editing (`nojs.test.js`)- a deleted or missing Media object renders an explicit broken-asset state (`integrations.test.js`)++- Media permissions survive the author/editor handoff: an editor keeps the author's attachments,++ cannot attach an object only the author can read, and an object Media deletes or makes private is++ shown as broken or withheld on the public page (`media-handoff.test.js`)- permissions are enforced; visitors without SSO read public content only; AI proposals need aperson's approval (`permissions.test.js`)- every event is a valid `events.event-envelope@1` and every index document a valid@@@ -17,6 +17,7 @@* The Publishing packages add their helper tables under the same prefix (wiki_page_drafts,* wiki_page_reviews, wiki_page_attachments, wiki_schedule_jobs, wiki_discussion_refs,* wiki_index_revisions, the purge audit tables) and the SDK outbox keeps wiki_event_outbox.++ * wiki_attachment_origins records who attached each Media object and the read rights checked then.*/const fs = require('fs');const path = require('path');@@@ -131,6 +132,20 @@ CREATE TABLE IF NOT EXISTS wiki_ai_proposals (UNIQUE (page_id, revision));CREATE INDEX IF NOT EXISTS wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);++++-- Who attached a Media object to a page, and what Media said about it at that moment (the read++-- rights that were checked). One row per attachment, written with it and never changed.++CREATE TABLE IF NOT EXISTS wiki_attachment_origins (++ attachment_id INTEGER PRIMARY KEY,++ page_id TEXT NOT NULL,++ media_id TEXT NOT NULL,++ attached_by TEXT NOT NULL,++ media_owner TEXT,++ media_visibility TEXT NOT NULL CHECK (media_visibility IN ('public','unlisted','private')),++ attached_at INTEGER NOT NULL++);++CREATE TRIGGER IF NOT EXISTS wiki_attachment_origins_no_update BEFORE UPDATE ON wiki_attachment_origins++BEGIN SELECT RAISE(ABORT, 'wiki_attachment_origins rows are immutable'); END;`;function openDb(dbPath) {@@@ -26,7 +26,7 @@* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }* POST /pages/:id/unpublish wiki.revision.publish* POST /pages/:id/revert wiki.revision.revert { to_revision, expected_revision, message?, publish? }-- * POST /pages/:id/media wiki.page.create { media_id, alt?, caption? }++ * POST /pages/:id/media wiki.page.create { media_id, alt?, caption? } a person who can read the object in Media* POST /pages/:id/media/verify wiki.page.create* PUT /pages/:id/watch { watching: bool } people only* POST /proposals wiki.revision.propose { space, page_id?, title?, body, summary?, infobox?, citations?, workflow: { id, run_id, version?, model? }, stub_provider?, expected_revision?, note? }@@@ -66,6 +66,10 @@ function serializeCitation(c) {return { id: c.id, revision: c.revision, url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote, license_note: c.licenseNote, carried_from: c.carriedFrom, attached_by: c.attachedBy, attached_at: c.attachedAt };}++function serializeAttachment(a) {++ return { id: a.id, media_id: a.mediaId, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, checked_at: a.checkedAt, attached_by: a.attachedBy };++}++function serializeTree(nodes, svc, space) {return nodes.map((n) => ({ ...serializePage(n.page, svc, space), children: serializeTree(n.children, svc, space) }));}@@@ -125,7 +129,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {return {page: serializePage(page, svc, space), revision: serializeRevision(v.revision),citations: v.citations.map(serializeCitation), infobox: v.infobox,-- attachments: v.attachments.map((a) => ({ id: a.id, media_id: a.mediaId, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, checked_at: a.checkedAt })),++ attachments: v.attachments.map(serializeAttachment),links: v.links.map((l) => ({ space: l.target_space, slug: l.target_slug, label: l.label })),indexability: { indexable: v.decision.indexable, reasons: v.decision.reasons },discussion_thread: v.discussion ? v.discussion.threadId : null,@@@ -201,9 +205,10 @@ function createApi({ svc, viewers, platform, config, log = console }) {const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };}, 201));-- router.post('/pages/:id/media', guard('wiki.page.create'), R((req) => {++ router.post('/pages/:id/media', guard('wiki.page.create'), R(async (req) => {const b = req.body || {};-- return { attachment: svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor) };++ const a = await svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor, { describe: platform.media.describe });++ return { attachment: serializeAttachment(a) };}, 201));router.post('/pages/:id/media/verify', guard('wiki.page.create'), R(async (req) => {const { space } = pageAndSpace(req.params.id);@@@ -421,7 +421,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {}case 'unpublish': svc.unpublish(page.id, req.actor); return back();case 'move': { const p = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }-- case 'attach': svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor); return back();++ case 'attach': await svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor, { describe: platform.media.describe }); return back();case 'detach': svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();case 'verify': {const results = await svc.verifyMedia(page.id, platform.media.resolve);@@@ -433,7 +433,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {default: throw new svc.WikiError(400, 'request.invalid', 'Unknown action');}} catch (err) {-- if (!err.status || err.status >= 500) throw err;++ if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;renderPageSettings(req, res, space, svc.pageById(page.id), err.status, { error: err.message });}}));@@@ -18,6 +18,7 @@ class IntegrationError extends Error {}const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;++const MEDIA_VISIBILITIES = ['public', 'unlisted', 'private'];function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = null, now = () => Date.now(), log = console } = {}) {const tokenClient = tokens || (config.oauth.clientSecret@@@ -132,12 +133,18 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul},};-- // ── Media: does an attached object still exist? (media.object.read, namespace = WIKI_MEDIA_APP) ──++ // ── Media: attached objects (media.object.read, namespace = WIKI_MEDIA_APP) ──++ // Wiki reads with its own authority, which sees the whole namespace; who else may read an object++ // is decided from what Media reports (visibility, owner) — see attachMedia in the wiki service.const media = {configured: !!(tokenClient && config.mediaInternalUrl),publicUrl: (mediaId) => `${config.mediaPublicUrl}/o/${encodeURIComponent(mediaId)}`,-- /** { exists: true } | { exists: false, reason } | throws (outage: no verdict). */-- async resolve(mediaId) {++ /**++ * { exists: false, reason: 'not_found'|'deleted' }++ * | { exists: true, visibility: 'public'|'unlisted'|'private'|null, owner: 'usr_…'|null, status }++ * | throws (outage: no verdict).++ */++ async describe(mediaId) {if (!media.configured) throw new IntegrationError(503, 'media.unavailable', 'OpenVibe.Media is not configured (OV_MEDIA_INTERNAL_URL)');const r = await getJson(`${config.mediaInternalUrl}/api/v2/${encodeURIComponent(config.mediaApp)}/objects/${encodeURIComponent(mediaId)}`, 'openvibe.media');if (r.status === 404) return { exists: false, reason: 'not_found' };@@@ -146,6 +153,20 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nulif (r.status !== 200 || !r.body) throw new IntegrationError(503, 'media.unavailable', `Media answered ${r.status}`);const obj = r.body.object || r.body;if (obj.lifecycle_status === 'deleted') return { exists: false, reason: 'deleted' };++ const owner = obj.owner && typeof obj.owner.subject === 'string' && SUBJECT_RE.test(obj.owner.subject) ? obj.owner.subject : null;++ return { exists: true, visibility: MEDIA_VISIBILITIES.includes(obj.visibility) ? obj.visibility : null, owner, status: obj.lifecycle_status || null };++ },++ /**++ * The periodic and on-demand check: { exists: true } | { exists: false, reason } | throws.++ * Every reader of a page sees its attachments, so an object that became private in Media is++ * withheld (broken, reason 'forbidden') until it is shared again; Media not saying who may++ * read it is no verdict.++ */++ async resolve(mediaId) {++ const d = await media.describe(mediaId);++ if (!d.exists) return d;++ if (d.visibility === 'private') return { exists: false, reason: 'forbidden' };++ if (d.visibility == null) throw new IntegrationError(503, 'media.unavailable', 'Media did not say who may read the object');return { exists: true };},};@@@ -72,9 +72,15 @@ function citationsHtml(cites) {return html`<section class="wk-sources"><h2>Sources</h2><ol>${cites.map((c) => html`<li id="cite-${String(c.id)}">${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <span class="wk-muted">(OpenVibe.Sources item <code>${c.sourceItemId}</code>)</span>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : html`, <span class="wk-muted">retrieval time unknown</span>`}${c.licenseNote ? html` (${c.licenseNote})` : ''}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</li>`)}</ol></section>`;}++const UNAVAILABLE = {++ deleted: 'This media was deleted from OpenVibe.Media and is no longer available.',++ forbidden: 'This media is no longer shared publicly in OpenVibe.Media, so it is not shown here.',++ not_found: 'This media is no longer available.',++};++function mediaHtml(attachments, urlFor) {if (!attachments.length) return '';-- return html`<section class="wk-media"><h2>Media</h2>${attachments.map((a) => raw(figureHtml(a, { urlFor, unavailableText: a.brokenReason === 'deleted' ? 'This media was deleted from OpenVibe.Media and is no longer available.' : 'This media is no longer available.' })))}</section>`;++ return html`<section class="wk-media"><h2>Media</h2>${attachments.map((a) => raw(figureHtml(a, { urlFor, unavailableText: UNAVAILABLE[a.brokenReason] || UNAVAILABLE.not_found })))}</section>`;}function discussionHtml(d, { space, page, actor }) {@@@ -254,7 +260,8 @@ ${page.state === 'published' ? html`<form method="post" action="${base}/settings<p class="wk-muted">The old address keeps working as a permanent redirect.</p><p><button type="submit">Move</button></p></form><h2>Media attachments</h2>--${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.mediaId}</code> ${a.state}${a.brokenReason ? ` (${a.brokenReason})` : ''} <form method="post" action="${base}/settings" class="wk-inline"><input type="hidden" name="op" value="detach"><input type="hidden" name="attachment_id" value="${String(a.id)}"><button type="submit">Detach</button></form></li>`)}</ul>` : html`<p class="wk-muted">No media attached.</p>`}++<p class="wk-muted">Attach public or unlisted OpenVibe.Media objects you can read; every reader of this page sees them. Attachments stay when someone else edits the page; if Media deletes an object or makes it private, the page shows a placeholder instead.</p>++${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.mediaId}</code> ${a.state}${a.brokenReason ? ` (${a.brokenReason})` : ''}${a.attachedBy ? html` <span class="wk-muted">attached by <code>${a.attachedBy}</code></span>` : ''} <form method="post" action="${base}/settings" class="wk-inline"><input type="hidden" name="op" value="detach"><input type="hidden" name="attachment_id" value="${String(a.id)}"><button type="submit">Detach</button></form></li>`)}</ul>` : html`<p class="wk-muted">No media attached.</p>`}<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="attach"><label>OpenVibe.Media object id <input type="text" name="media_id" required placeholder="med_…"></label><label>Alt text <input type="text" name="alt" maxlength="1000"></label>@@@ -16,6 +16,7 @@ const seo = require('openvibe-publishing/seo');const hooks = require('openvibe-publishing/index-hooks');const authorship = require('openvibe-publishing/authorship');const ssr = require('openvibe-publishing/ssr');++const { isMediaId } = require('openvibe-publishing/media');const { ulid } = require('openvibe-contracts').ids;const content = require('./content');const { createAccess, VISIBILITIES } = require('./access');@@@ -114,6 +115,9 @@ function createWikiService({ db, stores, outbox, config, community = null, now =allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_idWHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'ORDER BY p.id`),++ insertOrigin: db.prepare(`INSERT INTO wiki_attachment_origins (attachment_id, page_id, media_id, attached_by, media_owner, media_visibility, attached_at)++ VALUES (?, ?, ?, ?, ?, ?, ?)`),++ originsOf: db.prepare('SELECT * FROM wiki_attachment_origins WHERE page_id = ?'),search: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_idJOIN wiki_page_revisions r ON r.entity_id = p.id AND r.number = p.published_revisionWHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space IS NULL OR s.slug = @space)@@@ -792,7 +796,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =infobox: infoboxOf(page.id, n),citations: citations.forRevision(page.id, n),links: q.linksOf.all(page.id, n),-- attachments: attachments.list(page.id),++ attachments: svc.attachmentsOf(page.id),children: q.children.all(page.id).filter((c) => access.canReadPage(space, c, actor)),parent: page.parent_id ? (() => { const p = q.pageById.get(page.parent_id); return p && access.canReadPage(space, p, actor) ? p : null; })() : null,backlinks: svc.backlinks(space, page, actor),@@@ -906,11 +910,65 @@ function createWikiService({ db, stores, outbox, config, community = null, now =citationsOf(page, revisionNumber) { return citations.forRevision(page.id, Number(revisionNumber)); },citationHistory(page) { return citations.history(page.id); },-- attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor) {++ /**++ * MEDIA ATTACHMENTS ACROSS AUTHORS AND EDITORS. OpenVibe.Media owns an object's read rights:++ * public and unlisted objects are readable by anyone, a private one only by its owner (the++ * rule Media applies to an app acting for one of its users). Wiki reads Media with its own++ * authority, which sees the whole namespace, so it applies that rule itself:++ *++ * 1. Attaching is done by a person (an editor of the space, or a service acting for one), and++ * the object must be readable by that person: it exists, is not deleted, and is public,++ * unlisted or theirs. Anything else — missing, deleted, someone else's private object —++ * gets one answer (media.not_readable), so an id is never confirmed to exist.++ * 2. A page shows its attachments to every reader of the page, and Wiki never re-shares an++ * object under its own authority, so a private object is refused even for its owner++ * (media.private: share it in Media first). Only public and unlisted objects are attached.++ * 3. The attachment belongs to the page (every revision), with a record of who attached it++ * and what Media said then (wiki_attachment_origins). Editors who revise, revert or publish++ * later do not need read rights on existing attachments and never re-attach them: what was++ * checked is the attacher's grant. A later editor can detach an attachment, and attaches++ * new objects under rule 1 with their own rights.++ * 4. Media stays the authority afterwards: the check (verifyMedia, periodic when Media is++ * configured) marks an object that was deleted broken ('deleted'), and one that is missing++ * or became private broken ('not_found' / 'forbidden'). A broken attachment renders an++ * explicit placeholder, never an image; it is shown again when Media shares it again.++ *++ * describe(mediaId) is platform.media.describe. A Media outage refuses the attachment (503):++ * nothing is attached unchecked.++ */++ async attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor, { describe } = {}) {const page = pageOrFail(pageId);const space = spaceOrFail(page.space_id);++ if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can attach media');-- return wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: String(mediaId || '').trim(), alt, caption, role }));++ const who = actor && actor.kind === 'system' ? null : requirePerson(actor);++ const id = String(mediaId || '').trim();++ if (!isMediaId(id)) fail(400, 'media.invalid_id', 'mediaId must be a Media object id (med_<ULID> or legacy:<app>:<kind>:<id>)');++ if (typeof describe !== 'function') fail(503, 'media.unavailable', 'Attachments are checked against OpenVibe.Media, which is not configured here: nothing was attached');++ let obj;++ try { obj = await describe(id); } catch (err) {++ fail(503, 'media.unavailable', `Attachments are checked against OpenVibe.Media, which could not answer (${err && err.message}): nothing was attached`);++ }++ if (obj && obj.exists && !['public', 'unlisted', 'private'].includes(obj.visibility)) fail(503, 'media.unavailable', 'OpenVibe.Media did not say who may read that object: nothing was attached');++ const readable = !!(obj && obj.exists && (obj.visibility !== 'private' || (who && obj.owner === who)));++ if (!readable) fail(422, 'media.not_readable', 'No OpenVibe.Media object you can read has that id');++ if (obj.visibility === 'private') fail(409, 'media.private', 'That object is private in OpenVibe.Media. Every reader of a wiki page sees its media, so make the object unlisted or public in Media first');++ if (obj.status && obj.status !== 'ready') fail(409, 'media.not_ready', `That object is ${obj.status} in OpenVibe.Media, not ready yet`);++ return tx(() => {++ const att = wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: id, alt, caption, role }));++ q.insertOrigin.run(att.id, page.id, id, actorId(actor), obj.owner || null, obj.visibility, now());++ attachments.markAvailable(id);++ return svc.attachmentsOf(page.id).find((a) => a.id === att.id);++ });++ },++++ /** A page's attachments, each with who attached it (null for rows older than the record). */++ attachmentsOf(pageId) {++ const origins = new Map(q.originsOf.all(pageId).map((o) => [o.attachment_id, o]));++ return attachments.list(pageId).map((a) => {++ const o = origins.get(a.id);++ return { ...a, attachedBy: o ? o.attached_by : null, mediaVisibilityAtAttach: o ? o.media_visibility : null };++ });},detachMedia(pageId, attachmentId, actor) {@@@ -15,6 +15,9 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';(async () => {const calls = [];let sourcesUp = true;++ // Every object is ready when it is attached; later B goes missing, C is deleted and Media stops answering for D.++ let mediaLater = false;++ const mediaState = (id) => (!mediaLater || id === MED('A') ? 'ready' : { [MED('B')]: 'missing', [MED('C')]: 'deleted', [MED('D')]: 'down' }[id] || 'missing');const thread = { id: 7, visibility: 'public', comment_count: 1 };const comments = [{ id: 1, thread_id: 7, display_name: 'Bea', message: 'Nice page <b>really</b>', created_at: '2026-09-21T10:00:00.000Z', origin: 'user', deleted: false }];const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });@@@ -24,10 +27,10 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';calls.push({ url: u.pathname, method: init.method || 'GET', headers, body: init.body ? String(init.body) : null, host: u.host });if (u.host === 'media.internal') {const id = u.pathname.split('/').pop();-- if (id === MED('A')) return json(200, { id, lifecycle_status: 'ready' });-- if (id === MED('B')) return json(404, { code: 'media.object.not_found' });-- if (id === MED('C')) return json(200, { id, lifecycle_status: 'deleted' });-- throw new Error('ECONNREFUSED');++ const state = mediaState(id);++ if (state === 'down') throw new Error('ECONNREFUSED');++ if (state === 'missing') return json(404, { code: 'media.object.not_found' });++ return json(200, { id, visibility: 'public', owner: { subject: me }, lifecycle_status: state });}if (u.host === 'sources.internal') {if (!sourcesUp) throw new Error('ECONNREFUSED');@@@ -84,6 +87,7 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';}r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media`, { token: tok, body: { media_id: 'https://evil.example/x.png' } });assert.strictEqual(r.status, 400, 'only Media object ids, never URLs');++ mediaLater = true;r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media/verify`, { token: tok, body: {} });const outcome = Object.fromEntries(r.json.results.map((x) => [x.mediaId, x.outcome + (x.reason ? `:${x.reason}` : '')]));assert.deepStrictEqual(outcome, { [MED('A')]: 'available', [MED('B')]: 'broken:not_found', [MED('C')]: 'broken:deleted', [MED('D')]: 'check_failed' });@@@ -93,9 +97,9 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';assert.ok(html.includes('This media is no longer available.'));assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('B')}"`), 'no image for a broken object');assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('C')}"`));-- assert.ok(html.includes(`data-media-id="${MED('D')}" data-state="unverified"`), 'an outage leaves the state alone');++ assert.ok(html.includes(`data-media-id="${MED('D')}" data-state="available"`), 'an outage leaves the state alone');const pj = (await H.req(h, 'GET', '/w/mt/cited.json')).json;-- assert.deepStrictEqual(pj.attachments.map((a) => a.state), ['available', 'broken', 'broken', 'unverified']);++ assert.deepStrictEqual(pj.attachments.map((a) => a.state), ['available', 'broken', 'broken', 'available']);// Community: the thread is resolved once and stored as a reference; comments are Community's.r = await H.req(h, 'GET', '/w/mt/cited');nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++/**++ * Media permissions survive the author/editor handoff (the model is documented at attachMedia in++ * server/wiki/service.js):++ * author A attaches an object A can read → editor B revises, reverts and publishes (the attachment++ * stays, B needs no rights on it) → B cannot attach an object only A can read (the same answer as++ * an unknown id), and not even A can attach a private one → Media deletes one object and makes the++ * other private → the public page shows the explicit broken / withheld state, never the image, and++ * the image comes back when Media shares the object again.++ */++const assert = require('assert');++const H = require('./helpers');++++const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;++++(async () => {++ const A = H.subject();++ const B = H.subject();++ const C = H.subject();++ // Media's view of the namespace; tests change it as Media would.++ const objects = new Map([++ [MED(1), { visibility: 'public', owner: A, lifecycle_status: 'ready' }], // A's picture++ [MED(2), { visibility: 'unlisted', owner: C, lifecycle_status: 'ready' }], // someone else's, shared by link++ [MED(3), { visibility: 'private', owner: A, lifecycle_status: 'ready' }], // only A can read it++ [MED(4), { visibility: 'public', owner: A, lifecycle_status: 'pending' }], // upload not finished++ ]);++ let mediaUp = true;++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });++ const fetchStub = async (url) => {++ const u = new URL(url);++ if (u.host !== 'media.internal') return json(404, { error: 'stub: no route' });++ if (!mediaUp) throw new Error('ECONNREFUSED');++ const id = decodeURIComponent(u.pathname.split('/').pop());++ const o = objects.get(id);++ if (!o) return json(404, { code: 'media.object.not_found' });++ return json(200, { id, visibility: o.visibility, owner: { subject: o.owner, app: 'wiki', user_id: null }, lifecycle_status: o.lifecycle_status });++ };++ const h = await H.boot({ fetch: fetchStub, env: { OV_MEDIA_INTERNAL_URL: 'http://media.internal' } });++ const tokA = H.userToken({ subject: A });++ const tokB = H.userToken({ subject: B });++ const attach = (token, pageId, mediaId, extra = {}) => H.req(h, 'POST', `/api/v1/pages/${pageId}/media`, { token, body: { media_id: mediaId, alt: `alt ${mediaId.slice(-3)}` }, ...extra });++ const imgOf = (id) => `<img src="https://openvibe.media/o/${id}"`;++ try {++ // A's space; B is an editor.++ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tokA, body: { name: 'Handoff', slug: 'handoff' } });++ assert.strictEqual(r.status, 201);++ assert.strictEqual((await H.req(h, 'PUT', `/api/v1/spaces/handoff/roles/${B}`, { token: tokA, body: { role: 'editor' } })).status, 200);++ r = await H.req(h, 'POST', '/api/v1/spaces/handoff/pages', { token: tokA, body: { title: 'Gallery', body: H.LONG } });++ const pageId = r.json.page.id;++ await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tokA, body: {} });++++ // 1. Author A attaches an object A owns and one shared by link; both are checked as A.++ r = await attach(tokA, pageId, MED(1));++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.json.attachment.state, 'available');++ assert.strictEqual(r.json.attachment.attached_by, A);++ assert.strictEqual((await attach(tokA, pageId, MED(2))).status, 201, 'unlisted objects are readable by anyone with the id');++ const origins = h.db.prepare('SELECT media_id, attached_by, media_owner, media_visibility FROM wiki_attachment_origins WHERE page_id = ? ORDER BY attachment_id').all(pageId);++ assert.deepStrictEqual(origins, [++ { media_id: MED(1), attached_by: A, media_owner: A, media_visibility: 'public' },++ { media_id: MED(2), attached_by: A, media_owner: C, media_visibility: 'unlisted' },++ ]);++ assert.throws(() => h.db.prepare('UPDATE wiki_attachment_origins SET attached_by = ?').run(B), /immutable/);++ r = await attach(tokA, pageId, MED(4));++ assert.strictEqual(r.status, 409);++ assert.strictEqual(r.json.code, 'media.not_ready');++++ // 2. Editor B revises, publishes and reverts: the attachments stay, nothing is re-attached.++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/revisions`, { token: tokB, body: { expected_revision: 1, body: `${H.LONG} Edited by B.` } });++ assert.strictEqual(r.status, 201, r.text);++ await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tokB, body: {} });++ let page = await H.req(h, 'GET', '/w/handoff/gallery');++ assert.ok(page.text.includes('Edited by B.'));++ assert.ok(page.text.includes(imgOf(MED(1))) && page.text.includes(imgOf(MED(2))), 'the attachments carry over to B\'s revision');++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/revert`, { token: tokB, body: { to_revision: 1, expected_revision: 2 } });++ assert.strictEqual(r.status, 201, r.text);++ r = await H.req(h, 'GET', `/api/v1/pages/${pageId}`, { token: tokB });++ assert.deepStrictEqual(r.json.attachments.map((a) => [a.media_id, a.attached_by, a.state]), [[MED(1), A, 'available'], [MED(2), A, 'available']]);++ assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2);++++ // 3. B cannot attach an object only A can read — the same answer as an id that does not exist.++ const refused = await attach(tokB, pageId, MED(3));++ const unknown = await attach(tokB, pageId, MED(999));++ assert.strictEqual(refused.status, 422);++ assert.strictEqual(refused.json.code, 'media.not_readable');++ assert.deepStrictEqual([unknown.status, unknown.json.code, unknown.json.detail], [refused.status, refused.json.code, refused.json.detail], 'a private id is not confirmed to exist');++ // …nor through a service acting for B, nor through the page settings form.++ const svcTok = H.serviceToken({ client: 'ai', cap: ['wiki.page.create'] });++ r = await attach(svcTok, pageId, MED(3), { headers: { 'X-OV-Subject': B } });++ assert.strictEqual(r.status, 422);++ assert.strictEqual(r.json.code, 'media.not_readable');++ r = await H.req(h, 'POST', '/w/handoff/gallery/settings', { cookie: H.cookieFor(tokB), form: { op: 'attach', media_id: MED(3) } });++ assert.strictEqual(r.status, 422);++ assert.ok(r.text.includes('No OpenVibe.Media object you can read has that id'));++ // Its owner can read it, but a page shows media to every reader: private objects are never attached.++ r = await attach(tokA, pageId, MED(3));++ assert.strictEqual(r.status, 409);++ assert.strictEqual(r.json.code, 'media.private');++ // No person, no attachment (a service acting as itself has no rights in the space); a Media++ // outage attaches nothing unchecked.++ r = await attach(H.serviceToken({ client: 'ai', cap: ['wiki.page.create'] }), pageId, MED(1));++ assert.strictEqual(r.status, 403);++ await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'service', service: 'svc:ai', subject: null }, { describe: h.platform.media.describe }), (e) => e.status === 403);++ mediaUp = false;++ r = await attach(tokA, pageId, MED(1));++ assert.strictEqual(r.status, 503);++ mediaUp = true;++ await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'user', subject: A }, {}), (e) => e.status === 503, 'no Media configured: nothing is attached');++ assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2, 'nothing refused was attached');++++ // 4. Media deletes one object and makes the other private: after the check, the public page++ // shows the explicit states and no image.++ objects.get(MED(1)).visibility = 'private';++ objects.get(MED(2)).lifecycle_status = 'deleted';++ r = await H.req(h, 'POST', '/w/handoff/gallery/settings', { cookie: H.cookieFor(tokB), form: { op: 'verify' } });++ assert.strictEqual(r.status, 303);++ assert.ok(decodeURIComponent(r.headers.get('location')).includes('0 available, 2 broken'));++ page = await H.req(h, 'GET', '/w/handoff/gallery');++ assert.strictEqual(page.status, 200);++ assert.ok(!page.text.includes(imgOf(MED(1))) && !page.text.includes(imgOf(MED(2))), 'no image for a withheld or deleted object');++ assert.ok(page.text.includes('This media is no longer shared publicly in OpenVibe.Media, so it is not shown here.'));++ assert.ok(page.text.includes('This media was deleted from OpenVibe.Media and is no longer available.'));++ const pj = (await H.req(h, 'GET', '/w/handoff/gallery.json')).json;++ assert.deepStrictEqual(pj.attachments.map((a) => [a.state, a.broken_reason]), [['broken', 'forbidden'], ['broken', 'deleted']]);++++ // 5. Shared again in Media: the next check shows it again.++ objects.get(MED(1)).visibility = 'public';++ await H.req(h, 'POST', `/api/v1/pages/${pageId}/media/verify`, { token: tokA, body: {} });++ page = await H.req(h, 'GET', '/w/handoff/gallery');++ assert.ok(page.text.includes(imgOf(MED(1))));++ assert.ok(!page.text.includes(imgOf(MED(2))));++ console.log('media handoff ok');++ } finally {++ await h.stop();++ }++})().catch((err) => { console.error(err); process.exit(1); });
@@ -43,7 +43,8 @@ The ten authority tables of §15.13, in Wiki's own SQLite database (`WIKI_DB_PAT
@@ -43,7 +43,8 @@ The ten authority tables of §15.13, in Wiki's own SQLite database (`WIKI_DB_PAT
The packages add their helper tables under the same prefix (`wiki_page_drafts`, `wiki_page_reviews`,
The packages add their helper tables under the same prefix (`wiki_page_drafts`, `wiki_page_reviews`,
`wiki_page_attachments`, `wiki_schedule_jobs`, `wiki_discussion_refs`, `wiki_index_revisions`, the
`wiki_page_attachments`, `wiki_schedule_jobs`, `wiki_discussion_refs`, `wiki_index_revisions`, the
-purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
+purge audit tables); the SDK outbox keeps `wiki_event_outbox`. Wiki's own `wiki_attachment_origins`
+records who attached each Media object and what Media said about it at that moment.
## Does not own
## Does not own
@@ -83,6 +84,17 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
@@ -83,6 +84,17 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
- **Media**: attachments by Media object id; a check against Media marks missing or deleted objects
- **Media**: attachments by Media object id; a check against Media marks missing or deleted objects
`broken` and the page shows an explicit "no longer available" placeholder instead of an image; an
`broken` and the page shows an explicit "no longer available" placeholder instead of an image; an
outage changes nothing (`check_failed`). A periodic check runs when Media is configured.
outage changes nothing (`check_failed`). A periodic check runs when Media is configured.
+- **Media across authors and editors**: Media owns read rights (public and unlisted objects are
+ readable by anyone, a private one only by its owner). A person attaches an object only if they can
+ read it in Media — missing, deleted and other people's private objects get one answer
+ (`media.not_readable`), so an id is never confirmed — and only public or unlisted objects are
+ attached at all (every reader of a page sees its media; Wiki never re-shares a private object under
+ its own authority). The attachment belongs to the page, with who attached it and the Media
+ visibility checked then (`wiki_attachment_origins`); later editors revise, revert and publish
+ without re-attaching and need no rights on it. When Media deletes an object or makes it private,
+ the next check shows "deleted" or "no longer shared publicly" instead of the image, until Media
+ shares it again. With Media unreachable or not configured nothing is attached (503). The model is
+ documented at `attachMedia` in [server/wiki/service.js](server/wiki/service.js).
- **Discussion**: public, published pages get a Community thread (resolved once, stored as a
- **Discussion**: public, published pages get a Community thread (resolved once, stored as a
reference); comments render server-side; signed-in people comment through a form (posted to
reference); comments render server-side; signed-in people comment through a form (posted to
Community as that person). Failures show an explicit "could not be loaded" state.
Community as that person). Failures show an explicit "could not be loaded" state.
@@ -208,6 +220,9 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
@@ -208,6 +220,9 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
- `/feed.atom` is a valid Atom feed with zero entries while nothing is listable (`feeds.test.js`)
- `/feed.atom` is a valid Atom feed with zero entries while nothing is listable (`feeds.test.js`)
- a public page is useful with JavaScript disabled, and so is editing (`nojs.test.js`)
- a public page is useful with JavaScript disabled, and so is editing (`nojs.test.js`)
- a deleted or missing Media object renders an explicit broken-asset state (`integrations.test.js`)
- a deleted or missing Media object renders an explicit broken-asset state (`integrations.test.js`)
+- Media permissions survive the author/editor handoff: an editor keeps the author's attachments,
+ cannot attach an object only the author can read, and an object Media deletes or makes private is
+ shown as broken or withheld on the public page (`media-handoff.test.js`)
- permissions are enforced; visitors without SSO read public content only; AI proposals need a
- permissions are enforced; visitors without SSO read public content only; AI proposals need a
person's approval (`permissions.test.js`)
person's approval (`permissions.test.js`)
- every event is a valid `events.event-envelope@1` and every index document a valid
- every event is a valid `events.event-envelope@1` and every index document a valid
@@ -17,6 +17,7 @@
@@ -17,6 +17,7 @@
* The Publishing packages add their helper tables under the same prefix (wiki_page_drafts,
* The Publishing packages add their helper tables under the same prefix (wiki_page_drafts,
* wiki_page_reviews, wiki_page_attachments, wiki_schedule_jobs, wiki_discussion_refs,
* wiki_page_reviews, wiki_page_attachments, wiki_schedule_jobs, wiki_discussion_refs,
* wiki_index_revisions, the purge audit tables) and the SDK outbox keeps wiki_event_outbox.
* wiki_index_revisions, the purge audit tables) and the SDK outbox keeps wiki_event_outbox.
+ * wiki_attachment_origins records who attached each Media object and the read rights checked then.
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
@@ -131,6 +132,20 @@ CREATE TABLE IF NOT EXISTS wiki_ai_proposals (
@@ -131,6 +132,20 @@ CREATE TABLE IF NOT EXISTS wiki_ai_proposals (
UNIQUE (page_id, revision)
UNIQUE (page_id, revision)
);
);
CREATE INDEX IF NOT EXISTS wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);
CREATE INDEX IF NOT EXISTS wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);
+
+-- Who attached a Media object to a page, and what Media said about it at that moment (the read
+-- rights that were checked). One row per attachment, written with it and never changed.
+CREATE TABLE IF NOT EXISTS wiki_attachment_origins (
+ attachment_id INTEGER PRIMARY KEY,
+ page_id TEXT NOT NULL,
+ media_id TEXT NOT NULL,
+ attached_by TEXT NOT NULL,
+ media_owner TEXT,
+ media_visibility TEXT NOT NULL CHECK (media_visibility IN ('public','unlisted','private')),
+ attached_at INTEGER NOT NULL
+);
+CREATE TRIGGER IF NOT EXISTS wiki_attachment_origins_no_update BEFORE UPDATE ON wiki_attachment_origins
+BEGIN SELECT RAISE(ABORT, 'wiki_attachment_origins rows are immutable'); END;
`;
`;
function openDb(dbPath) {
function openDb(dbPath) {
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
* POST /pages/:id/unpublish wiki.revision.publish
* POST /pages/:id/unpublish wiki.revision.publish
* POST /pages/:id/revert wiki.revision.revert { to_revision, expected_revision, message?, publish? }
* POST /pages/:id/revert wiki.revision.revert { to_revision, expected_revision, message?, publish? }
- * POST /pages/:id/media wiki.page.create { media_id, alt?, caption? }
+ * POST /pages/:id/media wiki.page.create { media_id, alt?, caption? } a person who can read the object in Media
* POST /pages/:id/media/verify wiki.page.create
* POST /pages/:id/media/verify wiki.page.create
* PUT /pages/:id/watch { watching: bool } people only
* PUT /pages/:id/watch { watching: bool } people only
* POST /proposals wiki.revision.propose { space, page_id?, title?, body, summary?, infobox?, citations?, workflow: { id, run_id, version?, model? }, stub_provider?, expected_revision?, note? }
* POST /proposals wiki.revision.propose { space, page_id?, title?, body, summary?, infobox?, citations?, workflow: { id, run_id, version?, model? }, stub_provider?, expected_revision?, note? }
@@ -66,6 +66,10 @@ function serializeCitation(c) {
@@ -66,6 +66,10 @@ function serializeCitation(c) {
return { id: c.id, revision: c.revision, url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote, license_note: c.licenseNote, carried_from: c.carriedFrom, attached_by: c.attachedBy, attached_at: c.attachedAt };
return { id: c.id, revision: c.revision, url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote, license_note: c.licenseNote, carried_from: c.carriedFrom, attached_by: c.attachedBy, attached_at: c.attachedAt };
}
}
+function serializeAttachment(a) {
+ return { id: a.id, media_id: a.mediaId, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, checked_at: a.checkedAt, attached_by: a.attachedBy };
+}
+
function serializeTree(nodes, svc, space) {
function serializeTree(nodes, svc, space) {
return nodes.map((n) => ({ ...serializePage(n.page, svc, space), children: serializeTree(n.children, svc, space) }));
return nodes.map((n) => ({ ...serializePage(n.page, svc, space), children: serializeTree(n.children, svc, space) }));
}
}
@@ -125,7 +129,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -125,7 +129,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
return {
return {
page: serializePage(page, svc, space), revision: serializeRevision(v.revision),
page: serializePage(page, svc, space), revision: serializeRevision(v.revision),
citations: v.citations.map(serializeCitation), infobox: v.infobox,
citations: v.citations.map(serializeCitation), infobox: v.infobox,
- attachments: v.attachments.map((a) => ({ id: a.id, media_id: a.mediaId, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, checked_at: a.checkedAt })),
+ attachments: v.attachments.map(serializeAttachment),
links: v.links.map((l) => ({ space: l.target_space, slug: l.target_slug, label: l.label })),
links: v.links.map((l) => ({ space: l.target_space, slug: l.target_slug, label: l.label })),
indexability: { indexable: v.decision.indexable, reasons: v.decision.reasons },
indexability: { indexable: v.decision.indexable, reasons: v.decision.reasons },
discussion_thread: v.discussion ? v.discussion.threadId : null,
discussion_thread: v.discussion ? v.discussion.threadId : null,
@@ -201,9 +205,10 @@ function createApi({ svc, viewers, platform, config, log = console }) {
@@ -201,9 +205,10 @@ function createApi({ svc, viewers, platform, config, log = console }) {
const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };
}, 201));
}, 201));
- router.post('/pages/:id/media', guard('wiki.page.create'), R((req) => {
+ router.post('/pages/:id/media', guard('wiki.page.create'), R(async (req) => {
const b = req.body || {};
const b = req.body || {};
- return { attachment: svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor) };
+ const a = await svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor, { describe: platform.media.describe });
+ return { attachment: serializeAttachment(a) };
}, 201));
}, 201));
router.post('/pages/:id/media/verify', guard('wiki.page.create'), R(async (req) => {
router.post('/pages/:id/media/verify', guard('wiki.page.create'), R(async (req) => {
const { space } = pageAndSpace(req.params.id);
const { space } = pageAndSpace(req.params.id);
@@ -421,7 +421,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -421,7 +421,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
}
}
case 'unpublish': svc.unpublish(page.id, req.actor); return back();
case 'unpublish': svc.unpublish(page.id, req.actor); return back();
case 'move': { const p = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }
case 'move': { const p = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }
- case 'attach': svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor); return back();
+ case 'attach': await svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor, { describe: platform.media.describe }); return back();
case 'detach': svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();
case 'detach': svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();
case 'verify': {
case 'verify': {
const results = await svc.verifyMedia(page.id, platform.media.resolve);
const results = await svc.verifyMedia(page.id, platform.media.resolve);
@@ -433,7 +433,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
@@ -433,7 +433,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
default: throw new svc.WikiError(400, 'request.invalid', 'Unknown action');
default: throw new svc.WikiError(400, 'request.invalid', 'Unknown action');
}
}
} catch (err) {
} catch (err) {
- if (!err.status || err.status >= 500) throw err;
+ if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;
renderPageSettings(req, res, space, svc.pageById(page.id), err.status, { error: err.message });
renderPageSettings(req, res, space, svc.pageById(page.id), err.status, { error: err.message });
}
}
}));
}));
@@ -18,6 +18,7 @@ class IntegrationError extends Error {
@@ -18,6 +18,7 @@ class IntegrationError extends Error {
}
}
const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
+const MEDIA_VISIBILITIES = ['public', 'unlisted', 'private'];
function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = null, now = () => Date.now(), log = console } = {}) {
function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = null, now = () => Date.now(), log = console } = {}) {
const tokenClient = tokens || (config.oauth.clientSecret
const tokenClient = tokens || (config.oauth.clientSecret
@@ -132,12 +133,18 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
@@ -132,12 +133,18 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
},
},
};
};
- // ── Media: does an attached object still exist? (media.object.read, namespace = WIKI_MEDIA_APP) ──
+ // ── Media: attached objects (media.object.read, namespace = WIKI_MEDIA_APP) ──
+ // Wiki reads with its own authority, which sees the whole namespace; who else may read an object
+ // is decided from what Media reports (visibility, owner) — see attachMedia in the wiki service.
const media = {
const media = {
configured: !!(tokenClient && config.mediaInternalUrl),
configured: !!(tokenClient && config.mediaInternalUrl),
publicUrl: (mediaId) => `${config.mediaPublicUrl}/o/${encodeURIComponent(mediaId)}`,
publicUrl: (mediaId) => `${config.mediaPublicUrl}/o/${encodeURIComponent(mediaId)}`,
- /** { exists: true } | { exists: false, reason } | throws (outage: no verdict). */
- async resolve(mediaId) {
+ /**
+ * { exists: false, reason: 'not_found'|'deleted' }
+ * | { exists: true, visibility: 'public'|'unlisted'|'private'|null, owner: 'usr_…'|null, status }
+ * | throws (outage: no verdict).
+ */
+ async describe(mediaId) {
if (!media.configured) throw new IntegrationError(503, 'media.unavailable', 'OpenVibe.Media is not configured (OV_MEDIA_INTERNAL_URL)');
if (!media.configured) throw new IntegrationError(503, 'media.unavailable', 'OpenVibe.Media is not configured (OV_MEDIA_INTERNAL_URL)');
const r = await getJson(`${config.mediaInternalUrl}/api/v2/${encodeURIComponent(config.mediaApp)}/objects/${encodeURIComponent(mediaId)}`, 'openvibe.media');
const r = await getJson(`${config.mediaInternalUrl}/api/v2/${encodeURIComponent(config.mediaApp)}/objects/${encodeURIComponent(mediaId)}`, 'openvibe.media');
if (r.status === 404) return { exists: false, reason: 'not_found' };
if (r.status === 404) return { exists: false, reason: 'not_found' };
@@ -146,6 +153,20 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
@@ -146,6 +153,20 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
if (r.status !== 200 || !r.body) throw new IntegrationError(503, 'media.unavailable', `Media answered ${r.status}`);
if (r.status !== 200 || !r.body) throw new IntegrationError(503, 'media.unavailable', `Media answered ${r.status}`);
const obj = r.body.object || r.body;
const obj = r.body.object || r.body;
if (obj.lifecycle_status === 'deleted') return { exists: false, reason: 'deleted' };
if (obj.lifecycle_status === 'deleted') return { exists: false, reason: 'deleted' };
+ const owner = obj.owner && typeof obj.owner.subject === 'string' && SUBJECT_RE.test(obj.owner.subject) ? obj.owner.subject : null;
+ return { exists: true, visibility: MEDIA_VISIBILITIES.includes(obj.visibility) ? obj.visibility : null, owner, status: obj.lifecycle_status || null };
+ },
+ /**
+ * The periodic and on-demand check: { exists: true } | { exists: false, reason } | throws.
+ * Every reader of a page sees its attachments, so an object that became private in Media is
+ * withheld (broken, reason 'forbidden') until it is shared again; Media not saying who may
+ * read it is no verdict.
+ */
+ async resolve(mediaId) {
+ const d = await media.describe(mediaId);
+ if (!d.exists) return d;
+ if (d.visibility === 'private') return { exists: false, reason: 'forbidden' };
+ if (d.visibility == null) throw new IntegrationError(503, 'media.unavailable', 'Media did not say who may read the object');
return { exists: true };
return { exists: true };
},
},
};
};
@@ -72,9 +72,15 @@ function citationsHtml(cites) {
@@ -72,9 +72,15 @@ function citationsHtml(cites) {
return html`<section class="wk-sources"><h2>Sources</h2><ol>${cites.map((c) => html`<li id="cite-${String(c.id)}">${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <span class="wk-muted">(OpenVibe.Sources item <code>${c.sourceItemId}</code>)</span>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : html`, <span class="wk-muted">retrieval time unknown</span>`}${c.licenseNote ? html` (${c.licenseNote})` : ''}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</li>`)}</ol></section>`;
return html`<section class="wk-sources"><h2>Sources</h2><ol>${cites.map((c) => html`<li id="cite-${String(c.id)}">${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <span class="wk-muted">(OpenVibe.Sources item <code>${c.sourceItemId}</code>)</span>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : html`, <span class="wk-muted">retrieval time unknown</span>`}${c.licenseNote ? html` (${c.licenseNote})` : ''}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</li>`)}</ol></section>`;
}
}
+const UNAVAILABLE = {
+ deleted: 'This media was deleted from OpenVibe.Media and is no longer available.',
+ forbidden: 'This media is no longer shared publicly in OpenVibe.Media, so it is not shown here.',
+ not_found: 'This media is no longer available.',
+};
+
function mediaHtml(attachments, urlFor) {
function mediaHtml(attachments, urlFor) {
if (!attachments.length) return '';
if (!attachments.length) return '';
- return html`<section class="wk-media"><h2>Media</h2>${attachments.map((a) => raw(figureHtml(a, { urlFor, unavailableText: a.brokenReason === 'deleted' ? 'This media was deleted from OpenVibe.Media and is no longer available.' : 'This media is no longer available.' })))}</section>`;
+ return html`<section class="wk-media"><h2>Media</h2>${attachments.map((a) => raw(figureHtml(a, { urlFor, unavailableText: UNAVAILABLE[a.brokenReason] || UNAVAILABLE.not_found })))}</section>`;
}
}
function discussionHtml(d, { space, page, actor }) {
function discussionHtml(d, { space, page, actor }) {
@@ -254,7 +260,8 @@ ${page.state === 'published' ? html`<form method="post" action="${base}/settings
@@ -254,7 +260,8 @@ ${page.state === 'published' ? html`<form method="post" action="${base}/settings
<p class="wk-muted">The old address keeps working as a permanent redirect.</p>
<p class="wk-muted">The old address keeps working as a permanent redirect.</p>
<p><button type="submit">Move</button></p></form>
<p><button type="submit">Move</button></p></form>
<h2>Media attachments</h2>
<h2>Media attachments</h2>
-${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.mediaId}</code> ${a.state}${a.brokenReason ? ` (${a.brokenReason})` : ''} <form method="post" action="${base}/settings" class="wk-inline"><input type="hidden" name="op" value="detach"><input type="hidden" name="attachment_id" value="${String(a.id)}"><button type="submit">Detach</button></form></li>`)}</ul>` : html`<p class="wk-muted">No media attached.</p>`}
+<p class="wk-muted">Attach public or unlisted OpenVibe.Media objects you can read; every reader of this page sees them. Attachments stay when someone else edits the page; if Media deletes an object or makes it private, the page shows a placeholder instead.</p>
+${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.mediaId}</code> ${a.state}${a.brokenReason ? ` (${a.brokenReason})` : ''}${a.attachedBy ? html` <span class="wk-muted">attached by <code>${a.attachedBy}</code></span>` : ''} <form method="post" action="${base}/settings" class="wk-inline"><input type="hidden" name="op" value="detach"><input type="hidden" name="attachment_id" value="${String(a.id)}"><button type="submit">Detach</button></form></li>`)}</ul>` : html`<p class="wk-muted">No media attached.</p>`}
<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="attach">
<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="attach">
<label>OpenVibe.Media object id <input type="text" name="media_id" required placeholder="med_…"></label>
<label>OpenVibe.Media object id <input type="text" name="media_id" required placeholder="med_…"></label>
<label>Alt text <input type="text" name="alt" maxlength="1000"></label>
<label>Alt text <input type="text" name="alt" maxlength="1000"></label>
@@ -16,6 +16,7 @@ const seo = require('openvibe-publishing/seo');
@@ -16,6 +16,7 @@ const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
const authorship = require('openvibe-publishing/authorship');
const ssr = require('openvibe-publishing/ssr');
const ssr = require('openvibe-publishing/ssr');
+const { isMediaId } = require('openvibe-publishing/media');
const { ulid } = require('openvibe-contracts').ids;
const { ulid } = require('openvibe-contracts').ids;
const content = require('./content');
const content = require('./content');
const { createAccess, VISIBILITIES } = require('./access');
const { createAccess, VISIBILITIES } = require('./access');
@@ -114,6 +115,9 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -114,6 +115,9 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
ORDER BY p.id`),
ORDER BY p.id`),
+ insertOrigin: db.prepare(`INSERT INTO wiki_attachment_origins (attachment_id, page_id, media_id, attached_by, media_owner, media_visibility, attached_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?)`),
+ originsOf: db.prepare('SELECT * FROM wiki_attachment_origins WHERE page_id = ?'),
search: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
search: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
JOIN wiki_page_revisions r ON r.entity_id = p.id AND r.number = p.published_revision
JOIN wiki_page_revisions r ON r.entity_id = p.id AND r.number = p.published_revision
WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space IS NULL OR s.slug = @space)
WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space IS NULL OR s.slug = @space)
@@ -792,7 +796,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -792,7 +796,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
infobox: infoboxOf(page.id, n),
infobox: infoboxOf(page.id, n),
citations: citations.forRevision(page.id, n),
citations: citations.forRevision(page.id, n),
links: q.linksOf.all(page.id, n),
links: q.linksOf.all(page.id, n),
- attachments: attachments.list(page.id),
+ attachments: svc.attachmentsOf(page.id),
children: q.children.all(page.id).filter((c) => access.canReadPage(space, c, actor)),
children: q.children.all(page.id).filter((c) => access.canReadPage(space, c, actor)),
parent: page.parent_id ? (() => { const p = q.pageById.get(page.parent_id); return p && access.canReadPage(space, p, actor) ? p : null; })() : null,
parent: page.parent_id ? (() => { const p = q.pageById.get(page.parent_id); return p && access.canReadPage(space, p, actor) ? p : null; })() : null,
backlinks: svc.backlinks(space, page, actor),
backlinks: svc.backlinks(space, page, actor),
@@ -906,11 +910,65 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -906,11 +910,65 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
citationsOf(page, revisionNumber) { return citations.forRevision(page.id, Number(revisionNumber)); },
citationsOf(page, revisionNumber) { return citations.forRevision(page.id, Number(revisionNumber)); },
citationHistory(page) { return citations.history(page.id); },
citationHistory(page) { return citations.history(page.id); },
- attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor) {
+ /**
+ * MEDIA ATTACHMENTS ACROSS AUTHORS AND EDITORS. OpenVibe.Media owns an object's read rights:
+ * public and unlisted objects are readable by anyone, a private one only by its owner (the
+ * rule Media applies to an app acting for one of its users). Wiki reads Media with its own
+ * authority, which sees the whole namespace, so it applies that rule itself:
+ *
+ * 1. Attaching is done by a person (an editor of the space, or a service acting for one), and
+ * the object must be readable by that person: it exists, is not deleted, and is public,
+ * unlisted or theirs. Anything else — missing, deleted, someone else's private object —
+ * gets one answer (media.not_readable), so an id is never confirmed to exist.
+ * 2. A page shows its attachments to every reader of the page, and Wiki never re-shares an
+ * object under its own authority, so a private object is refused even for its owner
+ * (media.private: share it in Media first). Only public and unlisted objects are attached.
+ * 3. The attachment belongs to the page (every revision), with a record of who attached it
+ * and what Media said then (wiki_attachment_origins). Editors who revise, revert or publish
+ * later do not need read rights on existing attachments and never re-attach them: what was
+ * checked is the attacher's grant. A later editor can detach an attachment, and attaches
+ * new objects under rule 1 with their own rights.
+ * 4. Media stays the authority afterwards: the check (verifyMedia, periodic when Media is
+ * configured) marks an object that was deleted broken ('deleted'), and one that is missing
+ * or became private broken ('not_found' / 'forbidden'). A broken attachment renders an
+ * explicit placeholder, never an image; it is shown again when Media shares it again.
+ *
+ * describe(mediaId) is platform.media.describe. A Media outage refuses the attachment (503):
+ * nothing is attached unchecked.
+ */
+ async attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor, { describe } = {}) {
const page = pageOrFail(pageId);
const page = pageOrFail(pageId);
const space = spaceOrFail(page.space_id);
const space = spaceOrFail(page.space_id);
+ if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can attach media');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can attach media');
- return wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: String(mediaId || '').trim(), alt, caption, role }));
+ const who = actor && actor.kind === 'system' ? null : requirePerson(actor);
+ const id = String(mediaId || '').trim();
+ if (!isMediaId(id)) fail(400, 'media.invalid_id', 'mediaId must be a Media object id (med_<ULID> or legacy:<app>:<kind>:<id>)');
+ if (typeof describe !== 'function') fail(503, 'media.unavailable', 'Attachments are checked against OpenVibe.Media, which is not configured here: nothing was attached');
+ let obj;
+ try { obj = await describe(id); } catch (err) {
+ fail(503, 'media.unavailable', `Attachments are checked against OpenVibe.Media, which could not answer (${err && err.message}): nothing was attached`);
+ }
+ if (obj && obj.exists && !['public', 'unlisted', 'private'].includes(obj.visibility)) fail(503, 'media.unavailable', 'OpenVibe.Media did not say who may read that object: nothing was attached');
+ const readable = !!(obj && obj.exists && (obj.visibility !== 'private' || (who && obj.owner === who)));
+ if (!readable) fail(422, 'media.not_readable', 'No OpenVibe.Media object you can read has that id');
+ if (obj.visibility === 'private') fail(409, 'media.private', 'That object is private in OpenVibe.Media. Every reader of a wiki page sees its media, so make the object unlisted or public in Media first');
+ if (obj.status && obj.status !== 'ready') fail(409, 'media.not_ready', `That object is ${obj.status} in OpenVibe.Media, not ready yet`);
+ return tx(() => {
+ const att = wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: id, alt, caption, role }));
+ q.insertOrigin.run(att.id, page.id, id, actorId(actor), obj.owner || null, obj.visibility, now());
+ attachments.markAvailable(id);
+ return svc.attachmentsOf(page.id).find((a) => a.id === att.id);
+ });
+ },
+
+ /** A page's attachments, each with who attached it (null for rows older than the record). */
+ attachmentsOf(pageId) {
+ const origins = new Map(q.originsOf.all(pageId).map((o) => [o.attachment_id, o]));
+ return attachments.list(pageId).map((a) => {
+ const o = origins.get(a.id);
+ return { ...a, attachedBy: o ? o.attached_by : null, mediaVisibilityAtAttach: o ? o.media_visibility : null };
+ });
},
},
detachMedia(pageId, attachmentId, actor) {
detachMedia(pageId, attachmentId, actor) {
@@ -15,6 +15,9 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
@@ -15,6 +15,9 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
(async () => {
(async () => {
const calls = [];
const calls = [];
let sourcesUp = true;
let sourcesUp = true;
+ // Every object is ready when it is attached; later B goes missing, C is deleted and Media stops answering for D.
+ let mediaLater = false;
+ const mediaState = (id) => (!mediaLater || id === MED('A') ? 'ready' : { [MED('B')]: 'missing', [MED('C')]: 'deleted', [MED('D')]: 'down' }[id] || 'missing');
const thread = { id: 7, visibility: 'public', comment_count: 1 };
const thread = { id: 7, visibility: 'public', comment_count: 1 };
const comments = [{ id: 1, thread_id: 7, display_name: 'Bea', message: 'Nice page <b>really</b>', created_at: '2026-09-21T10:00:00.000Z', origin: 'user', deleted: false }];
const comments = [{ id: 1, thread_id: 7, display_name: 'Bea', message: 'Nice page <b>really</b>', created_at: '2026-09-21T10:00:00.000Z', origin: 'user', deleted: false }];
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
@@ -24,10 +27,10 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
@@ -24,10 +27,10 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
calls.push({ url: u.pathname, method: init.method || 'GET', headers, body: init.body ? String(init.body) : null, host: u.host });
calls.push({ url: u.pathname, method: init.method || 'GET', headers, body: init.body ? String(init.body) : null, host: u.host });
if (u.host === 'media.internal') {
if (u.host === 'media.internal') {
const id = u.pathname.split('/').pop();
const id = u.pathname.split('/').pop();
- if (id === MED('A')) return json(200, { id, lifecycle_status: 'ready' });
- if (id === MED('B')) return json(404, { code: 'media.object.not_found' });
- if (id === MED('C')) return json(200, { id, lifecycle_status: 'deleted' });
- throw new Error('ECONNREFUSED');
+ const state = mediaState(id);
+ if (state === 'down') throw new Error('ECONNREFUSED');
+ if (state === 'missing') return json(404, { code: 'media.object.not_found' });
+ return json(200, { id, visibility: 'public', owner: { subject: me }, lifecycle_status: state });
}
}
if (u.host === 'sources.internal') {
if (u.host === 'sources.internal') {
if (!sourcesUp) throw new Error('ECONNREFUSED');
if (!sourcesUp) throw new Error('ECONNREFUSED');
@@ -84,6 +87,7 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
@@ -84,6 +87,7 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
}
}
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media`, { token: tok, body: { media_id: 'https://evil.example/x.png' } });
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media`, { token: tok, body: { media_id: 'https://evil.example/x.png' } });
assert.strictEqual(r.status, 400, 'only Media object ids, never URLs');
assert.strictEqual(r.status, 400, 'only Media object ids, never URLs');
+ mediaLater = true;
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media/verify`, { token: tok, body: {} });
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media/verify`, { token: tok, body: {} });
const outcome = Object.fromEntries(r.json.results.map((x) => [x.mediaId, x.outcome + (x.reason ? `:${x.reason}` : '')]));
const outcome = Object.fromEntries(r.json.results.map((x) => [x.mediaId, x.outcome + (x.reason ? `:${x.reason}` : '')]));
assert.deepStrictEqual(outcome, { [MED('A')]: 'available', [MED('B')]: 'broken:not_found', [MED('C')]: 'broken:deleted', [MED('D')]: 'check_failed' });
assert.deepStrictEqual(outcome, { [MED('A')]: 'available', [MED('B')]: 'broken:not_found', [MED('C')]: 'broken:deleted', [MED('D')]: 'check_failed' });
@@ -93,9 +97,9 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
@@ -93,9 +97,9 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
assert.ok(html.includes('This media is no longer available.'));
assert.ok(html.includes('This media is no longer available.'));
assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('B')}"`), 'no image for a broken object');
assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('B')}"`), 'no image for a broken object');
assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('C')}"`));
assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('C')}"`));
- assert.ok(html.includes(`data-media-id="${MED('D')}" data-state="unverified"`), 'an outage leaves the state alone');
+ assert.ok(html.includes(`data-media-id="${MED('D')}" data-state="available"`), 'an outage leaves the state alone');
const pj = (await H.req(h, 'GET', '/w/mt/cited.json')).json;
const pj = (await H.req(h, 'GET', '/w/mt/cited.json')).json;
- assert.deepStrictEqual(pj.attachments.map((a) => a.state), ['available', 'broken', 'broken', 'unverified']);
+ assert.deepStrictEqual(pj.attachments.map((a) => a.state), ['available', 'broken', 'broken', 'available']);
// Community: the thread is resolved once and stored as a reference; comments are Community's.
// Community: the thread is resolved once and stored as a reference; comments are Community's.
r = await H.req(h, 'GET', '/w/mt/cited');
r = await H.req(h, 'GET', '/w/mt/cited');
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+/**
+ * Media permissions survive the author/editor handoff (the model is documented at attachMedia in
+ * server/wiki/service.js):
+ * author A attaches an object A can read → editor B revises, reverts and publishes (the attachment
+ * stays, B needs no rights on it) → B cannot attach an object only A can read (the same answer as
+ * an unknown id), and not even A can attach a private one → Media deletes one object and makes the
+ * other private → the public page shows the explicit broken / withheld state, never the image, and
+ * the image comes back when Media shares the object again.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+
+const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
+
+(async () => {
+ const A = H.subject();
+ const B = H.subject();
+ const C = H.subject();
+ // Media's view of the namespace; tests change it as Media would.
+ const objects = new Map([
+ [MED(1), { visibility: 'public', owner: A, lifecycle_status: 'ready' }], // A's picture
+ [MED(2), { visibility: 'unlisted', owner: C, lifecycle_status: 'ready' }], // someone else's, shared by link
+ [MED(3), { visibility: 'private', owner: A, lifecycle_status: 'ready' }], // only A can read it
+ [MED(4), { visibility: 'public', owner: A, lifecycle_status: 'pending' }], // upload not finished
+ ]);
+ let mediaUp = true;
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+ const fetchStub = async (url) => {
+ const u = new URL(url);
+ if (u.host !== 'media.internal') return json(404, { error: 'stub: no route' });
+ if (!mediaUp) throw new Error('ECONNREFUSED');
+ const id = decodeURIComponent(u.pathname.split('/').pop());
+ const o = objects.get(id);
+ if (!o) return json(404, { code: 'media.object.not_found' });
+ return json(200, { id, visibility: o.visibility, owner: { subject: o.owner, app: 'wiki', user_id: null }, lifecycle_status: o.lifecycle_status });
+ };
+ const h = await H.boot({ fetch: fetchStub, env: { OV_MEDIA_INTERNAL_URL: 'http://media.internal' } });
+ const tokA = H.userToken({ subject: A });
+ const tokB = H.userToken({ subject: B });
+ const attach = (token, pageId, mediaId, extra = {}) => H.req(h, 'POST', `/api/v1/pages/${pageId}/media`, { token, body: { media_id: mediaId, alt: `alt ${mediaId.slice(-3)}` }, ...extra });
+ const imgOf = (id) => `<img src="https://openvibe.media/o/${id}"`;
+ try {
+ // A's space; B is an editor.
+ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tokA, body: { name: 'Handoff', slug: 'handoff' } });
+ assert.strictEqual(r.status, 201);
+ assert.strictEqual((await H.req(h, 'PUT', `/api/v1/spaces/handoff/roles/${B}`, { token: tokA, body: { role: 'editor' } })).status, 200);
+ r = await H.req(h, 'POST', '/api/v1/spaces/handoff/pages', { token: tokA, body: { title: 'Gallery', body: H.LONG } });
+ const pageId = r.json.page.id;
+ await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tokA, body: {} });
+
+ // 1. Author A attaches an object A owns and one shared by link; both are checked as A.
+ r = await attach(tokA, pageId, MED(1));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.json.attachment.state, 'available');
+ assert.strictEqual(r.json.attachment.attached_by, A);
+ assert.strictEqual((await attach(tokA, pageId, MED(2))).status, 201, 'unlisted objects are readable by anyone with the id');
+ const origins = h.db.prepare('SELECT media_id, attached_by, media_owner, media_visibility FROM wiki_attachment_origins WHERE page_id = ? ORDER BY attachment_id').all(pageId);
+ assert.deepStrictEqual(origins, [
+ { media_id: MED(1), attached_by: A, media_owner: A, media_visibility: 'public' },
+ { media_id: MED(2), attached_by: A, media_owner: C, media_visibility: 'unlisted' },
+ ]);
+ assert.throws(() => h.db.prepare('UPDATE wiki_attachment_origins SET attached_by = ?').run(B), /immutable/);
+ r = await attach(tokA, pageId, MED(4));
+ assert.strictEqual(r.status, 409);
+ assert.strictEqual(r.json.code, 'media.not_ready');
+
+ // 2. Editor B revises, publishes and reverts: the attachments stay, nothing is re-attached.
+ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/revisions`, { token: tokB, body: { expected_revision: 1, body: `${H.LONG} Edited by B.` } });
+ assert.strictEqual(r.status, 201, r.text);
+ await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tokB, body: {} });
+ let page = await H.req(h, 'GET', '/w/handoff/gallery');
+ assert.ok(page.text.includes('Edited by B.'));
+ assert.ok(page.text.includes(imgOf(MED(1))) && page.text.includes(imgOf(MED(2))), 'the attachments carry over to B\'s revision');
+ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/revert`, { token: tokB, body: { to_revision: 1, expected_revision: 2 } });
+ assert.strictEqual(r.status, 201, r.text);
+ r = await H.req(h, 'GET', `/api/v1/pages/${pageId}`, { token: tokB });
+ assert.deepStrictEqual(r.json.attachments.map((a) => [a.media_id, a.attached_by, a.state]), [[MED(1), A, 'available'], [MED(2), A, 'available']]);
+ assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2);
+
+ // 3. B cannot attach an object only A can read — the same answer as an id that does not exist.
+ const refused = await attach(tokB, pageId, MED(3));
+ const unknown = await attach(tokB, pageId, MED(999));
+ assert.strictEqual(refused.status, 422);
+ assert.strictEqual(refused.json.code, 'media.not_readable');
+ assert.deepStrictEqual([unknown.status, unknown.json.code, unknown.json.detail], [refused.status, refused.json.code, refused.json.detail], 'a private id is not confirmed to exist');
+ // …nor through a service acting for B, nor through the page settings form.
+ const svcTok = H.serviceToken({ client: 'ai', cap: ['wiki.page.create'] });
+ r = await attach(svcTok, pageId, MED(3), { headers: { 'X-OV-Subject': B } });
+ assert.strictEqual(r.status, 422);
+ assert.strictEqual(r.json.code, 'media.not_readable');
+ r = await H.req(h, 'POST', '/w/handoff/gallery/settings', { cookie: H.cookieFor(tokB), form: { op: 'attach', media_id: MED(3) } });
+ assert.strictEqual(r.status, 422);
+ assert.ok(r.text.includes('No OpenVibe.Media object you can read has that id'));
+ // Its owner can read it, but a page shows media to every reader: private objects are never attached.
+ r = await attach(tokA, pageId, MED(3));
+ assert.strictEqual(r.status, 409);
+ assert.strictEqual(r.json.code, 'media.private');
+ // No person, no attachment (a service acting as itself has no rights in the space); a Media
+ // outage attaches nothing unchecked.
+ r = await attach(H.serviceToken({ client: 'ai', cap: ['wiki.page.create'] }), pageId, MED(1));
+ assert.strictEqual(r.status, 403);
+ await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'service', service: 'svc:ai', subject: null }, { describe: h.platform.media.describe }), (e) => e.status === 403);
+ mediaUp = false;
+ r = await attach(tokA, pageId, MED(1));
+ assert.strictEqual(r.status, 503);
+ mediaUp = true;
+ await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'user', subject: A }, {}), (e) => e.status === 503, 'no Media configured: nothing is attached');
+ assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2, 'nothing refused was attached');
+
+ // 4. Media deletes one object and makes the other private: after the check, the public page
+ // shows the explicit states and no image.
+ objects.get(MED(1)).visibility = 'private';
+ objects.get(MED(2)).lifecycle_status = 'deleted';
+ r = await H.req(h, 'POST', '/w/handoff/gallery/settings', { cookie: H.cookieFor(tokB), form: { op: 'verify' } });
+ assert.strictEqual(r.status, 303);
+ assert.ok(decodeURIComponent(r.headers.get('location')).includes('0 available, 2 broken'));
+ page = await H.req(h, 'GET', '/w/handoff/gallery');
+ assert.strictEqual(page.status, 200);
+ assert.ok(!page.text.includes(imgOf(MED(1))) && !page.text.includes(imgOf(MED(2))), 'no image for a withheld or deleted object');
+ assert.ok(page.text.includes('This media is no longer shared publicly in OpenVibe.Media, so it is not shown here.'));
+ assert.ok(page.text.includes('This media was deleted from OpenVibe.Media and is no longer available.'));
+ const pj = (await H.req(h, 'GET', '/w/handoff/gallery.json')).json;
+ assert.deepStrictEqual(pj.attachments.map((a) => [a.state, a.broken_reason]), [['broken', 'forbidden'], ['broken', 'deleted']]);
+
+ // 5. Shared again in Media: the next check shows it again.
+ objects.get(MED(1)).visibility = 'public';
+ await H.req(h, 'POST', `/api/v1/pages/${pageId}/media/verify`, { token: tokA, body: {} });
+ page = await H.req(h, 'GET', '/w/handoff/gallery');
+ assert.ok(page.text.includes(imgOf(MED(1))));
+ assert.ok(!page.text.includes(imgOf(MED(2))));
+ console.log('media handoff ok');
+ } finally {
+ await h.stop();
+ }
+})().catch((err) => { console.error(err); process.exit(1); });