openvibers/openvibe.space Commits

Add published space sharing defaults (#287)

OP OpenVibers committed 2 days ago · 7f2c4fd175 · 7 files, +120 −4

Changes

ddiff --git a/migrations/0017_space_share_defaults.sql b/migrations/0017_space_share_defaults.sql
nnew file mode 100644
iindex 0000000..1aa8b1a
---- /dev/null
++++ b/migrations/0017_space_share_defaults.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- A person's preferred audience when a draft becomes a published space. Drafts remain Only me.
++CREATE TABLE IF NOT EXISTS space_share_defaults (
++ owner_subject text COLLATE "C" PRIMARY KEY,
++ audience text NOT NULL CHECK (audience IN ('public', 'link')),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/server/account-data.js b/server/account-data.js
iindex 4ecc67c..1d09cdc 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -23,6 +23,7 @@ const TABLES = [
{ table: 'space_app_tokens', subject: 'subject', file: null },
{ table: 'space_app_codes', subject: 'subject', file: null },
{ table: 'space_app_counters', subject: 'scope', value: (usr) => `build:${usr}`, file: null },
++ { table: 'space_share_defaults', subject: 'owner_subject', file: 'space-share-defaults.json', columns: ['audience', 'updated_at'] },
{ table: 'space_project_members', subject: 'principal', file: 'hosting-project-roles.json', columns: ['project_id', 'role', 'created_at'] },
// The forum spaces this person started: the space stays as the community's record, without its creator.
{ table: 'spaces', subject: 'created_by', file: 'forum-spaces.json', columns: ['id', 'slug', 'name', 'description', 'visibility', 'created_at'], order: 'id', erase: { anonymize: {} } },
ddiff --git a/server/sharing.js b/server/sharing.js
iindex fe9ba27..75eff89 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -116,6 +116,33 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
const sv = (viewer) => shareViewer(viewer);
const at = (id) => refOf(id);
++ async function defaultAudience(owner) {
++ const row = await db.maybe('SELECT audience FROM space_share_defaults WHERE owner_subject = $1', [owner]);
++ return row ? row.audience : 'public';
++ }
++ async function setDefaultAudience(owner, audience) {
++ if (!['public', 'link'].includes(audience)) throw new Error('Invalid published space default');
++ await db.exec(`INSERT INTO space_share_defaults (owner_subject, audience) VALUES ($1, $2)
++ ON CONFLICT (owner_subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [owner, audience]);
++ }
++ async function applyDefault(owner, audience) {
++ if (!['public', 'link'].includes(audience)) throw new Error('Invalid published space default');
++ const rows = await db.many(`SELECT id FROM space_apps WHERE owner_subject = $1 AND status = 'active' AND site_id IS NOT NULL`, [owner]);
++ let changed = 0;
++ for (const row of rows) {
++ const current = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, row.id]);
++ if (!current) {
++ await shares.register(at(row.id), { owner_subject: { type: 'user', id: owner }, audience });
++ await onAudienceChange(at(row.id), audience, db);
++ changed++;
++ } else if (current.audience !== audience) {
++ await shares.setAudience(sv({ subject: owner }), at(row.id), audience);
++ changed++;
++ }
++ }
++ return changed;
++ }
++
return {
shares,
SERVICE,
@@@ -123,6 +150,7 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
AUDIENCES,
refOf,
shareViewer,
++ defaultAudience, setDefaultAudience, applyDefault,
/** Register a post with the sharing system (inside the post's own transaction). */
register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
owner_subject: viewer && viewer.subject ? { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject } : { type: 'service', id: SERVICE },
ddiff --git a/server/spaces/pages.js b/server/spaces/pages.js
iindex ab482b7..690ceff 100644
---- a/server/spaces/pages.js
++++ b/server/spaces/pages.js
@@@ -290,7 +290,17 @@ function createSpacesPages(ctx) {
router.get('/spaces/mine', view(async (req, res) => {
if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
-- html(res, 200, render.minePage({ spaces: await spaces.mine(req.viewer) }));
++ html(res, 200, render.minePage({ spaces: await spaces.mine(req.viewer),
++ shareDefault: await ctx.sharing.defaultAudience(req.viewer.subject), csrf: csrfOf(req) }));
++ }));
++
++ router.post('/spaces/mine/share-default', ...action(async (req, res) => {
++ const audience = String((req.body || {}).audience || '');
++ if (!['public', 'link'].includes(audience)) throw new ApiError(422, 'space.invalid_default', 'choose Everyone or Anyone with the link for published spaces');
++ const owner = spaces.personOf(req.viewer).subject;
++ await ctx.sharing.setDefaultAudience(owner, audience);
++ if (req.body && req.body.apply === '1') await ctx.sharing.applyDefault(owner, audience);
++ res.redirect(303, '/spaces/mine');
}));
router.get('/spaces/:slug', view(async (req, res) => {
ddiff --git a/server/spaces/render.js b/server/spaces/render.js
iindex daed903..704a2d6 100644
---- a/server/spaces/render.js
++++ b/server/spaces/render.js
@@@ -126,9 +126,14 @@ function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pic
return page({ title: 'New space', path: '/spaces/new', robots: 'noindex,nofollow', body });
}
--function minePage({ spaces }) {
++function minePage({ spaces, shareDefault = 'public', csrf = '' }) {
const body = `<div class="sp-wrap">
<div class="sp-head">${kit.pageHead({ title: `Your spaces`, icon: 'home', back: { href: '/', label: 'Home' } })}<a class="sp-btn" href="/spaces/new"><i class="fa-solid fa-plus" aria-hidden="true"></i> New space</a></div>
++ <form method="post" action="/spaces/mine/share-default"><input type="hidden" name="csrf" value="${esc(csrf)}">
++ <label>New published spaces <select name="audience"><option value="public"${shareDefault === 'public' ? ' selected' : ''}>Everyone</option><option value="link"${shareDefault === 'link' ? ' selected' : ''}>Anyone with the link</option></select></label>
++ <label><input type="checkbox" name="apply" value="1"> Apply to all my published spaces</label>
++ <button class="sp-btn sp-btn-quiet" type="submit">Save default</button><p class="sp-muted">Drafts stay Only me until you share or publish them.</p>
++ </form>
${spaces.length ? `<div class="sp-grid">${spaces.map((s) => card(s, { mine: true })).join('')}</div>` : '<p class="sp-empty">You have not made a space yet. <a href="/spaces/new">Make your first one</a>: describe it, or start from a template.</p>'}
</div>`;
return page({ title: 'Your spaces', path: '/spaces/mine', robots: 'noindex,nofollow', body });
ddiff --git a/server/spaces/spaces.js b/server/spaces/spaces.js
iindex 0f56f26..6b473ac 100644
---- a/server/spaces/spaces.js
++++ b/server/spaces/spaces.js
@@@ -53,7 +53,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
ownedCount: db.prepare("SELECT COUNT(*) AS n FROM space_apps WHERE owner_subject = ? AND status = 'active'"),
mine: db.prepare("SELECT * FROM space_apps WHERE owner_subject = ? AND status = 'active' ORDER BY updated_at DESC LIMIT 100"),
insert: db.prepare(`INSERT INTO space_apps (id, slug, owner_subject, owner_username, owner_name, name, description, instructions, capabilities,
-- template, forked_from, visibility, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'public', ?, ?)`),
++ template, forked_from, visibility, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
files: db.prepare('SELECT path, content FROM space_app_files WHERE space_id = ? ORDER BY path'),
clearFiles: db.prepare('DELETE FROM space_app_files WHERE space_id = ?'),
addFile: db.prepare('INSERT INTO space_app_files (space_id, path, content, updated_at) VALUES (?, ?, ?, ?)'),
@@@ -211,9 +211,11 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
const now = store.now();
const id = `spc_${ids.ulid(now)}`;
const slug = input.slug ? await fixedSlug(input.slug) : await uniqueSlug(name);
++ const publishedAudience = sharing ? await sharing.defaultAudience(person.subject) : 'public';
await store.tx(async () => {
await q.insert.run(id, slug, person.subject, person.username, person.name, name, text(input.description, 400), String(input.instructions || '').trim().slice(0, 4000),
-- JSON.stringify(capsOf(input.capabilities)), input.template ? text(input.template, 40) : null, input.forked_from || null, now, now);
++ JSON.stringify(capsOf(input.capabilities)), input.template ? text(input.template, 40) : null, input.forked_from || null,
++ publishedAudience === 'link' ? 'unlisted' : 'public', now, now);
for (const f of fileList) await q.addFile.run(id, f.path, f.content, now);
// A new space is a draft: Only me until its maker shares it or publishes it.
if (sharing) await sharing.register(viewer, id, 'private');
ddiff --git a/test/space-share-defaults.test.js b/test/space-share-defaults.test.js
nnew file mode 100644
iindex 0000000..beb9f9e
---- /dev/null
++++ b/test/space-share-defaults.test.js
@@@ -0,0 +1,63 @@
++'use strict';
++const assert = require('assert');
++const { boot, check, done } = require('./stageb/boot');
++const { csrfToken } = require('../server/auth/forms');
++
++(async () => {
++ const t = await boot();
++ const alice = t.user('alice');
++ const bob = t.user('bob');
++ const viewer = (user) => ({ kind: 'user', subject: user.subject, user: { username: user.username } });
++ const make = (user, name) => t.ctx.spaces.create(viewer(user), {
++ name, files: [{ path: 'index.html', content: '<h1>Draft</h1>' }],
++ });
++ const post = (user, fields) => t.api('POST', '/spaces/mine/share-default', {
++ session: user, form: { csrf: csrfToken(t.config, { subject: user.subject }), ...fields },
++ headers: { origin: 'https://openvibe.space' },
++ });
++ try {
++ await check('published audience default is saved on Your spaces; drafts remain Only me', async () => {
++ const page = await t.api('GET', '/spaces/mine', { session: alice });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('Apply to all my published spaces'));
++ assert.strictEqual(await t.ctx.sharing.defaultAudience(alice.subject), 'public');
++ const saved = await post(alice, { audience: 'link' });
++ assert.strictEqual(saved.status, 303, saved.text);
++ assert.strictEqual(await t.ctx.sharing.defaultAudience(alice.subject), 'link');
++ const draft = await make(alice, 'Link by default');
++ assert.strictEqual(draft.visibility, 'unlisted');
++ assert.strictEqual((await t.ctx.sharing.shareOf(draft.id)).audience, 'private');
++ const override = await t.ctx.spaces.updateSettings(viewer(alice), draft, { visibility: 'public' });
++ assert.strictEqual(override.visibility, 'public');
++ const other = await make(bob, 'Bob default');
++ assert.strictEqual(other.visibility, 'public');
++ });
++
++ await check('apply to all changes only this person’s published spaces, not drafts', async () => {
++ const aliceLive = await make(alice, 'Alice published');
++ const aliceDraft = await make(alice, 'Alice draft');
++ const bobLive = await make(bob, 'Bob published');
++ await t.ctx.store.db.exec('UPDATE space_apps SET site_id = $1 WHERE id = $2', ['sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', aliceLive.id]);
++ await t.ctx.store.db.exec('UPDATE space_apps SET site_id = $1 WHERE id = $2', ['sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6D', bobLive.id]);
++ await t.ctx.sharing.syncAudience(aliceLive.id, 'public');
++ await t.ctx.sharing.syncAudience(bobLive.id, 'public');
++ const applied = await post(alice, { audience: 'link', apply: '1' });
++ assert.strictEqual(applied.status, 303, applied.text);
++ assert.strictEqual((await t.ctx.sharing.shareOf(aliceLive.id)).audience, 'link');
++ assert.strictEqual((await t.ctx.store.db.maybe('SELECT visibility FROM space_apps WHERE id = $1', [aliceLive.id])).visibility, 'unlisted');
++ assert.strictEqual((await t.ctx.sharing.shareOf(aliceDraft.id)).audience, 'private');
++ assert.strictEqual((await t.ctx.sharing.shareOf(bobLive.id)).audience, 'public');
++ assert.strictEqual((await t.ctx.store.db.maybe('SELECT visibility FROM space_apps WHERE id = $1', [bobLive.id])).visibility, 'public');
++ });
++
++ await check('cross-site and invalid audience changes are refused', async () => {
++ assert.strictEqual((await t.api('POST', '/spaces/mine/share-default', {
++ session: alice, form: { csrf: csrfToken(t.config, { subject: alice.subject }), audience: 'public' },
++ headers: { origin: 'https://elsewhere.example' },
++ })).status, 403);
++ assert.strictEqual((await post(alice, { audience: 'private' })).status, 422);
++ assert.strictEqual(await t.ctx.sharing.defaultAudience(alice.subject), 'link');
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((error) => { console.error(error); process.exit(1); });