openvibers/openvibe.wiki Commits
Wiki: a default audience for new pages, and applying it to existing pages (#288)
Changes
nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- Sharing defaults on a wiki (VISIBILITY-EVERYWHERE-DESIGN-2026-11-06 rules 1-2): the audience a new++-- page starts with, set by the wiki's owner on its settings page, plus "apply it to every page".++-- Existing wikis keep Everyone, which is what their create form already offered. Expand only.++ALTER TABLE wiki_spaces ADD COLUMN IF NOT EXISTS default_visibility text NOT NULL DEFAULT 'public';++DO $$ BEGIN++ ALTER TABLE wiki_spaces ADD CONSTRAINT wiki_spaces_default_visibility_allowed++ CHECK (default_visibility IN ('public','members','vip','private'));++EXCEPTION WHEN duplicate_object THEN NULL; END $$;@@@ -277,7 +277,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, virouter.get('/s/:space/new', wrap(async (req, res) => {const space = await locateSpace(req, res);if (!space || !editorsOnly(req, res, space)) return;-- send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: await parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });++ send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: await parentsOf(space, req.actor), defaultVisibility: space.default_visibility }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });}));router.post('/s/:space/new', B('wiki.page.create'), form, wrap(async (req, res) => {@@@ -286,7 +286,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, viconst b = req.body || {};const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parent_id: b.parent_id, visibility: b.visibility, message: b.message };const parents = await parentsOf(space, req.actor);-- const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow', blocksCss: !!preview });++ const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents, defaultVisibility: space.default_visibility }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow', blocksCss: !!preview });try {const infobox = content.parseInfoboxText(b.infobox);if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));@@@ -312,6 +312,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, viif (!space) return;const b = req.body || {};try {++ if (b.op === 'sharing') { const out = await svc.setSpaceDefault(space.id, { defaultVisibility: b.default_visibility, apply: b.apply === '1' }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(space.slug)}/settings?saved=${encodeURIComponent(out.changed ? `New pages: ${out.visibility}. Applied to ${out.changed} page${out.changed === 1 ? '' : 's'}.` : `New pages: ${out.visibility}.`)}`); }if (b.op === 'role') await svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); await svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }else { const s = await svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }@@@ -500,7 +500,7 @@ function citationRows(n) {return rows;}--function editPage({ space, page = null, values, error = null, preview = null, citations = [], parents = [], baseRevision = 0, expectedRevision = null }) {++function editPage({ space, page = null, values, error = null, preview = null, citations = [], parents = [], baseRevision = 0, expectedRevision = null, defaultVisibility = null }) {const isNew = !page;const action = isNew ? `/s/${e(space.slug)}/new` : `${wpath(space, page)}/edit`;const title = isNew ? `New page in ${space.name}` : `Editing ${page.title}`;@@@ -513,7 +513,7 @@ ${preview ? html`<section class="wk-preview" aria-label="Preview"><h2>Preview (n<input type="hidden" name="base_revision" value="${String(baseRevision)}"><label>Title <input type="text" name="title" required maxlength="200" value="${values.title || ''}"></label>${isNew ? html`<label>Parent page <select name="parent_id"><option value="">(top level)</option>${parents.map((p) => html`<option value="${p.id}"${values.parent_id === p.id ? raw(' selected') : ''}>${p.title}</option>`)}</select></label>--<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${(values.visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>` : ''}++<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${(values.visibility || defaultVisibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>` : ''}<label>Summary (one sentence, optional) <input type="text" name="summary" maxlength="300" value="${values.summary || ''}"></label><label>Text (Markdown; link pages with [[Page title]] or [[space:Page title|label]]) <textarea name="body" rows="22">${values.body || ''}</textarea></label><label>Infobox (one row per line: <code>Label | type | value</code>; types: text, number, date, url, boolean, page, media) <textarea name="infobox" rows="5">${values.infobox || ''}</textarea></label>@@@ -573,6 +573,12 @@ function spaceSettingsPage({ space, roles, error = null, flash = null }) {<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${space.visibility === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label><p class="wk-muted">Making a wiki members-only or private removes its pages from sitemaps, feeds and OpenVibe.Search.</p><p><button type="submit" name="op" value="settings">Save settings</button></p></form>++<h2>New pages</h2>++<form method="post" action="/s/${e(space.slug)}/settings" class="wk-form"><input type="hidden" name="op" value="sharing">++<label>Who can read a new page <select name="default_visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${(space.default_visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>++<p class="wk-muted">This is only the starting choice; each page can differ in its own Share sheet.</p>++<label class="wk-check"><input type="checkbox" name="apply" value="1"> Apply it to all existing pages in this wiki too</label>++<p><button type="submit">Save the default</button></p></form><h2>Roles</h2><table><thead><tr><th scope="col">Person</th><th scope="col">Role</th><th scope="col">Since</th><th scope="col"></th></tr></thead><tbody>${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td><td>${t(r.granted_at)}</td><td><form method="post" action="/s/${e(space.slug)}/settings" class="wk-inline"><input type="hidden" name="op" value="role"><input type="hidden" name="subject" value="${r.subject}"><input type="hidden" name="role" value=""><button type="submit">Remove</button></form></td></tr>`)}@@@ -520,7 +520,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =const cleanSummary = checkSummary(summary);const text = checkBody(body == null ? '' : body);const box = wrapContentError(() => content.normalizeInfobox(infobox));-- const vis = checkVisibility(visibility, 'public', space);++ const vis = checkVisibility(visibility == null || visibility === '' ? space.default_visibility : visibility, 'public', space);return await tx(async () => {const existing = await q.pageBySlug.get(space.id, slug);if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });@@@ -770,7 +770,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =return build(null, 0);},-- async createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {++ async createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = null, citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {await roles(actor);const space = await spaceOrFail(spaceIdOrSlug);if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can create pages');@@@ -1033,6 +1033,31 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =});},++ /**++ * VISIBILITY-EVERYWHERE rules 1-2 for Wiki: the wiki's default audience for new pages, and++ * "apply to all my existing pages". Only the space owner may set it. A published page moves++ * through setPageVisibility (column, share row and search index together); a draft keeps its++ * share row Only me and only its intended-publish column moves, so applying Everyone never++ * makes an unpublished page readable by link.++ */++ async setSpaceDefault(spaceIdOrSlug, { defaultVisibility, apply = false } = {}, actor) {++ await roles(actor);++ const space = await spaceOrFail(spaceIdOrSlug);++ if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner changes the wiki’s default');++ const next = checkVisibility(defaultVisibility, space.default_visibility || 'public', space);++ await db.prepare('UPDATE wiki_spaces SET default_visibility = ?, updated_at = ? WHERE id = ?').run(next, now(), space.id);++ let changed = 0;++ if (apply) {++ const pages = await db.prepare("SELECT id, state FROM wiki_pages WHERE space_id = ? AND state <> 'deleted' ORDER BY created_at").all(space.id);++ for (const page of pages) {++ if (page.state === 'published') await svc.setPageVisibility(page.id, { visibility: next }, actor);++ else await db.prepare('UPDATE wiki_pages SET visibility = ?, updated_at = ? WHERE id = ?').run(next, now(), page.id);++ changed++;++ }++ }++ return { visibility: next, changed };++ },++async setPageVisibility(pageId, { visibility, noindex } = {}, actor) {await roles(actor);const page = await pageOrFail(pageId);nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++/**++ * A wiki's default audience for new pages and "apply it to every page" (VISIBILITY-EVERYWHERE++ * rules 1-2, step-5 test). The setting belongs to the wiki's owner on its settings page.++ */++const assert = require('assert');++const H = require('./helpers');++++let failures = 0;++async function check(name, fn) {++ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }++}++++(async () => {++ const h = await H.boot();++ const alice = { subject: H.subject(), username: 'alice' };++ const sam = { subject: H.subject(), username: 'sam' };++ const tok = (u) => H.userToken({ subject: u.subject, username: u.username, aud: ['openvibe.network', 'openvibe.wiki'] });++ const save = (form) => H.req(h, 'POST', '/s/handbook/settings', { token: tok(alice), form: { op: 'sharing', ...form } });++++ await H.req(h, 'POST', '/api/v1/spaces', { token: tok(alice), body: { slug: 'handbook', name: 'Handbook', kind: 'user', visibility: 'public' } });++ const make = async (title) => {++ const r = await H.req(h, 'POST', '/api/v1/spaces/handbook/pages', { token: tok(alice), body: { title, body: H.LONG } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json.page;++ };++ const row = async (id) => await h.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('wiki', 'page', id);++ const space = async () => await h.db.prepare("SELECT * FROM wiki_spaces WHERE slug = 'handbook'").get();++++ await check('a wiki starts at Everyone; the settings page shows the default and its form', async () => {++ const s = await space();++ assert.strictEqual(s.default_visibility, 'public');++ const page = await H.req(h, 'GET', '/s/handbook/settings', { token: tok(alice) });++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));++ assert.ok(page.text.includes('New pages') && page.text.includes('name="default_visibility"'));++ assert.ok(/<option value="public" selected>/.test(page.text));++ const form = await H.req(h, 'GET', '/s/handbook/new', { token: tok(alice) });++ assert.ok(/<option value="public" selected>/.test(form.text), 'the new-page form starts at the default');++ });++++ await check('the default moves the new-page form and an API create that does not choose', async () => {++ const saved = await save({ default_visibility: 'private' });++ assert.strictEqual(saved.status, 303, saved.text);++ const form = await H.req(h, 'GET', '/s/handbook/new', { token: tok(alice) });++ assert.ok(/<option value="private" selected>/.test(form.text), 'the form starts at Only me');++ const p = await make('Only me by default');++ assert.strictEqual(p.visibility, 'private');++ assert.strictEqual((await row(p.id)).audience, 'private', 'a new page is still a private draft');++ // A per-page choice still wins.++ const q = await H.req(h, 'POST', '/api/v1/spaces/handbook/pages', { token: tok(alice), body: { title: 'Chosen public', body: H.LONG, visibility: 'public' } });++ assert.strictEqual(q.json.page.visibility, 'public');++ });++++ await check('apply to all moves the published page and the drafts, and only a published page changes its share row', async () => {++ await save({ default_visibility: 'public' });++ const pub = await make('Published page');++ const pubres = await H.req(h, 'POST', `/api/v1/pages/${pub.id}/publish`, { token: tok(alice), body: {} });++ assert.strictEqual(pubres.status, 200, pubres.text);++ assert.strictEqual((await row(pub.id)).audience, 'public');++ const draft = await make('Still a draft');++ assert.strictEqual((await row(draft.id)).audience, 'private');++++ const applied = await save({ default_visibility: 'private', apply: '1' });++ assert.strictEqual(applied.status, 303, applied.text);++ assert.strictEqual((await space()).default_visibility, 'private');++ assert.strictEqual((await h.db.prepare('SELECT visibility FROM wiki_pages WHERE id = ?').get(pub.id)).visibility, 'private');++ assert.strictEqual((await h.db.prepare('SELECT visibility FROM wiki_pages WHERE id = ?').get(draft.id)).visibility, 'private');++ assert.strictEqual((await row(pub.id)).audience, 'private', 'the published page left Everyone at once');++ assert.strictEqual((await row(draft.id)).audience, 'private', 'the draft stays Only me');++ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${pub.slug}`)).status, 404, 'a stranger no longer reads it');++ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${pub.slug}`, { token: tok(alice) })).status, 200, 'the owner still does');++ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${draft.slug}`, { token: tok(sam) })).status, 404, 'the draft was never opened to anyone');++ });++++ await check('a non-owner cannot change the default', async () => {++ const r = await H.req(h, 'POST', '/s/handbook/settings', { token: tok(sam), form: { op: 'sharing', default_visibility: 'public' } });++ assert.ok(r.status === 403 || r.status === 404, `${r.status}: ${r.text.slice(0, 120)}`);++ assert.strictEqual((await space()).default_visibility, 'private');++ });++++ await h.stop();++ if (failures) { console.log(`\n${failures} failed`); process.exit(1); }++ console.log('\nall passed');++})().catch((e) => { console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- Sharing defaults on a wiki (VISIBILITY-EVERYWHERE-DESIGN-2026-11-06 rules 1-2): the audience a new
+-- page starts with, set by the wiki's owner on its settings page, plus "apply it to every page".
+-- Existing wikis keep Everyone, which is what their create form already offered. Expand only.
+ALTER TABLE wiki_spaces ADD COLUMN IF NOT EXISTS default_visibility text NOT NULL DEFAULT 'public';
+DO $$ BEGIN
+ ALTER TABLE wiki_spaces ADD CONSTRAINT wiki_spaces_default_visibility_allowed
+ CHECK (default_visibility IN ('public','members','vip','private'));
+EXCEPTION WHEN duplicate_object THEN NULL; END $$;
@@ -277,7 +277,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
@@ -277,7 +277,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
router.get('/s/:space/new', wrap(async (req, res) => {
router.get('/s/:space/new', wrap(async (req, res) => {
const space = await locateSpace(req, res);
const space = await locateSpace(req, res);
if (!space || !editorsOnly(req, res, space)) return;
if (!space || !editorsOnly(req, res, space)) return;
- send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: await parentsOf(space, req.actor) }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
+ send(req, res, 200, views.editPage({ space, values: { title: String(req.query.title || '').slice(0, 200) }, parents: await parentsOf(space, req.actor), defaultVisibility: space.default_visibility }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow' });
}));
}));
router.post('/s/:space/new', B('wiki.page.create'), form, wrap(async (req, res) => {
router.post('/s/:space/new', B('wiki.page.create'), form, wrap(async (req, res) => {
@@ -286,7 +286,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
@@ -286,7 +286,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
const b = req.body || {};
const b = req.body || {};
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parent_id: b.parent_id, visibility: b.visibility, message: b.message };
const values = { title: b.title, body: b.body, summary: b.summary, infobox: b.infobox, parent_id: b.parent_id, visibility: b.visibility, message: b.message };
const parents = await parentsOf(space, req.actor);
const parents = await parentsOf(space, req.actor);
- const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow', blocksCss: !!preview });
+ const again = (status, error, preview = null) => send(req, res, status, views.editPage({ space, values, error, preview, parents, defaultVisibility: space.default_visibility }), { title: `New page in ${space.name}`, robots: 'noindex, nofollow', blocksCss: !!preview });
try {
try {
const infobox = content.parseInfoboxText(b.infobox);
const infobox = content.parseInfoboxText(b.infobox);
if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
if (b.op === 'preview') return again(200, null, await svc.renderRevision(space, { content: String(b.body || '') }, req.actor));
@@ -312,6 +312,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
@@ -312,6 +312,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
if (!space) return;
if (!space) return;
const b = req.body || {};
const b = req.body || {};
try {
try {
+ if (b.op === 'sharing') { const out = await svc.setSpaceDefault(space.id, { defaultVisibility: b.default_visibility, apply: b.apply === '1' }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(space.slug)}/settings?saved=${encodeURIComponent(out.changed ? `New pages: ${out.visibility}. Applied to ${out.changed} page${out.changed === 1 ? '' : 's'}.` : `New pages: ${out.visibility}.`)}`); }
if (b.op === 'role') await svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);
if (b.op === 'role') await svc.setRole(space.id, String(b.subject || ''), b.role ? String(b.role) : null, req.actor);
else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); await svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }
else if (b.op === 'delete') { if (b.confirm !== 'yes') throw new svc.WikiError(422, 'space.confirm', 'Tick the box to confirm'); await svc.deleteSpace(space.id, req.actor); return res.redirect(303, '/'); }
else { const s = await svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }
else { const s = await svc.updateSpace(space.id, { name: b.name, description: b.description, visibility: b.visibility }, req.actor); return res.redirect(303, `/s/${encodeURIComponent(s.slug)}/settings?saved=1`); }
@@ -500,7 +500,7 @@ function citationRows(n) {
@@ -500,7 +500,7 @@ function citationRows(n) {
return rows;
return rows;
}
}
-function editPage({ space, page = null, values, error = null, preview = null, citations = [], parents = [], baseRevision = 0, expectedRevision = null }) {
+function editPage({ space, page = null, values, error = null, preview = null, citations = [], parents = [], baseRevision = 0, expectedRevision = null, defaultVisibility = null }) {
const isNew = !page;
const isNew = !page;
const action = isNew ? `/s/${e(space.slug)}/new` : `${wpath(space, page)}/edit`;
const action = isNew ? `/s/${e(space.slug)}/new` : `${wpath(space, page)}/edit`;
const title = isNew ? `New page in ${space.name}` : `Editing ${page.title}`;
const title = isNew ? `New page in ${space.name}` : `Editing ${page.title}`;
@@ -513,7 +513,7 @@ ${preview ? html`<section class="wk-preview" aria-label="Preview"><h2>Preview (n
@@ -513,7 +513,7 @@ ${preview ? html`<section class="wk-preview" aria-label="Preview"><h2>Preview (n
<input type="hidden" name="base_revision" value="${String(baseRevision)}">
<input type="hidden" name="base_revision" value="${String(baseRevision)}">
<label>Title <input type="text" name="title" required maxlength="200" value="${values.title || ''}"></label>
<label>Title <input type="text" name="title" required maxlength="200" value="${values.title || ''}"></label>
${isNew ? html`<label>Parent page <select name="parent_id"><option value="">(top level)</option>${parents.map((p) => html`<option value="${p.id}"${values.parent_id === p.id ? raw(' selected') : ''}>${p.title}</option>`)}</select></label>
${isNew ? html`<label>Parent page <select name="parent_id"><option value="">(top level)</option>${parents.map((p) => html`<option value="${p.id}"${values.parent_id === p.id ? raw(' selected') : ''}>${p.title}</option>`)}</select></label>
-<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${(values.visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>` : ''}
+<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${(values.visibility || defaultVisibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>` : ''}
<label>Summary (one sentence, optional) <input type="text" name="summary" maxlength="300" value="${values.summary || ''}"></label>
<label>Summary (one sentence, optional) <input type="text" name="summary" maxlength="300" value="${values.summary || ''}"></label>
<label>Text (Markdown; link pages with [[Page title]] or [[space:Page title|label]]) <textarea name="body" rows="22">${values.body || ''}</textarea></label>
<label>Text (Markdown; link pages with [[Page title]] or [[space:Page title|label]]) <textarea name="body" rows="22">${values.body || ''}</textarea></label>
<label>Infobox (one row per line: <code>Label | type | value</code>; types: text, number, date, url, boolean, page, media) <textarea name="infobox" rows="5">${values.infobox || ''}</textarea></label>
<label>Infobox (one row per line: <code>Label | type | value</code>; types: text, number, date, url, boolean, page, media) <textarea name="infobox" rows="5">${values.infobox || ''}</textarea></label>
@@ -573,6 +573,12 @@ function spaceSettingsPage({ space, roles, error = null, flash = null }) {
@@ -573,6 +573,12 @@ function spaceSettingsPage({ space, roles, error = null, flash = null }) {
<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${space.visibility === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>
<label>Visibility <select name="visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${space.visibility === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>
<p class="wk-muted">Making a wiki members-only or private removes its pages from sitemaps, feeds and OpenVibe.Search.</p>
<p class="wk-muted">Making a wiki members-only or private removes its pages from sitemaps, feeds and OpenVibe.Search.</p>
<p><button type="submit" name="op" value="settings">Save settings</button></p></form>
<p><button type="submit" name="op" value="settings">Save settings</button></p></form>
+<h2>New pages</h2>
+<form method="post" action="/s/${e(space.slug)}/settings" class="wk-form"><input type="hidden" name="op" value="sharing">
+<label>Who can read a new page <select name="default_visibility">${['public', 'members', 'vip', 'private'].map((v) => html`<option value="${v}"${(space.default_visibility || 'public') === v ? raw(' selected') : ''}>${VIS_LABEL[v]}</option>`)}</select></label>
+<p class="wk-muted">This is only the starting choice; each page can differ in its own Share sheet.</p>
+<label class="wk-check"><input type="checkbox" name="apply" value="1"> Apply it to all existing pages in this wiki too</label>
+<p><button type="submit">Save the default</button></p></form>
<h2>Roles</h2>
<h2>Roles</h2>
<table><thead><tr><th scope="col">Person</th><th scope="col">Role</th><th scope="col">Since</th><th scope="col"></th></tr></thead><tbody>
<table><thead><tr><th scope="col">Person</th><th scope="col">Role</th><th scope="col">Since</th><th scope="col"></th></tr></thead><tbody>
${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td><td>${t(r.granted_at)}</td><td><form method="post" action="/s/${e(space.slug)}/settings" class="wk-inline"><input type="hidden" name="op" value="role"><input type="hidden" name="subject" value="${r.subject}"><input type="hidden" name="role" value=""><button type="submit">Remove</button></form></td></tr>`)}
${roles.map((r) => html`<tr><td><code>${r.subject}</code></td><td>${r.role}</td><td>${t(r.granted_at)}</td><td><form method="post" action="/s/${e(space.slug)}/settings" class="wk-inline"><input type="hidden" name="op" value="role"><input type="hidden" name="subject" value="${r.subject}"><input type="hidden" name="role" value=""><button type="submit">Remove</button></form></td></tr>`)}
@@ -520,7 +520,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -520,7 +520,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
const cleanSummary = checkSummary(summary);
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', space);
+ const vis = checkVisibility(visibility == null || visibility === '' ? space.default_visibility : visibility, 'public', space);
return await tx(async () => {
return await tx(async () => {
const existing = await q.pageBySlug.get(space.id, slug);
const existing = await q.pageBySlug.get(space.id, slug);
if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });
if (existing) fail(409, existing.state === 'deleted' ? 'page.slug_deleted' : 'page.slug_taken', existing.state === 'deleted' ? `A deleted page held "${slug}"; its address stays gone (410)` : `A page "${slug}" already exists in this space`, { page_id: existing.id });
@@ -770,7 +770,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -770,7 +770,7 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
return build(null, 0);
return build(null, 0);
},
},
- async createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = 'public', citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {
+ async createPage(spaceIdOrSlug, { title, body, infobox = [], parentId = null, visibility = null, citations: cites = [], message = null, summary = null, authorship: importRecord = null } = {}, actor) {
await roles(actor);
await roles(actor);
const space = await spaceOrFail(spaceIdOrSlug);
const space = await spaceOrFail(spaceIdOrSlug);
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can create pages');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can create pages');
@@ -1033,6 +1033,31 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
@@ -1033,6 +1033,31 @@ function createWikiService({ db, stores, outbox, config, community = null, vip =
});
});
},
},
+ /**
+ * VISIBILITY-EVERYWHERE rules 1-2 for Wiki: the wiki's default audience for new pages, and
+ * "apply to all my existing pages". Only the space owner may set it. A published page moves
+ * through setPageVisibility (column, share row and search index together); a draft keeps its
+ * share row Only me and only its intended-publish column moves, so applying Everyone never
+ * makes an unpublished page readable by link.
+ */
+ async setSpaceDefault(spaceIdOrSlug, { defaultVisibility, apply = false } = {}, actor) {
+ await roles(actor);
+ const space = await spaceOrFail(spaceIdOrSlug);
+ if (!access.canManage(space, actor)) fail(403, 'space.forbidden', 'Only an owner changes the wiki’s default');
+ const next = checkVisibility(defaultVisibility, space.default_visibility || 'public', space);
+ await db.prepare('UPDATE wiki_spaces SET default_visibility = ?, updated_at = ? WHERE id = ?').run(next, now(), space.id);
+ let changed = 0;
+ if (apply) {
+ const pages = await db.prepare("SELECT id, state FROM wiki_pages WHERE space_id = ? AND state <> 'deleted' ORDER BY created_at").all(space.id);
+ for (const page of pages) {
+ if (page.state === 'published') await svc.setPageVisibility(page.id, { visibility: next }, actor);
+ else await db.prepare('UPDATE wiki_pages SET visibility = ?, updated_at = ? WHERE id = ?').run(next, now(), page.id);
+ changed++;
+ }
+ }
+ return { visibility: next, changed };
+ },
+
async setPageVisibility(pageId, { visibility, noindex } = {}, actor) {
async setPageVisibility(pageId, { visibility, noindex } = {}, actor) {
await roles(actor);
await roles(actor);
const page = await pageOrFail(pageId);
const page = await pageOrFail(pageId);
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+/**
+ * A wiki's default audience for new pages and "apply it to every page" (VISIBILITY-EVERYWHERE
+ * rules 1-2, step-5 test). The setting belongs to the wiki's owner on its settings page.
+ */
+const assert = require('assert');
+const H = require('./helpers');
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); console.log(' ✓', name); } catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 6).join('\n ')); }
+}
+
+(async () => {
+ const h = await H.boot();
+ const alice = { subject: H.subject(), username: 'alice' };
+ const sam = { subject: H.subject(), username: 'sam' };
+ const tok = (u) => H.userToken({ subject: u.subject, username: u.username, aud: ['openvibe.network', 'openvibe.wiki'] });
+ const save = (form) => H.req(h, 'POST', '/s/handbook/settings', { token: tok(alice), form: { op: 'sharing', ...form } });
+
+ await H.req(h, 'POST', '/api/v1/spaces', { token: tok(alice), body: { slug: 'handbook', name: 'Handbook', kind: 'user', visibility: 'public' } });
+ const make = async (title) => {
+ const r = await H.req(h, 'POST', '/api/v1/spaces/handbook/pages', { token: tok(alice), body: { title, body: H.LONG } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json.page;
+ };
+ const row = async (id) => await h.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('wiki', 'page', id);
+ const space = async () => await h.db.prepare("SELECT * FROM wiki_spaces WHERE slug = 'handbook'").get();
+
+ await check('a wiki starts at Everyone; the settings page shows the default and its form', async () => {
+ const s = await space();
+ assert.strictEqual(s.default_visibility, 'public');
+ const page = await H.req(h, 'GET', '/s/handbook/settings', { token: tok(alice) });
+ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
+ assert.ok(page.text.includes('New pages') && page.text.includes('name="default_visibility"'));
+ assert.ok(/<option value="public" selected>/.test(page.text));
+ const form = await H.req(h, 'GET', '/s/handbook/new', { token: tok(alice) });
+ assert.ok(/<option value="public" selected>/.test(form.text), 'the new-page form starts at the default');
+ });
+
+ await check('the default moves the new-page form and an API create that does not choose', async () => {
+ const saved = await save({ default_visibility: 'private' });
+ assert.strictEqual(saved.status, 303, saved.text);
+ const form = await H.req(h, 'GET', '/s/handbook/new', { token: tok(alice) });
+ assert.ok(/<option value="private" selected>/.test(form.text), 'the form starts at Only me');
+ const p = await make('Only me by default');
+ assert.strictEqual(p.visibility, 'private');
+ assert.strictEqual((await row(p.id)).audience, 'private', 'a new page is still a private draft');
+ // A per-page choice still wins.
+ const q = await H.req(h, 'POST', '/api/v1/spaces/handbook/pages', { token: tok(alice), body: { title: 'Chosen public', body: H.LONG, visibility: 'public' } });
+ assert.strictEqual(q.json.page.visibility, 'public');
+ });
+
+ await check('apply to all moves the published page and the drafts, and only a published page changes its share row', async () => {
+ await save({ default_visibility: 'public' });
+ const pub = await make('Published page');
+ const pubres = await H.req(h, 'POST', `/api/v1/pages/${pub.id}/publish`, { token: tok(alice), body: {} });
+ assert.strictEqual(pubres.status, 200, pubres.text);
+ assert.strictEqual((await row(pub.id)).audience, 'public');
+ const draft = await make('Still a draft');
+ assert.strictEqual((await row(draft.id)).audience, 'private');
+
+ const applied = await save({ default_visibility: 'private', apply: '1' });
+ assert.strictEqual(applied.status, 303, applied.text);
+ assert.strictEqual((await space()).default_visibility, 'private');
+ assert.strictEqual((await h.db.prepare('SELECT visibility FROM wiki_pages WHERE id = ?').get(pub.id)).visibility, 'private');
+ assert.strictEqual((await h.db.prepare('SELECT visibility FROM wiki_pages WHERE id = ?').get(draft.id)).visibility, 'private');
+ assert.strictEqual((await row(pub.id)).audience, 'private', 'the published page left Everyone at once');
+ assert.strictEqual((await row(draft.id)).audience, 'private', 'the draft stays Only me');
+ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${pub.slug}`)).status, 404, 'a stranger no longer reads it');
+ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${pub.slug}`, { token: tok(alice) })).status, 200, 'the owner still does');
+ assert.strictEqual((await H.req(h, 'GET', `/w/handbook/${draft.slug}`, { token: tok(sam) })).status, 404, 'the draft was never opened to anyone');
+ });
+
+ await check('a non-owner cannot change the default', async () => {
+ const r = await H.req(h, 'POST', '/s/handbook/settings', { token: tok(sam), form: { op: 'sharing', default_visibility: 'public' } });
+ assert.ok(r.status === 403 || r.status === 404, `${r.status}: ${r.text.slice(0, 120)}`);
+ assert.strictEqual((await space()).default_visibility, 'private');
+ });
+
+ await h.stop();
+ if (failures) { console.log(`\n${failures} failed`); process.exit(1); }
+ console.log('\nall passed');
+})().catch((e) => { console.error(e); process.exit(1); });