openvibers/openvibe.wiki Commits
Threat review fixes: outbound links in community spaces are rel="nofollow ugc noopener" (Markdown links, bare URLs, citations, infobox URLs; official spaces unchanged); a page summary is text of at most 300 characters on every write path (any JSON value of any size was stored and put in the meta description); AI proposals come only from first-party svc: principals (a developer app or module holding wiki.revision.propose could file draft pages into any space, private ones included); GET /search and revision diffs (SSR and API) are rate-limited per address (60/min, 30/min). test/threat-review.test.js; test helpers can boot with rate limits on
Changes
@@@ -76,6 +76,9 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra// Imports are the heaviest writes (up to 200 pages in one transaction): a few per hour.app.post(['/api/v1/spaces/:space/import', '/s/:space/import'], limiter(60 * 60000, 20));++ // A word diff of two very different 200,000-character revisions costs a few hundred ms of CPU++ // and tens of MB (openvibe-publishing/diff caps the search at 4000 edits): per address, not per crawl.++ app.get(['/w/:space/:slug/diff/:a/:b', '/api/v1/pages/:id/diff'], limiter(60000, 30));app.use('/api/', limiter(60000, 300));app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));@@@ -87,6 +90,8 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, raapp.use(createMachine({ svc, config }));app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));++ // Search scans the text of every published page (LIKE): a person's pace, not a crawler's.++ app.get('/search', limiter(60000, 60));const pages = createPages({ svc, viewers, platform, config, log });app.use(pages.router);@@@ -14,6 +14,9 @@ const VIS_LABEL = { public: 'Public', members: 'Members (any signed-in OpenVibefunction wpath(space, page) { return `/w/${e(space.slug)}/${e(page.slug)}`; }++/** rel for outbound links (citations, infobox URLs): community spaces add ugc (see service.linkRel). */++function outRel(space) { return space && space.kind === 'official' ? 'noopener nofollow' : 'nofollow ugc noopener'; }++function notice(kind, text) { return html`<p class="wk-notice wk-${kind}" role="status">${text}</p>`; }function crumbs(items) { return raw(ssr.breadcrumbsHtml(items)); }@@@ -51,11 +54,11 @@ ${proposals && proposals.length ? html`<p>${notice('info', `${proposals.length}</section>`;}--function infoboxHtml(entries, resolve) {++function infoboxHtml(entries, resolve, rel = 'nofollow ugc noopener') {if (!entries.length) return '';const cell = (x) => {switch (x.type) {-- case 'url': return html`<a href="${x.value}" rel="noopener nofollow">${x.value.replace(/^https?:\/\//, '')}</a>`;++ case 'url': return html`<a href="${x.value}" rel="${rel}">${x.value.replace(/^https?:\/\//, '')}</a>`;case 'date': return raw(ssr.timeTag(x.value, { label: String(x.value).slice(0, 10) }) || ssr.escapeHtml(x.value));case 'boolean': return x.value ? 'Yes' : 'No';case 'number': return String(x.value);@@@ -67,10 +70,10 @@ function infoboxHtml(entries, resolve) {return html`<aside class="wk-infobox" aria-label="Infobox"><table><tbody>${entries.map((x) => html`<tr data-key="${x.key}" data-type="${x.type}"><th scope="row">${x.label}</th><td>${cell(x)}</td></tr>`)}</tbody></table></aside>`;}--function citationsHtml(cites, inspectHref) {++function citationsHtml(cites, inspectHref, rel = 'nofollow ugc noopener') {const inspect = inspectHref ? html`<p class="wk-muted"><a href="${inspectHref}">Inspect these sources</a> (retrieval times, where each was first cited, what changed between revisions)</p>` : '';if (!cites.length) return html`<section class="wk-sources"><h2>Sources</h2><p class="wk-muted">This revision cites no sources.</p>${inspect}</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>${inspect}</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="${rel}">${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>${inspect}</section>`;}const CHANGE_LABEL = { kept: 'kept from revision', new: 'new in this revision', restored: 'restored from an older revision' };@@@ -80,8 +83,9 @@ function sourcesPage({ space, page, view: v, citations: cites, previous, droppedconst base = wpath(space, page);const n = v.revision.number;const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };++ const rel = outRel(space);const row = (c) => html`<tr id="cite-${String(c.id)}">--<td>${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a><br><span class="wk-muted">${hostOf(c.url) || ''}</span>` : html`${c.title || 'Sources item'}`}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</td>++<td>${c.url ? html`<a href="${c.url}" rel="${rel}">${c.title || c.url}</a><br><span class="wk-muted">${hostOf(c.url) || ''}</span>` : html`${c.title || 'Sources item'}`}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</td><td>${c.sourceItemId ? html`OpenVibe.Sources item <code>${c.sourceItemId}</code><br><span class="wk-muted">URL, title, retrieval time and license from the item's provenance</span>` : 'URL given by the editor'}</td><td>${c.retrievedAt ? t(c.retrievedAt) : html`<span class="wk-muted">unknown</span>`}</td><td>${c.licenseNote || html`<span class="wk-muted">none recorded</span>`}</td>@@@ -92,7 +96,7 @@ function sourcesPage({ space, page, view: v, citations: cites, previous, dropped<p>${v.isPublishedRevision ? 'This is the published revision.' : html`This is not the published revision${page.published_revision && page.state === 'published' ? html` (<a href="${base}/sources">see the published one</a>)` : ''}.`} <a href="${base}?rev=${String(n)}">Read revision ${String(n)}</a> · <a href="${base}/history">history</a></p><p class="wk-muted">Every citation belongs to the revision that used it and is never edited. A later revision keeps a source by carrying it forward; a dropped source stays on the older revision.</p>${cites.length ? html`<table class="wk-citations"><thead><tr><th scope="col">Source</th><th scope="col">Kind</th><th scope="col">Retrieved</th><th scope="col">License</th><th scope="col">First cited in</th><th scope="col">In this revision</th></tr></thead><tbody>${cites.map(row)}</tbody></table>` : html`<p>Revision ${String(n)} cites no sources.</p>`}--${previous ? html`<h2>Dropped since revision ${String(previous)}</h2>${dropped.length ? html`<ul>${dropped.map((c) => html`<li>${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <code>${c.sourceItemId}</code>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : ''}</li>`)}</ul>` : html`<p class="wk-muted">None.</p>`}` : ''}++${previous ? html`<h2>Dropped since revision ${String(previous)}</h2>${dropped.length ? html`<ul>${dropped.map((c) => html`<li>${c.url ? html`<a href="${c.url}" rel="${rel}">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <code>${c.sourceItemId}</code>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : ''}</li>`)}</ul>` : html`<p class="wk-muted">None.</p>`}` : ''}<h2>Other revisions</h2><ul class="wk-revisions">${revisions.map((r) => html`<li>${r.number === n ? html`<strong>Revision ${String(r.number)}</strong>` : html`<a href="${base}/sources?rev=${String(r.number)}">Revision ${String(r.number)}</a>`}${r.published ? ' (published)' : ''} · ${t(r.createdAt)} · ${String(r.citationCount)} source(s)</li>`)}</ul></section>`;@@@ -142,10 +146,10 @@ function articlePage(v, { html: contentHtml, discussion, actor, mediaUrl, resolv<article class="wk-article" data-page-id="${page.id}" data-revision="${String(rev.number)}"><header><h1>${rev.fields.title || page.title}</h1><nav class="wk-actions" aria-label="Page actions">${actions.map((a, i) => html`${i ? ' · ' : ''}${a}`)}</nav></header>${banners}--${raw(infoboxHtml(v.infobox, resolve))}++${raw(infoboxHtml(v.infobox, resolve, outRel(space)))}<div class="wk-content">${raw(contentHtml)}</div>${raw(mediaHtml(v.attachments, mediaUrl))}--${raw(citationsHtml(v.citations, `${base}/sources${v.isPublishedRevision ? '' : `?rev=${rev.number}`}`))}++${raw(citationsHtml(v.citations, `${base}/sources${v.isPublishedRevision ? '' : `?rev=${rev.number}`}`, outRel(space)))}${v.children.length ? html`<section><h2>Subpages</h2><ul>${v.children.map((c) => html`<li><a href="${wpath(space, c)}">${c.title}</a></li>`)}</ul></section>` : ''}${v.backlinks.length ? html`<section><h2>What links here</h2><ul>${v.backlinks.map((b) => html`<li><a href="${wpath(b.space, b.page)}">${b.page.title}</a>${b.space.id !== space.id ? html` <span class="wk-muted">(${b.space.name})</span>` : ''}</li>`)}</ul></section>` : ''}<footer class="wk-meta"><p>Revision ${String(rev.number)}${rev.kind === 'revert' ? html` (a revert to revision ${String(rev.revertedTo)})` : ''}, saved ${t(rev.createdAt)}${page.published_at ? html` · first published ${t(page.published_at)}` : ''} · <a href="${base}/history">history</a> · <a href="${base}.json">JSON</a></p>@@@ -29,6 +29,7 @@ class WikiError extends Error {const SPACE_KINDS = ['official', 'user'];const MAX_BODY = 200000;const MAX_TITLE = 200;++const MAX_SUMMARY = 300;const GATE_VIS = { public: 'public', members: 'gated', private: 'private' };const PAGE_ID_RE = /^pg_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -205,6 +206,15 @@ function createWikiService({ db, stores, outbox, config, community = null, now =return t;}++ /** A summary is one short sentence of text (it becomes the meta description, feeds and Search). */++ function checkSummary(summary) {++ if (summary == null || summary === '') return null;++ if (typeof summary !== 'string') fail(422, 'page.invalid_summary', 'summary must be text');++ const t = summary.replace(/\s+/g, ' ').trim();++ if (t.length > MAX_SUMMARY) fail(422, 'page.invalid_summary', `A summary is at most ${MAX_SUMMARY} characters`);++ return t || null;++ }++function checkBody(body) {if (typeof body !== 'string') fail(422, 'page.invalid_body', 'body must be Markdown text');if (body.length > MAX_BODY) fail(413, 'page.body_too_large', `A page is at most ${MAX_BODY} characters`);@@@ -401,6 +411,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {const cleanTitle = checkTitle(title);const slug = wrapContentError(() => content.pageSlug(cleanTitle));++ const cleanSummary = checkSummary(summary);const text = checkBody(body == null ? '' : body);const box = wrapContentError(() => content.normalizeInfobox(infobox));const vis = checkVisibility(visibility, 'public');@@@ -413,7 +424,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });redirects.release(`/w/${space.slug}/${slug}`);const { revision } = wrapContentError(() => revisions.create({-- entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: summary || null, infobox: box },++ entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: cleanSummary, infobox: box },meta: { authorship: rec }, author: actorId(actor), message,}));const page = q.pageById.get(id);@@@ -675,7 +686,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =if (!head) fail(404, 'revision.not_found', 'This page has no revision');const text = body == null ? head.content : checkBody(body);const box = infobox === undefined ? (head.fields.infobox || []) : wrapContentError(() => content.normalizeInfobox(infobox));-- const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : (summary || null), infobox: box };++ const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : checkSummary(summary), infobox: box };const { revision, created } = wrapContentError(() => revisions.create({entityId: page.id, expectedRevision, content: text, fields, meta: { authorship: rec }, author: actorId(actor), message,}));@@@ -904,10 +915,17 @@ function createWikiService({ db, stores, outbox, config, community = null, now =return { href: null, exists: false };},++ /**++ * Markdown → safe HTML. Outbound links in community (user) spaces carry rel="nofollow ugc":++ * nobody vouches for them, so they pass no ranking (link spam gains nothing). Official spaces++ * are edited by staff, who vouch for their links.++ */renderRevision(space, rev, actor) {-- return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor) });++ return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor), rel: svc.linkRel(space) });},++ linkRel(space) { return space && space.kind === 'official' ? 'noopener' : 'nofollow ugc noopener'; },++history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {const props = new Map(q.proposalsOfPage.all(page.id).map((p) => [p.revision, p]));const space = q.spaceById.get(page.space_id);@@@ -1171,9 +1189,13 @@ function createWikiService({ db, stores, outbox, config, community = null, now =* pageId null + title = propose a new page (created as a draft).*/propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {-- if (!actor || (actor.kind !== 'service' && actor.kind !== 'system')) fail(403, 'proposal.service_only', 'AI proposals come from a service principal');++ // A proposal may target any space, private ones included, and never needs a person: only++ // first-party services (svc:…, i.e. OpenVibe.AI) file them, never a developer app or module.++ const firstParty = actor && actor.kind === 'service' && /^svc:/.test(String(actor.service)) && (!actor.claims || actor.claims.actor_type === 'service');++ if (!actor || (actor.kind !== 'system' && !firstParty)) fail(403, 'proposal.service_only', 'AI proposals come from a first-party service principal');if (!workflow || !workflow.id || !workflow.runId) fail(400, 'authorship.workflow_required', 'An AI proposal names its OpenVibe.AI workflow (workflow.id) and run (workflow.run_id)');const rec = wrapContentError(() => authorship.record({ mode: 'ai', workflow, stubProvider: !!stubProvider }));++ const cleanSummary = checkSummary(summary);const text = checkBody(body == null ? '' : body);const box = wrapContentError(() => content.normalizeInfobox(infobox));return tx(() => {@@@ -1194,7 +1216,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =if (expectedRevision != null && Number(expectedRevision) !== base) fail(412, 'revision.conflict', `Revision conflict: expected ${expectedRevision}, current is ${base}`, { expected: Number(expectedRevision), current: base });}const head = base ? revisions.head(page.id) : null;-- const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary, infobox: box };++ const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary: cleanSummary, infobox: box };const { revision } = wrapContentError(() => revisions.create({entityId: page.id, expectedRevision: base, content: text, fields, meta: { authorship: rec }, author: actorId(actor),message: note ? `AI proposal: ${String(note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,@@@ -51,7 +51,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');let page2 = stripScripts(r.text);assert.ok(page2.includes('<h1>Sources of Rye, revision 2</h1>'));assert.ok(page2.includes('This is the published revision.'));-- assert.ok(page2.includes('href="https://flour.example/rye" rel="noopener nofollow">Rye flour</a>'));++ assert.ok(page2.includes('href="https://flour.example/rye" rel="nofollow ugc noopener">Rye flour</a>'), 'a community space\'s links pass no ranking');assert.ok(page2.includes('<time datetime="2026-09-01T00:00:00.000Z">'), 'retrieval time of the kept source');assert.ok(page2.includes('<time datetime="2026-09-10T12:00:00.000Z">'), 'retrieval time of the new source');assert.ok(page2.includes('CC BY 4.0') && page2.includes('Rye has less gluten.'));@@@ -34,7 +34,7 @@ const quiet = { log() {}, warn() {}, error() {} };* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls* (Community, Sources, Media, Events, Network token endpoint).*/--async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens } = {}) {++async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false } = {}) {const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));const config = load({NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',@@@ -43,7 +43,7 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,...env,});const h = await start({-- config, publicKey, log: quiet, listen: true, workers, rateLimits: false, now,++ config, publicKey, log: quiet, listen: true, workers, rateLimits, now,fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },});nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * Regressions for the gaps fixed in the threat review (docs/threat-review.md):++ * - outbound links in community (user) spaces carry rel="nofollow ugc" — Markdown links, bare++ * URLs, citations and infobox URLs — so link spam passes no ranking; official spaces keep theirs;++ * - a page summary is short text (it becomes the meta description, feeds and the Search document):++ * anything else is refused instead of stored;++ * - AI proposals (which reach any space without a person) come only from first-party services;++ * - the HTML search (a LIKE scan over every published page) and revision diffs (CPU and memory++ * heavy for large, very different revisions) are rate-limited per address.++ */++const assert = require('assert');++const H = require('./helpers');++const { SYSTEM } = require('../server/wiki/seed');++++(async () => {++ const h = await H.boot();++ const me = H.subject();++ const tok = H.userToken({ subject: me });++ const cite = [{ url: 'https://cited.example/a', title: 'Cited', retrievedAt: '2026-09-01' }];++ const body = `[a link](https://spam.example/one) and https://spam.example/two ${H.LONG}`;++ const infobox = [{ label: 'Site', type: 'url', value: 'https://infobox.example/' }];++ const anchors = (html, host) => html.match(new RegExp(`<a href="https://${host.replace('.', '\\.')}[^"]*"[^>]*>`, 'g')) || [];++ try {++ // Community space: every outbound link is nofollow ugc.++ const user = h.svc.createSpace({ name: 'Community', slug: 'community' }, { kind: 'user', subject: me });++ const up = h.svc.createPage(user.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me }).page;++ h.svc.publish(up.id, {}, { kind: 'user', subject: me });++ let html = (await H.req(h, 'GET', '/w/community/links')).text;++ const userLinks = [...anchors(html, 'spam.example'), ...anchors(html, 'cited.example'), ...anchors(html, 'infobox.example')];++ assert.strictEqual(userLinks.length, 4, userLinks.join('\n'));++ for (const a of userLinks) assert.ok(/rel="nofollow ugc noopener"/.test(a), a);++ html = (await H.req(h, 'GET', '/w/community/links/sources')).text;++ assert.ok(anchors(html, 'cited.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));++ // The editor's preview renders the same way.++ html = (await H.req(h, 'POST', '/s/community/new', { cookie: H.cookieFor(tok), form: { op: 'preview', title: 'X', body } })).text;++ assert.ok(anchors(html, 'spam.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));++++ // Official space: staff vouch for their links.++ const off = h.svc.createSpace({ name: 'Official', slug: 'official', kind: 'official' }, SYSTEM);++ const op = h.svc.createPage(off.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me, staff: true }).page;++ h.svc.publish(op.id, {}, { kind: 'user', subject: me, staff: true });++ html = (await H.req(h, 'GET', '/w/official/links')).text;++ assert.ok(anchors(html, 'spam.example').every((a) => /rel="noopener"/.test(a)) && anchors(html, 'spam.example').length === 2);++ assert.ok(anchors(html, 'cited.example').every((a) => /rel="noopener nofollow"/.test(a)));++++ // Summaries: short text only, through every write path.++ const bad = [{ a: 1 }, ['x'], 42, 's'.repeat(301)];++ for (const summary of bad) {++ const r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: `S ${String(summary).length}`, body: H.LONG, summary } });++ assert.strictEqual(r.status, 422, JSON.stringify(summary).slice(0, 40));++ assert.strictEqual(r.json.code, 'page.invalid_summary');++ }++ let r = await H.req(h, 'POST', `/api/v1/pages/${up.id}/revisions`, { token: tok, body: { expected_revision: 1, summary: 'x'.repeat(5000) } });++ assert.strictEqual(r.status, 422);++ assert.throws(() => h.svc.propose({ space: 'community', title: 'Proposed', body: H.LONG, summary: { html: '<b>' }, workflow: { id: 'wiki.generate_page', runId: 'run_1' } }, { kind: 'service', service: 'svc:ai' }), (e) => e.status === 422 && e.code === 'page.invalid_summary');++ r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: 'Summarised', body: H.LONG, summary: ' One short sentence. ' } });++ assert.strictEqual(r.status, 201);++ assert.strictEqual(r.json.revision.summary, 'One short sentence.');++++ // AI proposals reach any space without a person, so only first-party services file them:++ // a developer app or module holding the capability is refused.++ const proposal = { space: 'community', title: 'Planted', body: H.LONG, workflow: { id: 'wiki.generate_page', run_id: 'run_2' } };++ for (const [sub, actorType] of [['app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app'], ['mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'mod']]) {++ r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ sub, actorType, cap: ['wiki.revision.propose'], extra: { on_behalf_of: me } }), body: proposal });++ assert.strictEqual(r.status, 403, `${actorType}: ${r.text}`);++ assert.strictEqual(r.json.code, 'proposal.service_only');++ }++ assert.strictEqual(h.svc.findPage(user.id, 'planted'), null);++ r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ client: 'ai', cap: ['wiki.revision.propose'] }), body: proposal });++ assert.strictEqual(r.status, 201, r.text);++ await h.stop();++++ // The HTML search and diffs are rate-limited per address (other pages are not).++ const limited = await H.boot({ rateLimits: true });++ try {++ for (let i = 0; i < 60; i++) assert.strictEqual((await H.req(limited, 'GET', `/search?q=w${i}`)).status, 200);++ assert.strictEqual((await H.req(limited, 'GET', '/search?q=again')).status, 429);++ assert.strictEqual((await H.req(limited, 'GET', '/')).status, 200);++ const owner = { kind: 'user', subject: H.subject() };++ const sp = limited.svc.createSpace({ name: 'Diffs', slug: 'diffs' }, owner);++ const pg = limited.svc.createPage(sp.id, { title: 'Long', body: 'a b c' }, owner).page;++ limited.svc.editPage(pg.id, { expectedRevision: 1, body: 'x y z' }, owner);++ limited.svc.publish(pg.id, {}, owner);++ for (let i = 0; i < 15; i++) {++ assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2')).status, 200);++ assert.strictEqual((await H.req(limited, 'GET', `/api/v1/pages/${pg.id}/diff?from=1&to=2`)).status, 200);++ }++ assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2?mode=line')).status, 429, 'SSR and API diffs share one budget');++ assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long')).status, 200);++ } finally {++ await limited.stop();++ }++ console.log('threat review regressions ok');++ } catch (err) {++ await h.stop().catch(() => {});++ throw err;++ }++})().catch((err) => { console.error(err); process.exit(1); });
@@ -76,6 +76,9 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
@@ -76,6 +76,9 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
// Imports are the heaviest writes (up to 200 pages in one transaction): a few per hour.
// Imports are the heaviest writes (up to 200 pages in one transaction): a few per hour.
app.post(['/api/v1/spaces/:space/import', '/s/:space/import'], limiter(60 * 60000, 20));
app.post(['/api/v1/spaces/:space/import', '/s/:space/import'], limiter(60 * 60000, 20));
+ // A word diff of two very different 200,000-character revisions costs a few hundred ms of CPU
+ // and tens of MB (openvibe-publishing/diff caps the search at 4000 edits): per address, not per crawl.
+ app.get(['/w/:space/:slug/diff/:a/:b', '/api/v1/pages/:id/diff'], limiter(60000, 30));
app.use('/api/', limiter(60000, 300));
app.use('/api/', limiter(60000, 300));
app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));
app.use('/api/v1', createApi({ svc, viewers, platform, config, log }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
app.use('/api', (req, res) => require('openvibe-contracts').http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found' }));
@@ -87,6 +90,8 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
@@ -87,6 +90,8 @@ function createApp({ config, svc, viewers, platform, keys, db, log = console, ra
app.use(createMachine({ svc, config }));
app.use(createMachine({ svc, config }));
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
app.post(['/new-space', '/s/*', '/w/*', '/proposals/*'], limiter(10 * 60000, 120));
+ // Search scans the text of every published page (LIKE): a person's pace, not a crawler's.
+ app.get('/search', limiter(60000, 60));
const pages = createPages({ svc, viewers, platform, config, log });
const pages = createPages({ svc, viewers, platform, config, log });
app.use(pages.router);
app.use(pages.router);
@@ -14,6 +14,9 @@ const VIS_LABEL = { public: 'Public', members: 'Members (any signed-in OpenVibe
@@ -14,6 +14,9 @@ const VIS_LABEL = { public: 'Public', members: 'Members (any signed-in OpenVibe
function wpath(space, page) { return `/w/${e(space.slug)}/${e(page.slug)}`; }
function wpath(space, page) { return `/w/${e(space.slug)}/${e(page.slug)}`; }
+/** rel for outbound links (citations, infobox URLs): community spaces add ugc (see service.linkRel). */
+function outRel(space) { return space && space.kind === 'official' ? 'noopener nofollow' : 'nofollow ugc noopener'; }
+
function notice(kind, text) { return html`<p class="wk-notice wk-${kind}" role="status">${text}</p>`; }
function notice(kind, text) { return html`<p class="wk-notice wk-${kind}" role="status">${text}</p>`; }
function crumbs(items) { return raw(ssr.breadcrumbsHtml(items)); }
function crumbs(items) { return raw(ssr.breadcrumbsHtml(items)); }
@@ -51,11 +54,11 @@ ${proposals && proposals.length ? html`<p>${notice('info', `${proposals.length}
@@ -51,11 +54,11 @@ ${proposals && proposals.length ? html`<p>${notice('info', `${proposals.length}
</section>`;
</section>`;
}
}
-function infoboxHtml(entries, resolve) {
+function infoboxHtml(entries, resolve, rel = 'nofollow ugc noopener') {
if (!entries.length) return '';
if (!entries.length) return '';
const cell = (x) => {
const cell = (x) => {
switch (x.type) {
switch (x.type) {
- case 'url': return html`<a href="${x.value}" rel="noopener nofollow">${x.value.replace(/^https?:\/\//, '')}</a>`;
+ case 'url': return html`<a href="${x.value}" rel="${rel}">${x.value.replace(/^https?:\/\//, '')}</a>`;
case 'date': return raw(ssr.timeTag(x.value, { label: String(x.value).slice(0, 10) }) || ssr.escapeHtml(x.value));
case 'date': return raw(ssr.timeTag(x.value, { label: String(x.value).slice(0, 10) }) || ssr.escapeHtml(x.value));
case 'boolean': return x.value ? 'Yes' : 'No';
case 'boolean': return x.value ? 'Yes' : 'No';
case 'number': return String(x.value);
case 'number': return String(x.value);
@@ -67,10 +70,10 @@ function infoboxHtml(entries, resolve) {
@@ -67,10 +70,10 @@ function infoboxHtml(entries, resolve) {
return html`<aside class="wk-infobox" aria-label="Infobox"><table><tbody>${entries.map((x) => html`<tr data-key="${x.key}" data-type="${x.type}"><th scope="row">${x.label}</th><td>${cell(x)}</td></tr>`)}</tbody></table></aside>`;
return html`<aside class="wk-infobox" aria-label="Infobox"><table><tbody>${entries.map((x) => html`<tr data-key="${x.key}" data-type="${x.type}"><th scope="row">${x.label}</th><td>${cell(x)}</td></tr>`)}</tbody></table></aside>`;
}
}
-function citationsHtml(cites, inspectHref) {
+function citationsHtml(cites, inspectHref, rel = 'nofollow ugc noopener') {
const inspect = inspectHref ? html`<p class="wk-muted"><a href="${inspectHref}">Inspect these sources</a> (retrieval times, where each was first cited, what changed between revisions)</p>` : '';
const inspect = inspectHref ? html`<p class="wk-muted"><a href="${inspectHref}">Inspect these sources</a> (retrieval times, where each was first cited, what changed between revisions)</p>` : '';
if (!cites.length) return html`<section class="wk-sources"><h2>Sources</h2><p class="wk-muted">This revision cites no sources.</p>${inspect}</section>`;
if (!cites.length) return html`<section class="wk-sources"><h2>Sources</h2><p class="wk-muted">This revision cites no sources.</p>${inspect}</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>${inspect}</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="${rel}">${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>${inspect}</section>`;
}
}
const CHANGE_LABEL = { kept: 'kept from revision', new: 'new in this revision', restored: 'restored from an older revision' };
const CHANGE_LABEL = { kept: 'kept from revision', new: 'new in this revision', restored: 'restored from an older revision' };
@@ -80,8 +83,9 @@ function sourcesPage({ space, page, view: v, citations: cites, previous, dropped
@@ -80,8 +83,9 @@ function sourcesPage({ space, page, view: v, citations: cites, previous, dropped
const base = wpath(space, page);
const base = wpath(space, page);
const n = v.revision.number;
const n = v.revision.number;
const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };
const hostOf = (u) => { try { return new URL(u).host; } catch { return null; } };
+ const rel = outRel(space);
const row = (c) => html`<tr id="cite-${String(c.id)}">
const row = (c) => html`<tr id="cite-${String(c.id)}">
-<td>${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a><br><span class="wk-muted">${hostOf(c.url) || ''}</span>` : html`${c.title || 'Sources item'}`}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</td>
+<td>${c.url ? html`<a href="${c.url}" rel="${rel}">${c.title || c.url}</a><br><span class="wk-muted">${hostOf(c.url) || ''}</span>` : html`${c.title || 'Sources item'}`}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</td>
<td>${c.sourceItemId ? html`OpenVibe.Sources item <code>${c.sourceItemId}</code><br><span class="wk-muted">URL, title, retrieval time and license from the item's provenance</span>` : 'URL given by the editor'}</td>
<td>${c.sourceItemId ? html`OpenVibe.Sources item <code>${c.sourceItemId}</code><br><span class="wk-muted">URL, title, retrieval time and license from the item's provenance</span>` : 'URL given by the editor'}</td>
<td>${c.retrievedAt ? t(c.retrievedAt) : html`<span class="wk-muted">unknown</span>`}</td>
<td>${c.retrievedAt ? t(c.retrievedAt) : html`<span class="wk-muted">unknown</span>`}</td>
<td>${c.licenseNote || html`<span class="wk-muted">none recorded</span>`}</td>
<td>${c.licenseNote || html`<span class="wk-muted">none recorded</span>`}</td>
@@ -92,7 +96,7 @@ function sourcesPage({ space, page, view: v, citations: cites, previous, dropped
@@ -92,7 +96,7 @@ function sourcesPage({ space, page, view: v, citations: cites, previous, dropped
<p>${v.isPublishedRevision ? 'This is the published revision.' : html`This is not the published revision${page.published_revision && page.state === 'published' ? html` (<a href="${base}/sources">see the published one</a>)` : ''}.`} <a href="${base}?rev=${String(n)}">Read revision ${String(n)}</a> · <a href="${base}/history">history</a></p>
<p>${v.isPublishedRevision ? 'This is the published revision.' : html`This is not the published revision${page.published_revision && page.state === 'published' ? html` (<a href="${base}/sources">see the published one</a>)` : ''}.`} <a href="${base}?rev=${String(n)}">Read revision ${String(n)}</a> · <a href="${base}/history">history</a></p>
<p class="wk-muted">Every citation belongs to the revision that used it and is never edited. A later revision keeps a source by carrying it forward; a dropped source stays on the older revision.</p>
<p class="wk-muted">Every citation belongs to the revision that used it and is never edited. A later revision keeps a source by carrying it forward; a dropped source stays on the older revision.</p>
${cites.length ? html`<table class="wk-citations"><thead><tr><th scope="col">Source</th><th scope="col">Kind</th><th scope="col">Retrieved</th><th scope="col">License</th><th scope="col">First cited in</th><th scope="col">In this revision</th></tr></thead><tbody>${cites.map(row)}</tbody></table>` : html`<p>Revision ${String(n)} cites no sources.</p>`}
${cites.length ? html`<table class="wk-citations"><thead><tr><th scope="col">Source</th><th scope="col">Kind</th><th scope="col">Retrieved</th><th scope="col">License</th><th scope="col">First cited in</th><th scope="col">In this revision</th></tr></thead><tbody>${cites.map(row)}</tbody></table>` : html`<p>Revision ${String(n)} cites no sources.</p>`}
-${previous ? html`<h2>Dropped since revision ${String(previous)}</h2>${dropped.length ? html`<ul>${dropped.map((c) => html`<li>${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <code>${c.sourceItemId}</code>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : ''}</li>`)}</ul>` : html`<p class="wk-muted">None.</p>`}` : ''}
+${previous ? html`<h2>Dropped since revision ${String(previous)}</h2>${dropped.length ? html`<ul>${dropped.map((c) => html`<li>${c.url ? html`<a href="${c.url}" rel="${rel}">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <code>${c.sourceItemId}</code>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : ''}</li>`)}</ul>` : html`<p class="wk-muted">None.</p>`}` : ''}
<h2>Other revisions</h2>
<h2>Other revisions</h2>
<ul class="wk-revisions">${revisions.map((r) => html`<li>${r.number === n ? html`<strong>Revision ${String(r.number)}</strong>` : html`<a href="${base}/sources?rev=${String(r.number)}">Revision ${String(r.number)}</a>`}${r.published ? ' (published)' : ''} · ${t(r.createdAt)} · ${String(r.citationCount)} source(s)</li>`)}</ul>
<ul class="wk-revisions">${revisions.map((r) => html`<li>${r.number === n ? html`<strong>Revision ${String(r.number)}</strong>` : html`<a href="${base}/sources?rev=${String(r.number)}">Revision ${String(r.number)}</a>`}${r.published ? ' (published)' : ''} · ${t(r.createdAt)} · ${String(r.citationCount)} source(s)</li>`)}</ul>
</section>`;
</section>`;
@@ -142,10 +146,10 @@ function articlePage(v, { html: contentHtml, discussion, actor, mediaUrl, resolv
@@ -142,10 +146,10 @@ function articlePage(v, { html: contentHtml, discussion, actor, mediaUrl, resolv
<article class="wk-article" data-page-id="${page.id}" data-revision="${String(rev.number)}">
<article class="wk-article" data-page-id="${page.id}" data-revision="${String(rev.number)}">
<header><h1>${rev.fields.title || page.title}</h1><nav class="wk-actions" aria-label="Page actions">${actions.map((a, i) => html`${i ? ' · ' : ''}${a}`)}</nav></header>
<header><h1>${rev.fields.title || page.title}</h1><nav class="wk-actions" aria-label="Page actions">${actions.map((a, i) => html`${i ? ' · ' : ''}${a}`)}</nav></header>
${banners}
${banners}
-${raw(infoboxHtml(v.infobox, resolve))}
+${raw(infoboxHtml(v.infobox, resolve, outRel(space)))}
<div class="wk-content">${raw(contentHtml)}</div>
<div class="wk-content">${raw(contentHtml)}</div>
${raw(mediaHtml(v.attachments, mediaUrl))}
${raw(mediaHtml(v.attachments, mediaUrl))}
-${raw(citationsHtml(v.citations, `${base}/sources${v.isPublishedRevision ? '' : `?rev=${rev.number}`}`))}
+${raw(citationsHtml(v.citations, `${base}/sources${v.isPublishedRevision ? '' : `?rev=${rev.number}`}`, outRel(space)))}
${v.children.length ? html`<section><h2>Subpages</h2><ul>${v.children.map((c) => html`<li><a href="${wpath(space, c)}">${c.title}</a></li>`)}</ul></section>` : ''}
${v.children.length ? html`<section><h2>Subpages</h2><ul>${v.children.map((c) => html`<li><a href="${wpath(space, c)}">${c.title}</a></li>`)}</ul></section>` : ''}
${v.backlinks.length ? html`<section><h2>What links here</h2><ul>${v.backlinks.map((b) => html`<li><a href="${wpath(b.space, b.page)}">${b.page.title}</a>${b.space.id !== space.id ? html` <span class="wk-muted">(${b.space.name})</span>` : ''}</li>`)}</ul></section>` : ''}
${v.backlinks.length ? html`<section><h2>What links here</h2><ul>${v.backlinks.map((b) => html`<li><a href="${wpath(b.space, b.page)}">${b.page.title}</a>${b.space.id !== space.id ? html` <span class="wk-muted">(${b.space.name})</span>` : ''}</li>`)}</ul></section>` : ''}
<footer class="wk-meta"><p>Revision ${String(rev.number)}${rev.kind === 'revert' ? html` (a revert to revision ${String(rev.revertedTo)})` : ''}, saved ${t(rev.createdAt)}${page.published_at ? html` · first published ${t(page.published_at)}` : ''} · <a href="${base}/history">history</a> · <a href="${base}.json">JSON</a></p>
<footer class="wk-meta"><p>Revision ${String(rev.number)}${rev.kind === 'revert' ? html` (a revert to revision ${String(rev.revertedTo)})` : ''}, saved ${t(rev.createdAt)}${page.published_at ? html` · first published ${t(page.published_at)}` : ''} · <a href="${base}/history">history</a> · <a href="${base}.json">JSON</a></p>
@@ -29,6 +29,7 @@ class WikiError extends Error {
@@ -29,6 +29,7 @@ class WikiError extends Error {
const SPACE_KINDS = ['official', 'user'];
const SPACE_KINDS = ['official', 'user'];
const MAX_BODY = 200000;
const MAX_BODY = 200000;
const MAX_TITLE = 200;
const MAX_TITLE = 200;
+const MAX_SUMMARY = 300;
const GATE_VIS = { public: 'public', members: 'gated', private: 'private' };
const GATE_VIS = { public: 'public', members: 'gated', private: 'private' };
const PAGE_ID_RE = /^pg_[0-9A-HJKMNP-TV-Z]{26}$/;
const PAGE_ID_RE = /^pg_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -205,6 +206,15 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -205,6 +206,15 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
return t;
return t;
}
}
+ /** A summary is one short sentence of text (it becomes the meta description, feeds and Search). */
+ function checkSummary(summary) {
+ if (summary == null || summary === '') return null;
+ if (typeof summary !== 'string') fail(422, 'page.invalid_summary', 'summary must be text');
+ const t = summary.replace(/\s+/g, ' ').trim();
+ if (t.length > MAX_SUMMARY) fail(422, 'page.invalid_summary', `A summary is at most ${MAX_SUMMARY} characters`);
+ return t || null;
+ }
+
function checkBody(body) {
function checkBody(body) {
if (typeof body !== 'string') fail(422, 'page.invalid_body', 'body must be Markdown text');
if (typeof body !== 'string') fail(422, 'page.invalid_body', 'body must be Markdown text');
if (body.length > MAX_BODY) fail(413, 'page.body_too_large', `A page is at most ${MAX_BODY} characters`);
if (body.length > MAX_BODY) fail(413, 'page.body_too_large', `A page is at most ${MAX_BODY} characters`);
@@ -401,6 +411,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -401,6 +411,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {
function insertPage(space, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null }, actor, rec) {
const cleanTitle = checkTitle(title);
const cleanTitle = checkTitle(title);
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
const slug = wrapContentError(() => content.pageSlug(cleanTitle));
+ const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
const box = wrapContentError(() => content.normalizeInfobox(infobox));
const vis = checkVisibility(visibility, 'public');
const vis = checkVisibility(visibility, 'public');
@@ -413,7 +424,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -413,7 +424,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
q.insertPage.run({ id, space_id: space.id, slug, title: cleanTitle, parent_id: parentId || null, position: 0, visibility: vis, created_by: actorId(actor), now: t });
redirects.release(`/w/${space.slug}/${slug}`);
redirects.release(`/w/${space.slug}/${slug}`);
const { revision } = wrapContentError(() => revisions.create({
const { revision } = wrapContentError(() => revisions.create({
- entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: summary || null, infobox: box },
+ entityId: id, expectedRevision: 0, content: text, fields: { title: cleanTitle, summary: cleanSummary, infobox: box },
meta: { authorship: rec }, author: actorId(actor), message,
meta: { authorship: rec }, author: actorId(actor), message,
}));
}));
const page = q.pageById.get(id);
const page = q.pageById.get(id);
@@ -675,7 +686,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -675,7 +686,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
if (!head) fail(404, 'revision.not_found', 'This page has no revision');
if (!head) fail(404, 'revision.not_found', 'This page has no revision');
const text = body == null ? head.content : checkBody(body);
const text = body == null ? head.content : checkBody(body);
const box = infobox === undefined ? (head.fields.infobox || []) : wrapContentError(() => content.normalizeInfobox(infobox));
const box = infobox === undefined ? (head.fields.infobox || []) : wrapContentError(() => content.normalizeInfobox(infobox));
- const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : (summary || null), infobox: box };
+ const fields = { title: title == null ? head.fields.title : checkTitle(title), summary: summary === undefined ? head.fields.summary || null : checkSummary(summary), infobox: box };
const { revision, created } = wrapContentError(() => revisions.create({
const { revision, created } = wrapContentError(() => revisions.create({
entityId: page.id, expectedRevision, content: text, fields, meta: { authorship: rec }, author: actorId(actor), message,
entityId: page.id, expectedRevision, content: text, fields, meta: { authorship: rec }, author: actorId(actor), message,
}));
}));
@@ -904,10 +915,17 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -904,10 +915,17 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
return { href: null, exists: false };
return { href: null, exists: false };
},
},
+ /**
+ * Markdown → safe HTML. Outbound links in community (user) spaces carry rel="nofollow ugc":
+ * nobody vouches for them, so they pass no ranking (link spam gains nothing). Official spaces
+ * are edited by staff, who vouch for their links.
+ */
renderRevision(space, rev, actor) {
renderRevision(space, rev, actor) {
- return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor) });
+ return content.renderContent(rev.content, { currentSpace: space.slug, resolve: (t) => svc.resolveLink(t, actor), rel: svc.linkRel(space) });
},
},
+ linkRel(space) { return space && space.kind === 'official' ? 'noopener' : 'nofollow ugc noopener'; },
+
history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {
history(page, { limit = 100, before: cursor, actor = { kind: 'system' } } = {}) {
const props = new Map(q.proposalsOfPage.all(page.id).map((p) => [p.revision, p]));
const props = new Map(q.proposalsOfPage.all(page.id).map((p) => [p.revision, p]));
const space = q.spaceById.get(page.space_id);
const space = q.spaceById.get(page.space_id);
@@ -1171,9 +1189,13 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -1171,9 +1189,13 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
* pageId null + title = propose a new page (created as a draft).
* pageId null + title = propose a new page (created as a draft).
*/
*/
propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {
propose({ space: spaceIdOrSlug, pageId = null, title, body, infobox = [], summary = null, citations: cites = [], workflow, stubProvider = false, expectedRevision, note = null } = {}, actor) {
- if (!actor || (actor.kind !== 'service' && actor.kind !== 'system')) fail(403, 'proposal.service_only', 'AI proposals come from a service principal');
+ // A proposal may target any space, private ones included, and never needs a person: only
+ // first-party services (svc:…, i.e. OpenVibe.AI) file them, never a developer app or module.
+ const firstParty = actor && actor.kind === 'service' && /^svc:/.test(String(actor.service)) && (!actor.claims || actor.claims.actor_type === 'service');
+ if (!actor || (actor.kind !== 'system' && !firstParty)) fail(403, 'proposal.service_only', 'AI proposals come from a first-party service principal');
if (!workflow || !workflow.id || !workflow.runId) fail(400, 'authorship.workflow_required', 'An AI proposal names its OpenVibe.AI workflow (workflow.id) and run (workflow.run_id)');
if (!workflow || !workflow.id || !workflow.runId) fail(400, 'authorship.workflow_required', 'An AI proposal names its OpenVibe.AI workflow (workflow.id) and run (workflow.run_id)');
const rec = wrapContentError(() => authorship.record({ mode: 'ai', workflow, stubProvider: !!stubProvider }));
const rec = wrapContentError(() => authorship.record({ mode: 'ai', workflow, stubProvider: !!stubProvider }));
+ const cleanSummary = checkSummary(summary);
const text = checkBody(body == null ? '' : body);
const text = checkBody(body == null ? '' : body);
const box = wrapContentError(() => content.normalizeInfobox(infobox));
const box = wrapContentError(() => content.normalizeInfobox(infobox));
return tx(() => {
return tx(() => {
@@ -1194,7 +1216,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
@@ -1194,7 +1216,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
if (expectedRevision != null && Number(expectedRevision) !== base) fail(412, 'revision.conflict', `Revision conflict: expected ${expectedRevision}, current is ${base}`, { expected: Number(expectedRevision), current: base });
if (expectedRevision != null && Number(expectedRevision) !== base) fail(412, 'revision.conflict', `Revision conflict: expected ${expectedRevision}, current is ${base}`, { expected: Number(expectedRevision), current: base });
}
}
const head = base ? revisions.head(page.id) : null;
const head = base ? revisions.head(page.id) : null;
- const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary, infobox: box };
+ const fields = { title: title ? checkTitle(title) : (head ? head.fields.title : page.title), summary: cleanSummary, infobox: box };
const { revision } = wrapContentError(() => revisions.create({
const { revision } = wrapContentError(() => revisions.create({
entityId: page.id, expectedRevision: base, content: text, fields, meta: { authorship: rec }, author: actorId(actor),
entityId: page.id, expectedRevision: base, content: text, fields, meta: { authorship: rec }, author: actorId(actor),
message: note ? `AI proposal: ${String(note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,
message: note ? `AI proposal: ${String(note).slice(0, 200)}` : 'AI proposal', allowUnchanged: true,
@@ -51,7 +51,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
@@ -51,7 +51,7 @@ const stripScripts = (html) => html.replace(/<script[\s\S]*?<\/script>/g, '');
let page2 = stripScripts(r.text);
let page2 = stripScripts(r.text);
assert.ok(page2.includes('<h1>Sources of Rye, revision 2</h1>'));
assert.ok(page2.includes('<h1>Sources of Rye, revision 2</h1>'));
assert.ok(page2.includes('This is the published revision.'));
assert.ok(page2.includes('This is the published revision.'));
- assert.ok(page2.includes('href="https://flour.example/rye" rel="noopener nofollow">Rye flour</a>'));
+ assert.ok(page2.includes('href="https://flour.example/rye" rel="nofollow ugc noopener">Rye flour</a>'), 'a community space\'s links pass no ranking');
assert.ok(page2.includes('<time datetime="2026-09-01T00:00:00.000Z">'), 'retrieval time of the kept source');
assert.ok(page2.includes('<time datetime="2026-09-01T00:00:00.000Z">'), 'retrieval time of the kept source');
assert.ok(page2.includes('<time datetime="2026-09-10T12:00:00.000Z">'), 'retrieval time of the new source');
assert.ok(page2.includes('<time datetime="2026-09-10T12:00:00.000Z">'), 'retrieval time of the new source');
assert.ok(page2.includes('CC BY 4.0') && page2.includes('Rye has less gluten.'));
assert.ok(page2.includes('CC BY 4.0') && page2.includes('Rye has less gluten.'));
@@ -34,7 +34,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -34,7 +34,7 @@ const quiet = { log() {}, warn() {}, error() {} };
* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls
* A running Wiki. opts.env overrides env vars; opts.fetch is the stub for outbound calls
* (Community, Sources, Media, Events, Network token endpoint).
* (Community, Sources, Media, Events, Network token endpoint).
*/
*/
-async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens } = {}) {
+async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false, tokens, rateLimits = false } = {}) {
const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));
const dir = dbPath ? path.dirname(dbPath) : fs.mkdtempSync(path.join(os.tmpdir(), 'wiki-test-'));
const config = load({
const config = load({
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',
NODE_ENV: 'test', PORT: '0', HOST: '127.0.0.1', BASE_URL: 'http://wiki.test',
@@ -43,7 +43,7 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,
@@ -43,7 +43,7 @@ async function boot({ env = {}, fetch: fetchImpl, dbPath, now, workers = false,
...env,
...env,
});
});
const h = await start({
const h = await start({
- config, publicKey, log: quiet, listen: true, workers, rateLimits: false, now,
+ config, publicKey, log: quiet, listen: true, workers, rateLimits, now,
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
fetchImpl: fetchImpl || (async (url) => { throw new Error(`unexpected outbound fetch ${url}`); }),
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
tokens: tokens || { getToken: async () => 'stub-token', authHeaders: async () => ({ Authorization: 'Bearer stub-token' }), invalidate() {} },
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * Regressions for the gaps fixed in the threat review (docs/threat-review.md):
+ * - outbound links in community (user) spaces carry rel="nofollow ugc" — Markdown links, bare
+ * URLs, citations and infobox URLs — so link spam passes no ranking; official spaces keep theirs;
+ * - a page summary is short text (it becomes the meta description, feeds and the Search document):
+ * anything else is refused instead of stored;
+ * - AI proposals (which reach any space without a person) come only from first-party services;
+ * - the HTML search (a LIKE scan over every published page) and revision diffs (CPU and memory
+ * heavy for large, very different revisions) are rate-limited per address.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+const { SYSTEM } = require('../server/wiki/seed');
+
+(async () => {
+ const h = await H.boot();
+ const me = H.subject();
+ const tok = H.userToken({ subject: me });
+ const cite = [{ url: 'https://cited.example/a', title: 'Cited', retrievedAt: '2026-09-01' }];
+ const body = `[a link](https://spam.example/one) and https://spam.example/two ${H.LONG}`;
+ const infobox = [{ label: 'Site', type: 'url', value: 'https://infobox.example/' }];
+ const anchors = (html, host) => html.match(new RegExp(`<a href="https://${host.replace('.', '\\.')}[^"]*"[^>]*>`, 'g')) || [];
+ try {
+ // Community space: every outbound link is nofollow ugc.
+ const user = h.svc.createSpace({ name: 'Community', slug: 'community' }, { kind: 'user', subject: me });
+ const up = h.svc.createPage(user.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me }).page;
+ h.svc.publish(up.id, {}, { kind: 'user', subject: me });
+ let html = (await H.req(h, 'GET', '/w/community/links')).text;
+ const userLinks = [...anchors(html, 'spam.example'), ...anchors(html, 'cited.example'), ...anchors(html, 'infobox.example')];
+ assert.strictEqual(userLinks.length, 4, userLinks.join('\n'));
+ for (const a of userLinks) assert.ok(/rel="nofollow ugc noopener"/.test(a), a);
+ html = (await H.req(h, 'GET', '/w/community/links/sources')).text;
+ assert.ok(anchors(html, 'cited.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));
+ // The editor's preview renders the same way.
+ html = (await H.req(h, 'POST', '/s/community/new', { cookie: H.cookieFor(tok), form: { op: 'preview', title: 'X', body } })).text;
+ assert.ok(anchors(html, 'spam.example').every((a) => /rel="nofollow ugc noopener"/.test(a)));
+
+ // Official space: staff vouch for their links.
+ const off = h.svc.createSpace({ name: 'Official', slug: 'official', kind: 'official' }, SYSTEM);
+ const op = h.svc.createPage(off.id, { title: 'Links', body, infobox, citations: cite }, { kind: 'user', subject: me, staff: true }).page;
+ h.svc.publish(op.id, {}, { kind: 'user', subject: me, staff: true });
+ html = (await H.req(h, 'GET', '/w/official/links')).text;
+ assert.ok(anchors(html, 'spam.example').every((a) => /rel="noopener"/.test(a)) && anchors(html, 'spam.example').length === 2);
+ assert.ok(anchors(html, 'cited.example').every((a) => /rel="noopener nofollow"/.test(a)));
+
+ // Summaries: short text only, through every write path.
+ const bad = [{ a: 1 }, ['x'], 42, 's'.repeat(301)];
+ for (const summary of bad) {
+ const r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: `S ${String(summary).length}`, body: H.LONG, summary } });
+ assert.strictEqual(r.status, 422, JSON.stringify(summary).slice(0, 40));
+ assert.strictEqual(r.json.code, 'page.invalid_summary');
+ }
+ let r = await H.req(h, 'POST', `/api/v1/pages/${up.id}/revisions`, { token: tok, body: { expected_revision: 1, summary: 'x'.repeat(5000) } });
+ assert.strictEqual(r.status, 422);
+ assert.throws(() => h.svc.propose({ space: 'community', title: 'Proposed', body: H.LONG, summary: { html: '<b>' }, workflow: { id: 'wiki.generate_page', runId: 'run_1' } }, { kind: 'service', service: 'svc:ai' }), (e) => e.status === 422 && e.code === 'page.invalid_summary');
+ r = await H.req(h, 'POST', '/api/v1/spaces/community/pages', { token: tok, body: { title: 'Summarised', body: H.LONG, summary: ' One short sentence. ' } });
+ assert.strictEqual(r.status, 201);
+ assert.strictEqual(r.json.revision.summary, 'One short sentence.');
+
+ // AI proposals reach any space without a person, so only first-party services file them:
+ // a developer app or module holding the capability is refused.
+ const proposal = { space: 'community', title: 'Planted', body: H.LONG, workflow: { id: 'wiki.generate_page', run_id: 'run_2' } };
+ for (const [sub, actorType] of [['app:app_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'app'], ['mod:mod_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', 'mod']]) {
+ r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ sub, actorType, cap: ['wiki.revision.propose'], extra: { on_behalf_of: me } }), body: proposal });
+ assert.strictEqual(r.status, 403, `${actorType}: ${r.text}`);
+ assert.strictEqual(r.json.code, 'proposal.service_only');
+ }
+ assert.strictEqual(h.svc.findPage(user.id, 'planted'), null);
+ r = await H.req(h, 'POST', '/api/v1/proposals', { token: H.serviceToken({ client: 'ai', cap: ['wiki.revision.propose'] }), body: proposal });
+ assert.strictEqual(r.status, 201, r.text);
+ await h.stop();
+
+ // The HTML search and diffs are rate-limited per address (other pages are not).
+ const limited = await H.boot({ rateLimits: true });
+ try {
+ for (let i = 0; i < 60; i++) assert.strictEqual((await H.req(limited, 'GET', `/search?q=w${i}`)).status, 200);
+ assert.strictEqual((await H.req(limited, 'GET', '/search?q=again')).status, 429);
+ assert.strictEqual((await H.req(limited, 'GET', '/')).status, 200);
+ const owner = { kind: 'user', subject: H.subject() };
+ const sp = limited.svc.createSpace({ name: 'Diffs', slug: 'diffs' }, owner);
+ const pg = limited.svc.createPage(sp.id, { title: 'Long', body: 'a b c' }, owner).page;
+ limited.svc.editPage(pg.id, { expectedRevision: 1, body: 'x y z' }, owner);
+ limited.svc.publish(pg.id, {}, owner);
+ for (let i = 0; i < 15; i++) {
+ assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2')).status, 200);
+ assert.strictEqual((await H.req(limited, 'GET', `/api/v1/pages/${pg.id}/diff?from=1&to=2`)).status, 200);
+ }
+ assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long/diff/1/2?mode=line')).status, 429, 'SSR and API diffs share one budget');
+ assert.strictEqual((await H.req(limited, 'GET', '/w/diffs/long')).status, 200);
+ } finally {
+ await limited.stop();
+ }
+ console.log('threat review regressions ok');
+ } catch (err) {
+ await h.stop().catch(() => {});
+ throw err;
+ }
+})().catch((err) => { console.error(err); process.exit(1); });