openvibers/openvibe.space Commits
Add published space sharing defaults (#287)
Changes
nnew file mode 100644@@@ -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()++);@@@ -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: {} } },@@@ -116,6 +116,33 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchIconst 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, fetchIAUDIENCES,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 },@@@ -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) => {@@@ -126,9 +126,14 @@ function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, picreturn 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 });@@@ -53,7 +53,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outbownedCount: 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, outbconst 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');nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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()
+);
@@ -23,6 +23,7 @@ const TABLES = [
@@ -23,6 +23,7 @@ const TABLES = [
{ table: 'space_app_tokens', subject: 'subject', file: null },
{ table: 'space_app_tokens', subject: 'subject', file: null },
{ table: 'space_app_codes', 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_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'] },
{ 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.
// 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: {} } },
{ table: 'spaces', subject: 'created_by', file: 'forum-spaces.json', columns: ['id', 'slug', 'name', 'description', 'visibility', 'created_at'], order: 'id', erase: { anonymize: {} } },
@@ -116,6 +116,33 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
@@ -116,6 +116,33 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
const sv = (viewer) => shareViewer(viewer);
const sv = (viewer) => shareViewer(viewer);
const at = (id) => refOf(id);
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 {
return {
shares,
shares,
SERVICE,
SERVICE,
@@ -123,6 +150,7 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
@@ -123,6 +150,7 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
AUDIENCES,
AUDIENCES,
refOf,
refOf,
shareViewer,
shareViewer,
+ defaultAudience, setDefaultAudience, applyDefault,
/** Register a post with the sharing system (inside the post's own transaction). */
/** Register a post with the sharing system (inside the post's own transaction). */
register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
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 },
owner_subject: viewer && viewer.subject ? { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject } : { type: 'service', id: SERVICE },
@@ -290,7 +290,17 @@ function createSpacesPages(ctx) {
@@ -290,7 +290,17 @@ function createSpacesPages(ctx) {
router.get('/spaces/mine', view(async (req, res) => {
router.get('/spaces/mine', view(async (req, res) => {
if (!signedIn(req)) throw new ApiError(401, 'auth.required', 'sign in first');
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) => {
router.get('/spaces/:slug', view(async (req, res) => {
@@ -126,9 +126,14 @@ function newPage({ viewer, templates, csrf, error, prompt, aiOn, buildsLeft, pic
@@ -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 });
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">
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>
<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>'}
${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>`;
</div>`;
return page({ title: 'Your spaces', path: '/spaces/mine', robots: 'noindex,nofollow', body });
return page({ title: 'Your spaces', path: '/spaces/mine', robots: 'noindex,nofollow', body });
@@ -53,7 +53,7 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
@@ -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'"),
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"),
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,
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'),
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 = ?'),
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 (?, ?, ?, ?)'),
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
@@ -211,9 +211,11 @@ function createSpaces({ store, config, projects, sites, deploys, takedowns, outb
const now = store.now();
const now = store.now();
const id = `spc_${ids.ulid(now)}`;
const id = `spc_${ids.ulid(now)}`;
const slug = input.slug ? await fixedSlug(input.slug) : await uniqueSlug(name);
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 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),
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);
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.
// A new space is a draft: Only me until its maker shares it or publishes it.
if (sharing) await sharing.register(viewer, id, 'private');
if (sharing) await sharing.register(viewer, id, 'private');
new file mode 100644
new file mode 100644
@@ -0,0 +1,63 @@
@@ -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); });