openvibers/openvibe.blog Commits
Blog: a default audience for new posts, and apply it to existing posts (#280)
Changes
nnew file mode 100644@@@ -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)++);@@@ -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();@@@ -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);@@@ -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)}@@@ -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, fetchISERVICE,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)),nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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)
+);
@@ -288,7 +288,11 @@ function createPosts({ store, blogs, publication, access, outbox, log = console,
@@ -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');
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 t = title(input.title);
const content = bodyOf(input);
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);
const rec = authorshipFor(viewer, input, null);
return await store.tx(async () => {
return await store.tx(async () => {
const now = store.now();
const now = store.now();
@@ -36,7 +36,7 @@ function formInput(body) {
@@ -36,7 +36,7 @@ function formInput(body) {
}
}
function createEditorRoutes(ctx) {
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 router = express.Router();
const form = express.urlencoded({ extended: false, limit: '600kb' });
const form = express.urlencoded({ extended: false, limit: '600kb' });
router.use(viewers.middleware({ services: false }));
router.use(viewers.middleware({ services: false }));
@@ -62,6 +62,7 @@ function createEditorRoutes(ctx) {
@@ -62,6 +62,7 @@ function createEditorRoutes(ctx) {
router.post('/@:handle/ai-draft', B('blog.post.ai_draft'));
router.post('/@:handle/ai-draft', B('blog.post.ai_draft'));
router.post('/@:handle/new', B('blog.post.create'));
router.post('/@:handle/new', B('blog.post.create'));
router.post('/@:handle/settings', B('blog.blog.configure'));
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(['/@:handle/members', '/@:handle/members/remove'], B('blog.member.manage'));
router.post(['/posts/:id', '/posts/:id/revert'], B('blog.post.update'));
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'));
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) {
@@ -136,14 +137,16 @@ function createEditorRoutes(ctx) {
if (!blog) return notFound(req, res);
if (!blog) return notFound(req, res);
const list = await posts.listForDashboard(blog, req.viewer);
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])));
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) => {
router.get('/@:handle/new', wrap(async (req, res) => {
const blog = await blogFor(req);
const blog = await blogFor(req);
if (!blog || !await access.canWrite(store, req.viewer, blog, 'create')) return notFound(req, res);
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) }) : '';
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.
// Draft with AI: the draft is AI-authored and stays a noindex draft until someone reviews it.
@@ -191,6 +194,26 @@ function createEditorRoutes(ctx) {
@@ -191,6 +194,26 @@ function createEditorRoutes(ctx) {
res.redirect(303, `/write/@${blog.handle}/settings?saved=${encodeURIComponent('Settings saved.')}`);
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) => {
router.post('/@:handle/members', wrap(async (req, res) => {
const blog = await blogFor(req);
const blog = await blogFor(req);
if (!blog || !await access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
if (!blog || !await access.canWrite(store, req.viewer, blog, 'members')) return notFound(req, res);
@@ -33,12 +33,21 @@ ${own ? '' : h`<section class="card"><h2>Start your blog</h2>
@@ -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>`);
<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>
return String(h`<p><a href="/write">← All your blogs</a></p>
<h1>${blog.title}</h1>
<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>
<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)}
${flash(message)}
<p><a class="button" href="/write/@${blog.handle}/new">New post</a></p>
<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.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>`)}
${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>`}`);
</tbody></table>` : h`<p class="empty">No posts yet.</p>`}`);
@@ -67,9 +76,9 @@ function aiDraftForm({ blog, csrf }) {
@@ -67,9 +76,9 @@ function aiDraftForm({ blog, csrf }) {
}
}
/** New post and edit form (the same fields). */
/** 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 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')}">
return String(h`<link rel="stylesheet" href="${asset('css/publishing-blocks.css')}">
<link rel="stylesheet" href="${asset('css/blocks.css')}">
<link rel="stylesheet" href="${asset('css/blocks.css')}">
${flash(message)}
${flash(message)}
@@ -29,6 +29,11 @@ const AUDIENCES = ['public', 'link', 'people', 'private'];
@@ -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. */
/** 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 TO_SDK = { public: 'public', unlisted: 'link', private: 'private', members: 'private' };
const FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', private: '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) });
const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
@@ -133,6 +138,7 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
@@ -133,6 +138,7 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
SERVICE,
SERVICE,
KIND,
KIND,
AUDIENCES,
AUDIENCES,
+ DEFAULTABLE,
refOf,
refOf,
shareViewer,
shareViewer,
/** 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). */
@@ -154,6 +160,44 @@ function createSharing({ db, config, outbox, publication, now = Date.now, fetchI
@@ -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)]),
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)),
canView: async (viewer, id) => await shares.canView(sv(viewer), at(id)),
canEdit: async (viewer, id) => await shares.canEdit(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)),
get: async (viewer, id) => await shares.get(sv(viewer), at(id)),
new file mode 100644
new file mode 100644
@@ -0,0 +1,92 @@
@@ -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); });