openvibers/openvibe.blog Commits

Blog: a default audience for new posts, and apply it to existing posts (#280)

OP OpenVibers committed 2 days ago · 1af23b2dcf · 6 files, +190 −7

Changes

ddiff --git a/migrations/0012_share_defaults.sql b/migrations/0012_share_defaults.sql
nnew file mode 100644
iindex 0000000..6d597a2
---- /dev/null
++++ b/migrations/0012_share_defaults.sql
@@@ -0,0 +1,11 @@
++-- phase: expand
++-- Sharing defaults (VISIBILITY-EVERYWHERE-DESIGN-2026-11-06 rules 1-2): a person's default audience for
++-- the posts they write, and the setting "Apply to all my existing posts" next to it. Expand only.
++-- Specific people is a per-post choice (it needs a list), so it is not a default here.
++CREATE TABLE IF NOT EXISTS share_defaults (
++ owner_subject text NOT NULL,
++ kind text NOT NULL,
++ audience text NOT NULL CHECK (audience IN ('public','unlisted','private')),
++ updated_at timestamptz NOT NULL,
++ PRIMARY KEY (owner_subject, kind)
++);
ddiff --git a/server/domain/posts.js b/server/domain/posts.js
iindex c6c8882..c1dc00a 100644
---- a/server/domain/posts.js
++++ b/server/domain/posts.js
@@@ -288,7 +288,11 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
if (!await access.canWrite(store, viewer, blog, 'create')) throw new ApiError(403, 'blog.forbidden', 'You cannot write on this blog');
const t = title(input.title);
const content = bodyOf(input);
-- const vis = visibilityOf(blog, input.visibility, input.entitlementKey ?? input.entitlement_key);
++ // A person's default audience for new posts (VISIBILITY-EVERYWHERE rule 1); a per-post choice
++ // in the form or the API still wins.
++ let wanted = input.visibility;
++ if ((wanted === undefined || wanted === null || wanted === '') && sharing) wanted = await sharing.defaultOf(subject, 'post');
++ const vis = visibilityOf(blog, wanted, input.entitlementKey ?? input.entitlement_key);
const rec = authorshipFor(viewer, input, null);
return await store.tx(async () => {
const now = store.now();
ddiff --git a/server/http/editor.js b/server/http/editor.js
iindex 5cead6e..0972c39 100644
---- a/server/http/editor.js
++++ b/server/http/editor.js
@@@ -36,7 +36,7 @@ function formInput(body) {
}
function createEditorRoutes(ctx) {
-- const { config, store, blogs, posts, publication, access, people, viewers, effects, publicRoutes, limits } = ctx;
++ const { config, store, blogs, posts, publication, access, people, viewers, effects, publicRoutes, limits, sharing } = ctx;
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '600kb' });
router.use(viewers.middleware({ services: false }));
@@@ -62,6 +62,7 @@ function createEditorRoutes(ctx) {
router.post('/@:handle/ai-draft', B('blog.post.ai_draft'));
router.post('/@:handle/new', B('blog.post.create'));
router.post('/@:handle/settings', B('blog.blog.configure'));
++ router.post('/@:handle/sharing', B('blog.blog.configure'));
router.post(['/@:handle/members', '/@:handle/members/remove'], B('blog.member.manage'));
router.post(['/posts/:id', '/posts/:id/revert'], B('blog.post.update'));
router.post(['/posts/:id/publish', '/posts/:id/schedule', '/posts/:id/unschedule', '/posts/:id/unpublish', '/posts/:id/review', '/posts/:id/checks'], B('blog.post.publish'));
@@@ -136,14 +137,16 @@ function createEditorRoutes(ctx) {
if (!blog) return notFound(req, res);
const list = await posts.listForDashboard(blog, req.viewer);
const heads = new Map(await Promise.all(list.map(async (p) => [p.id, ((await posts.head(p)) || { fields: {} }).fields])));
-- page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: await access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog) }));
++ const defaultAudience = sharing ? await sharing.defaultOf(req.viewer.subject, 'post') : 'public';
++ page(req, res, blog.title, editor.blogDashboard({ blog, posts: list, heads, role: await access.effectiveRole(store, req.viewer, blog) || 'staff', csrf: csrf(req), message: flashOf(req), publicUrl: publication.blogPath(blog), defaultAudience }));
}));
router.get('/@:handle/new', wrap(async (req, res) => {
const blog = await blogFor(req);
if (!blog || !await access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
const aiForm = ctx.aiDrafts && ctx.aiDrafts.enabled ? editor.aiDraftForm({ blog, csrf: csrf(req) }) : '';
-- page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${aiForm}${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new` })}`);
++ const defaultVisibility = sharing ? await sharing.defaultOf(req.viewer.subject, 'post') : 'public';
++ page(req, res, 'New post', `<p><a href="/write/@${esc(blog.handle)}">← ${esc(blog.title)}</a></p><h1>New post</h1>${aiForm}${editor.postForm({ blog, post: null, head: null, terms: {}, series: null, csrf: csrf(req), action: `/write/@${blog.handle}/new`, defaultVisibility })}`);
}));
// Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.
@@@ -191,6 +194,26 @@ function createEditorRoutes(ctx) {
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Settings saved.')}`);
}));
++ // Sharing defaults (VISIBILITY-EVERYWHERE rules 1-2): the person's audience for new posts, and
++ // "Apply to all my existing posts" over the posts this person wrote on this blog.
++ router.post('/@:handle/sharing', wrap(async (req, res) => {
++ const blog = await blogFor(req);
++ if (!blog || !await access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
++ if (!sharing) return publicRoutes.messagePage(req, res, 503, 'Sharing is not available', 'Try again in a moment.', { href: `/write/@${blog.handle}`, label: 'Back' });
++ const b = formInput(req.body);
++ const audience = ['public', 'unlisted', 'private'].includes(b.audience) ? b.audience : null;
++ if (!audience) return publicRoutes.messagePage(req, res, 422, 'Choose an audience', 'Everyone, Anyone with the link or Only me.', { href: `/write/@${blog.handle}`, label: 'Back' });
++ await sharing.setDefault(req.viewer.subject, audience);
++ let applied = 0;
++ if (b.apply === '1') {
++ const own = await store.db.prepare("SELECT id, state FROM blog_posts WHERE blog_id = ? AND author_subject = ? AND state <> 'deleted' AND visibility <> 'members' ORDER BY updated_at DESC LIMIT 500").all(blog.id, req.viewer.subject);
++ applied = await sharing.applyDefault(req.viewer, own, audience);
++ }
++ const words = { public: 'Everyone', unlisted: 'Anyone with the link', private: 'Only me' }[audience];
++ const said = b.apply === '1' ? `New posts: ${words}. Applied to ${applied} existing post${applied === 1 ? '' : 's'}.` : `New posts: ${words}.`;
++ res.redirect(303, `/write/@${blog.handle}?saved=${encodeURIComponent(said)}`);
++ }));
++
router.post('/@:handle/members', wrap(async (req, res) => {
const blog = await blogFor(req);
if (!blog || !await access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
ddiff --git a/server/render/editor.js b/server/render/editor.js
iindex ea4053c..8bc7117 100644
---- a/server/render/editor.js
++++ b/server/render/editor.js
@@@ -33,12 +33,21 @@ ${own ? '' : h`<section class="card"><h2>Start your blog</h2>
<p class="meta">Signed in as ${viewer.user && (viewer.user.display_name || viewer.user.username) ? (viewer.user.display_name || viewer.user.username) : viewer.subject}. · <a href="/write/voice">How I write</a></p>`);
}
--function blogDashboard({ blog, posts, heads, role, csrf, message, publicUrl }) {
++function blogDashboard({ blog, posts, heads, role, csrf, message, publicUrl, defaultAudience = 'public' }) {
++ const audienceWords = { public: 'Everyone', unlisted: 'Anyone with the link', private: 'Only me' };
return String(h`<p><a href="/write">← All your blogs</a></p>
<h1>${blog.title}</h1>
<p class="meta">@${blog.handle} · your role: ${role} · <a href="${publicUrl}">View the blog</a>${role === 'owner' ? h` · <a href="/write/@${blog.handle}/settings">Settings</a>` : ''}</p>
${flash(message)}
<p><a class="button" href="/write/@${blog.handle}/new">New post</a></p>
++${role === 'owner' || role === 'editor' || role === 'author' ? h`<details class="share-default"><summary>New posts: ${audienceWords[defaultAudience] || 'Everyone'}</summary>
++<form method="post" action="/write/@${blog.handle}/sharing">${csrfField(csrf)}
++<label for="default-audience">Who can read a new post</label>
++${select('audience', [['public', 'Everyone'], ['unlisted', 'Anyone with the link'], ['private', 'Only me']], defaultAudience)}
++<p class="meta">Specific people is chosen per post in its Share sheet. This is only the starting choice; each post can differ.</p>
++<label><input type="checkbox" name="apply" value="1"> Apply it to all my existing posts on this blog too</label>
++<button type="submit">Save</button>
++</form></details>` : ''}
${posts.length ? h`<table class="posts"><thead><tr><th>Title</th><th>State</th><th>Visibility</th><th>Updated</th></tr></thead><tbody>
${posts.map((p) => h`<tr><td><a href="/write/posts/${p.id}">${(heads.get(p.id) || {}).title || p.slug}</a></td><td>${STATE_LABEL[p.state] || p.state}</td><td>${p.visibility}</td><td>${time(p.updated_at)}</td></tr>`)}
</tbody></table>` : h`<p class="empty">No posts yet.</p>`}`);
@@@ -67,9 +76,9 @@ function aiDraftForm({ blog, csrf }) {
}
/** New post and edit form (the same fields). */
--function postForm({ blog, post, head, terms, series, csrf, message, action }) {
++function postForm({ blog, post, head, terms, series, csrf, message, action, defaultVisibility = 'public' }) {
const f = head ? head.fields : {};
-- const vis = post ? post.visibility : 'public';
++ const vis = post ? post.visibility : defaultVisibility;
return String(h`<link rel="stylesheet" href="${asset('css/publishing-blocks.css')}">
<link rel="stylesheet" href="${asset('css/blocks.css')}">
${flash(message)}
ddiff --git a/server/sharing.js b/server/sharing.js
iindex 29c8226..e94e349 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -29,6 +29,11 @@ const AUDIENCES = ['public', 'link', 'people', 'private'];
/** Blog's visibility words to the SDK's, and back. `members` is VIP and not a share audience. */
const TO_SDK = { public: 'public', unlisted: 'link', private: 'private', members: 'private' };
const FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
++/**
++ * The audiences a person can make their default for new posts. Specific people needs a list per item,
++ * so it stays a per-post choice in the Share sheet; everyone else can be a default.
++ */
++const DEFAULTABLE = ['public', 'unlisted', 'private'];
const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
@@@ -133,6 +138,7 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
SERVICE,
KIND,
AUDIENCES,
++ DEFAULTABLE,
refOf,
shareViewer,
/** Register a post with the sharing system (inside the post's own transaction). */
@@@ -154,6 +160,44 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
});
},
shareOf: async (id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]),
++ /** The person's default audience for new posts; Everyone unless they chose another. */
++ defaultOf: async (owner, kind = KIND) => {
++ if (!owner) return 'public';
++ const row = await db.maybe('SELECT audience FROM share_defaults WHERE owner_subject = $1 AND kind = $2', [String(owner), String(kind)]);
++ return row ? row.audience : 'public';
++ },
++ /** Store a person's default for new posts (VISIBILITY-EVERYWHERE rule 1). */
++ setDefault: async (owner, audience, kind = KIND) => {
++ if (!owner) throw new Error('a default needs an owner');
++ if (!DEFAULTABLE.includes(audience)) throw new Error(`default audience must be one of ${DEFAULTABLE.join(', ')}`);
++ await db.prepare(`INSERT INTO share_defaults (owner_subject, kind, audience, updated_at) VALUES (?, ?, ?, ?)
++ ON CONFLICT (owner_subject, kind) DO UPDATE SET audience = excluded.audience, updated_at = excluded.updated_at`)
++ .run(String(owner), String(kind), audience, new Date(now()).toISOString());
++ return audience;
++ },
++ /**
++ * Rule 2: apply an audience to posts this person already wrote. A published post moves through
++ * the SDK's own update, so the old column stays in step and a post that leaves Everyone is
++ * re-synced (or tombstoned) exactly as the Share sheet would do it. A draft is still Only me
++ * until its author publishes it, so only its intended-publish column moves: applying Everyone
++ * to a draft must not make the unpublished text readable by link.
++ */
++ applyDefault: async (viewer, items, audience, kind = KIND) => {
++ const next = TO_SDK[audience];
++ if (!next || next === 'people') throw new Error('an applied audience must be Everyone, Anyone with the link or Only me');
++ let changed = 0;
++ for (const item of items || []) {
++ const id = typeof item === 'string' ? item : item.id;
++ if (item && item.state === 'published') {
++ await shares.update(sv(viewer), at(id), { audience: next, members: [] });
++ } else {
++ await db.prepare('UPDATE blog_posts SET visibility = ?, updated_at = ? WHERE id = ?')
++ .run(audience, now(), String(id));
++ }
++ changed++;
++ }
++ return changed;
++ },
canView: async (viewer, id) => await shares.canView(sv(viewer), at(id)),
canEdit: async (viewer, id) => await shares.canEdit(sv(viewer), at(id)),
get: async (viewer, id) => await shares.get(sv(viewer), at(id)),
ddiff --git a/test/share-defaults.test.js b/test/share-defaults.test.js
nnew file mode 100644
iindex 0000000..a078518
---- /dev/null
++++ b/test/share-defaults.test.js
@@@ -0,0 +1,92 @@
++'use strict';
++/**
++ * Sharing defaults on Blog (VISIBILITY-EVERYWHERE-DESIGN-2026-11-06 rules 1-2, step 5): a person's
++ * default audience for new posts, the per-post override that still wins, and "Apply to all my
++ * existing posts" moving every post they wrote (and the shares row beside it) at once.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot({ env: { BLOG_WORKER: 'off' } });
++ const carol = t.network.addUser('carol');
++ const maticus = t.network.addUser('maticus');
++ const sam = t.network.addUser('sam');
++ await t.get('/api/v1/blogs', { as: carol, json: {} });
++
++ const make = async (input = {}) => {
++ const r = await t.get('/api/v1/blogs/carol/posts', { as: carol, json: { title: `Post ${Math.random().toString(36).slice(2)}`, body: 'SECRET-SHARING-DEFAULTS text', ...input } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json().post;
++ };
++ const row = async (id) => await t.ctx.store.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('blog', 'post', id);
++ const post = async (id) => await t.ctx.store.db.prepare('SELECT * FROM blog_posts WHERE id = ?').get(id);
++ const saveDefault = (form) => t.get('/write/@carol/sharing', { as: carol, method: 'POST', form: { _csrf: t.csrf(carol), ...form } });
++
++ await check('a blog shows the default and the form; a new post is Everyone until the default changes', async () => {
++ const page = await t.get('/write/@carol', { as: carol });
++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
++ assert.ok(page.text.includes('New posts: Everyone'), 'the default is shown');
++ assert.ok(page.text.includes('action="/write/@carol/sharing"'), 'the setting posts to its own route');
++ const p = await make();
++ assert.strictEqual(p.visibility, 'public', 'Everyone is the unchanged default');
++ assert.strictEqual((await row(p.id)).audience, 'private', 'a new draft is still Only me');
++ });
++
++ await check('the default changes the new-post form and an API create that does not choose', async () => {
++ const saved = await saveDefault({ audience: 'unlisted' });
++ assert.strictEqual(saved.status, 303, saved.text);
++ const dash = await t.get('/write/@carol', { as: carol });
++ assert.ok(dash.text.includes('New posts: Anyone with the link'), 'the new default is shown');
++ const form = await t.get('/write/@carol/new', { as: carol });
++ assert.ok(/<option value="unlisted" selected>/.test(form.text), 'the new post form starts at the default');
++ const p = await make();
++ assert.strictEqual(p.visibility, 'unlisted', 'the API create uses the default');
++ assert.strictEqual((await row(p.id)).audience, 'private', 'a new draft is still Only me');
++ const pub = await t.get(`/api/v1/posts/${p.id}/publish`, { as: carol, json: {} });
++ assert.strictEqual(pub.status, 200, pub.text);
++ assert.strictEqual((await row(p.id)).audience, 'link', 'publishing widens the draft to the default');
++ // A per-post override still wins.
++ const q = await make({ visibility: 'private' });
++ assert.strictEqual(q.visibility, 'private');
++ assert.strictEqual((await row(q.id)).audience, 'private');
++ });
++
++ await check('apply to all moves every post this person wrote, and only theirs', async () => {
++ const one = await make({ visibility: 'public', title: 'First public post' });
++ const two = await make({ visibility: 'public', title: 'Second public post' });
++ const published = await t.get(`/api/v1/posts/${one.id}/publish`, { as: carol, json: {} });
++ assert.strictEqual(published.status, 200, published.text);
++ assert.strictEqual((await t.get(`/@carol/${one.slug}`, { as: sam })).status, 200, 'public before the change');
++ // Someone else's post in the same blog is not Carol's to move.
++ await t.ctx.store.db.prepare("INSERT INTO blog_memberships (blog_id, subject, role, added_by, created_at, updated_at) VALUES ((SELECT id FROM blogs WHERE handle = 'carol'), ?, 'editor', ?, ?, ?)")
++ .run(maticus.subject, carol.subject, Date.now(), Date.now());
++ const theirs = await t.get('/api/v1/blogs/carol/posts', { as: maticus, json: { title: 'Maticus draft', body: 'not carol’s', visibility: 'public' } });
++ assert.strictEqual(theirs.status, 201, theirs.text);
++ const other = theirs.json().post;
++
++ const applied = await saveDefault({ audience: 'private', apply: '1' });
++ assert.strictEqual(applied.status, 303, applied.text);
++ assert.ok(applied.headers.get('location').includes('Applied%20to%20'), applied.headers.get('location'));
++ assert.strictEqual((await post(one.id)).visibility, 'private');
++ assert.strictEqual((await post(two.id)).visibility, 'private');
++ assert.strictEqual((await row(one.id)).audience, 'private');
++ assert.strictEqual((await row(two.id)).audience, 'private');
++ assert.strictEqual((await post(other.id)).visibility, 'public', 'a co-author’s post was left alone');
++ assert.strictEqual((await row(other.id)).audience, 'private', 'the co-author’s draft stays Only me');
++ assert.strictEqual((await t.get(`/@carol/${one.slug}`, { as: sam })).status, 404, 'the narrowed post is not readable');
++ assert.strictEqual((await t.get(`/@carol/${two.slug}`, { as: carol })).status, 200, 'the owner still reads it');
++ assert.strictEqual((await t.get(`/@carol/${other.slug}`, { as: maticus })).status, 200, 'the other post is untouched');
++ });
++
++ await check('a default cannot be Specific people, and a bad audience is refused', async () => {
++ const bad = await saveDefault({ audience: 'people' });
++ assert.strictEqual(bad.status, 422, bad.text.slice(0, 200));
++ assert.ok(!bad.text.includes('New posts: Specific'), 'nothing changed');
++ const dash = await t.get('/write/@carol', { as: carol });
++ assert.ok(dash.text.includes('New posts: Only me'), 'the previous default is intact');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });