openvibers/openvibe.wiki Commits

Wiki: a default audience for new pages, and applying it to existing pages (#288)

OP OpenVibers committed 2 days ago · d312db57a7 · 5 files, +131 −6

Changes

ddiff --git a/migrations/0009_wiki_share_defaults.sql b/migrations/0009_wiki_share_defaults.sql
nnew file mode 100644
iindex 0000000..f43c4f0
---- /dev/null
++++ b/migrations/0009_wiki_share_defaults.sql
@@@ -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 $$;
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex b0fa9f3..5b1f988 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -277,7 +277,7 @@ function createPages({ svc, viewers, platform, config, log = console, limits, vi
router.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, vi
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 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, vi
if (!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`); }
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 2d6ec38..e92631b 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -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>`)}
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex 9a4231d..547c394 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -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);
ddiff --git a/test/share-defaults.test.js b/test/share-defaults.test.js
nnew file mode 100644
iindex 0000000..6320285
---- /dev/null
++++ b/test/share-defaults.test.js
@@@ -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); });