openvibers/openvibe.news Commits
Add personal News draft sharing defaults (#285)
Changes
nnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- Each editor's new story drafts start Only me, or Specific people until names are added in the sheet.++CREATE TABLE IF NOT EXISTS news_share_defaults (++ owner_subject text COLLATE "C" PRIMARY KEY,++ audience text NOT NULL CHECK (audience IN ('private', 'people')),++ updated_at timestamptz NOT NULL DEFAULT now()++);@@@ -16,6 +16,7 @@ const editorial = (why) => ({ keep: why });const TABLES = [{ table: 'news_story_drafts', subject: 'owner', file: 'story-drafts.json', order: 'updated_at', columns: ['entity_id', 'base_revision', 'content', 'fields', 'created_at', 'updated_at'] },++ { table: 'news_share_defaults', subject: 'owner_subject', file: 'story-share-defaults.json', columns: ['audience', 'updated_at'] },{ table: 'subject_projections', subject: 'subject', file: 'profile.json', order: 'refreshed_at' },{ table: 'news_stories', subject: 'created_by', file: 'stories.json', order: 'first_published_at', columns: ['id', 'slug', 'working_headline', 'topic_id', 'state', 'first_published_at', 'published_at'], erase: editorial('published journalism keeps its editor byline as the publication record') },{ table: 'news_perspectives', subject: 'created_by', file: 'perspectives.json', order: 'created_at', columns: ['id', 'story_id', 'label', 'description', 'position', 'created_at'], erase: editorial('a published perspective is part of the story') },@@@ -87,7 +87,7 @@ async function createApp(opts = {}) {const discussion = createDiscussion({ store, publication, community, log });const ingest = createIngest({ store, config, clusters, outbox, sources, discussion, log });// Sharing (openvibe-sdk/share): a story's share row, its people and the one access check for the story page.-- const sharing = opts.sharing || createSharing({ db: store.db, config, outbox, now: store.now, fetchImpl: opts.fetchImpl, log });++ const sharing = opts.sharing || createSharing({ db: store.db, config, outbox, publication, now: store.now, fetchImpl: opts.fetchImpl, log });const stories = createStories({ store, config, publication, clusters, outbox, ai, discussion, log, sharing });ingest.setStories(stories);const topics = createTopics({ store });@@@ -126,7 +126,7 @@ function createPublication({ store, config, outbox, indexnow }) {const rec = authorshipOf(rev);const facts = {state: gateState(story, state),-- visibility: 'public',++ visibility: story.audience === 'public' ? 'public' : 'private',canonicalUrl: storyUrl(story),text: plain(rev),citationCount: sup.independent,@@@ -181,7 +181,7 @@ function createPublication({ store, config, outbox, indexnow }) {}function snapshot(story) {-- return story ? { state: story.state, visibility: 'public', revision: story.published_revision, url: storyUrl(story) } : null;++ return story ? { state: story.state, visibility: story.audience === 'public' ? 'public' : 'private', revision: story.published_revision, url: storyUrl(story) } : null;}/**@@@ -312,6 +312,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = nullcluster = await clusters.resolve(cluster.id);}const topic = await topicFrom(input.topic);++ const draftAudience = sharing && /^usr_/.test(String(subject)) ? await sharing.defaultAudience(subject) : 'private';return await store.tx(async () => {const now = store.now();const id = newStoryId(now);@@@ -320,7 +321,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = nullVALUES (?, ?, ?, ?, ?, 'draft', 0, ?, ?, ?)`).run(id, slug, headline, cluster ? cluster.id : null, topic ? topic.id : null, subject, now, now);const story = await q.byId.get(id);// A new story is a draft: Only me until its editor shares it or publishes it.-- if (sharing) await sharing.register({ subject: /^usr_/.test(String(subject)) ? subject : null }, id, 'private');++ if (sharing) await sharing.register({ subject: /^usr_/.test(String(subject)) ? subject : null }, id, draftAudience);if (cluster) {const items = (await clusters.members(cluster.id)).filter((it) => it.status === 'active').sort((a, b) => String(a.published_at || '').localeCompare(String(b.published_at || '')) || a.first_seen_at - b.first_seen_at || (a.id < b.id ? -1 : 1));@@@ -74,7 +74,7 @@ function itemDto(it) {}function createApi(ctx) {-- const { config, stories, clusters, briefs, publication, reading, topics, ingest, viewers, limits } = ctx;++ const { config, stories, clusters, briefs, publication, reading, topics, ingest, viewers, limits, sharing } = ctx;const router = express.Router();router.use(cors(config.apiCorsOrigins));router.use(viewers.middleware());@@@ -177,6 +177,7 @@ function createApi(ctx) {return { story: await storyDto(story), published: m ? reading.storyJson(m) : null };}if (!story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) throw new ApiError(404, 'story.not_found', 'No such story');++ if (sharing && !await sharing.canView(req.viewer, story.id)) throw new ApiError(404, 'story.not_found', 'No such story');return { published: reading.storyJson(await reading.storyModel(story)) };}));@@@ -20,7 +20,7 @@ const express = require('express');const { renderPage } = require('../render/layout');const editor = require('../render/editor');const { csrfToken, checkCsrf } = require('../auth/forms');--const { asApiError } = require('./errors');++const { ApiError, asApiError } = require('./errors');const access = require('../domain/access');const cache = require('openvibe-shared/cache-policy');@@@ -28,7 +28,7 @@ const cache = require('openvibe-shared/cache-policy');const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);function createEditorRoutes(ctx) {-- const { config, store, stories, clusters, topics, ingest, publication, viewers, publicRoutes, ai, limits } = ctx;++ const { config, store, stories, clusters, topics, ingest, publication, viewers, publicRoutes, ai, limits, sharing } = ctx;const router = express.Router();const form = express.urlencoded({ extended: false, limit: '300kb' });router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (req, res, next) => {@@@ -84,10 +84,19 @@ function createEditorRoutes(ctx) {clusters: await Promise.all((await clusters.recent({ limit: 40 })).map(async (c) => ({ ...c, count: (await clusters.members(c.id)).length }))),stories: await stories.listAll({ limit: 100 }), flags, runs: await ingest.runs({ limit: 20 }), sources: await ingest.sourceStatus(),cursor: await ingest.cursor(), csrf: csrf(req), message: flashOf(req), aiEnabled: Boolean(ai && ai.enabled),++ shareDefault: await sharing.defaultAudience(req.viewer.subject),pullOn: Boolean(config.worker.enabled && config.sources.pullIntervalMs),}));}));++ router.post('/edit/share-default', B('news.story.revise'), wrap(async (req, res) => {++ const b = input(req.body);++ if (!['private', 'people'].includes(b.audience)) throw new ApiError(422, 'share.default_invalid', 'Choose Only me or Specific people for new drafts');++ await sharing.setDefaultAudience(req.viewer.subject, b.audience);++ const changed = b.apply === '1' ? await sharing.applyDefault(req.viewer.subject, b.audience) : 0;++ done(res, '/edit', `Draft default saved${changed ? `; ${changed} of your drafts changed` : ''}.`);++ }));++router.post('/edit/pull', B('news.ingest.pull'), wrap(async (req, res) => {const r = await ingest.pull();done(res, '/edit', r.ok ? `Pulled: ${JSON.stringify(r.counts)}` : `Pull failed (${r.error}): ${r.detail}`);@@@ -299,7 +299,7 @@ function createPublicRoutes(ctx) {const group = ['outlet', 'perspective'].includes(req.query.group) ? req.query.group : 'number';const decision = preview ? await publication.decide(story, m.rev, { state: 'draft' }) : m.decision;const crumbs = [{ name: 'OpenVibe.News', url: '/' }, ...(m.topic ? [{ name: m.topic.name, url: publication.topicPath(m.topic) }] : []), { name: m.headline }];-- const cacheable = !preview && (story.state === 'published' || story.state === 'retracted');++ const cacheable = !preview && story.audience === 'public' && (story.state === 'published' || story.state === 'retracted');const comments = preview ? null : await commentsFor(req, story, m.headline);const signedIn = req.viewer.kind === 'user' && Boolean(req.viewer.subject);send(req, res, 200, {@@@ -364,7 +364,7 @@ function createPublicRoutes(ctx) {}if (asJson) {const m = await reading.storyModel(story);-- cacheHeaders(res, { cacheable: req.viewer.kind === 'anonymous', robots: m.decision.robots });++ cacheHeaders(res, { cacheable: req.viewer.kind === 'anonymous' && story.audience === 'public', robots: m.decision.robots });return res.json(reading.storyJson(m));}return await renderStory(req, res, { story });@@@ -374,6 +374,7 @@ function createPublicRoutes(ctx) {router.post('/stories/:slug/comments', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false }), ctx.limits.signedIn('news.discussion.comment'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {const story = await stories.bySlug(req.params.slug);if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) return notFound(req, res);++ if (!access.isEditor(config, req.viewer) && sharing && !await sharing.canView(req.viewer, story.id)) return notFound(req, res);const path = publication.storyPath(story);if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(path)}`);if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the story and try again.', { href: path, label: 'Back to the story' });@@@ -35,10 +35,14 @@ function why(reason) {return r.rule + (r.from ? ` from ${r.from}` : '');}--function dashboard({ clusters, stories, flags, runs, sources, cursor, csrf, message, aiEnabled, pullOn }) {++function dashboard({ clusters, stories, flags, runs, sources, cursor, csrf, message, aiEnabled, pullOn, shareDefault = 'private' }) {return String(h`<h1>Editor desk</h1>${flash(message)}<p class="meta">Stories are written by editors from source items. Nothing is published until an editor publishes it${aiEnabled ? '; AI drafts wait for a person’s review' : ''}.</p>++<form method="post" action="/edit/share-default" class="card">${csrfField(csrf)}++<label>New story drafts ${select('audience', [['private', 'Only me'], ['people', 'Specific people']], shareDefault)}</label>++<label><input type="checkbox" name="apply" value="1"> Apply to all my existing drafts</label>++<button type="submit">Save default</button><p class="meta">Add names in each draft’s Share sheet before others can read it.</p></form><section><h2>Open flags</h2>${flags.length ? h`<ul>${flags.map((f) => h`<li><a href="/edit/stories/${f.story_id}">${f.working_headline}</a> — ${f.kind.replace('_', ' ')}${f.pending_revision ? h` (pending revision ${f.pending_revision})` : ''} · ${time(f.created_at)}</li>`)}</ul>` : h`<p class="empty">None.</p>`}</section><section><h2>Stories</h2>${stories.length ? h`<table><thead><tr><th>Headline</th><th>State</th><th>Revision</th><th>Updated</th></tr></thead><tbody>${stories.map((s) => h`<tr><td><a href="/edit/stories/${s.id}">${s.working_headline}</a></td><td>${STATE_LABEL[s.state]}</td><td>${s.published_revision || '—'}</td><td>${time(s.updated_at)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No stories yet. Open one from a cluster below.</p>`}</section><section><h2>Recent clusters</h2>${clusters.length ? h`<table><thead><tr><th>Cluster</th><th>Items</th><th>Latest</th></tr></thead><tbody>${clusters.map((c) => h`<tr><td><a href="/clusters/${c.id}">${c.label}</a></td><td>${c.count}</td><td>${time(c.window_end)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No source items have arrived yet.</p>`}</section>@@@ -10,8 +10,7 @@* - network.json Network's identity lookups (@name → subject, and a person's card for the sheet)* - events.publish News' own outbox, so share.* envelopes go out with every news.* event* - notify Network's notification push; a refusal is logged, never allowed to stall the queue-- * - onAudienceChange keeps news_stories.audience in step (public→public, link→public but unlisted in intent? no: the-- * story page reads the share row; the column mirrors public/private for the lists)++ * - onAudienceChange keeps news_stories.audience in step; only Everyone is public to lists and Search* - onOwnerChange moves news_stories.created_by to the new owner** A draft is Only me. The sheet on it can name someone ("look at this before I post it"); the@@@ -25,8 +24,8 @@ const SERVICE = 'news';const KIND = 'story';const AUDIENCES = ['public', 'link', 'people', 'private'];/** News' visibility words to the SDK's, and back. */--const TO_SDK = { public: 'public', unlisted: 'link', private: 'private' };--const FROM_SDK = { public: 'public', link: 'public', people: 'private', private: 'private' };++const TO_SDK = { public: 'public', link: 'link', unlisted: 'link', people: 'people', private: 'private' };++const FROM_SDK = { public: 'public', link: 'private', people: 'private', private: 'private' };const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });@@@ -38,7 +37,7 @@ function shareViewer(v) {return null;}--function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {++function createSharing({ db, config, outbox, publication, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {async function publish(envelope) { await outbox.emit(envelope); }const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({@@@ -95,9 +94,13 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT/** The row's audience change keeps the column the lists read in step (a story page reads the row itself). */async function onAudienceChange(ref, audience, tx) {if (ref.service !== SERVICE || ref.kind !== KIND) return;-- const row = await tx.maybe('SELECT state FROM news_stories WHERE id = $1', [ref.id]);++ const row = await tx.maybe('SELECT * FROM news_stories WHERE id = $1', [ref.id]);if (!row) return;await tx.exec('UPDATE news_stories SET audience = $1, updated_at = $2 WHERE id = $3', [FROM_SDK[audience] || 'private', now(), ref.id]);++ if (row.state === 'published' && publication) {++ const changed = await tx.maybe('SELECT * FROM news_stories WHERE id = $1', [ref.id]);++ await publication.syncIndex(changed);++ }}async function onOwnerChange(ref, from, to, tx) {@@@ -118,6 +121,33 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalTconst sv = (viewer) => shareViewer(viewer);const at = (id) => refOf(id);++ async function defaultAudience(owner) {++ const row = await db.maybe('SELECT audience FROM news_share_defaults WHERE owner_subject = $1', [owner]);++ return row ? row.audience : 'private';++ }++ async function setDefaultAudience(owner, audience) {++ if (!['private', 'people'].includes(audience)) throw new Error('Invalid story draft default');++ await db.exec(`INSERT INTO news_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 (!['private', 'people'].includes(audience)) throw new Error('Invalid story draft default');++ const rows = await db.many(`SELECT id FROM news_stories WHERE created_by = $1 AND state IN ('draft', 'unpublished')`, [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,@@@ -125,6 +155,7 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalTAUDIENCES,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 },nnew file mode 100644@@@ -0,0 +1,97 @@++'use strict';++const assert = require('assert');++const { boot, check, done, launchReports } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const make = async (headline) => {++ const r = await t.api('/stories', { json: { headline } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json().story;++ };++ const save = (audience, apply = false, extra = {}) => t.get('/edit/share-default', {++ as: t.editor, method: 'POST', form: { _csrf: t.csrf(t.editor), audience, ...(apply ? { apply: '1' } : {}) }, ...extra,++ });++ try {++ await check('the editor desk saves a personal draft default; new drafts can override it', async () => {++ const desk = await t.get('/edit', { as: t.editor });++ assert.strictEqual(desk.status, 200);++ assert.ok(desk.text.includes('Apply to all my existing drafts'));++ assert.strictEqual(await t.ctx.sharing.defaultAudience(t.editor.subject), 'private');++ assert.strictEqual((await save('people')).status, 303);++ assert.strictEqual(await t.ctx.sharing.defaultAudience(t.editor.subject), 'people');++ const draft = await make('A draft for named readers');++ assert.strictEqual((await t.ctx.sharing.shareOf(draft.id)).audience, 'people');++ const stranger = t.network.addUser('stranger');++ const gate = await t.get(`/stories/${draft.slug}`, { as: stranger });++ assert.strictEqual(gate.status, 404);++ assert.ok(gate.text.includes('data-ov-share-gate') && !gate.text.includes(draft.working_headline));++ const override = await t.get(`/api/v1/story/${draft.id}/share`, {++ as: t.editor, method: 'PUT', json: { audience: 'private' }, headers: { 'x-requested-with': 'ov-share' },++ });++ assert.strictEqual(override.status, 200, override.text);++ assert.strictEqual((await t.ctx.sharing.shareOf(draft.id)).audience, 'private');++ });++++ await check('apply to all changes this editor’s drafts, not published or another editor’s story', async () => {++ await save('private');++ const own = await make('Own unfinished story');++ const published = await make('Published editorial story');++ const other = await make('Someone else owns this draft');++ const nextOwner = t.network.addUser('another-editor');++ await t.db().exec("UPDATE news_stories SET state = 'published' WHERE id = $1", [published.id]);++ await t.ctx.sharing.syncAudience(published.id, 'public');++ await t.db().exec('UPDATE news_stories SET created_by = $1 WHERE id = $2', [nextOwner.subject, other.id]);++ await t.db().exec("UPDATE shares SET owner_subject = $1::jsonb WHERE service = 'news' AND kind = 'story' AND resource_id = $2",++ [JSON.stringify({ type: 'user', id: nextOwner.subject }), other.id]);++ const applied = await save('people', true);++ assert.strictEqual(applied.status, 303, applied.text);++ assert.strictEqual((await t.ctx.sharing.shareOf(own.id)).audience, 'people');++ assert.strictEqual((await t.ctx.sharing.shareOf(published.id)).audience, 'public');++ assert.strictEqual((await t.ctx.sharing.shareOf(other.id)).audience, 'private');++ });++++ await check('invalid default and missing form token are refused', async () => {++ assert.strictEqual((await save('public')).status, 422);++ assert.strictEqual((await save('private', false, { form: { _csrf: 'wrong', audience: 'private' } })).status, 403);++ });++++ await check('a published story leaving Everyone drops from lists and Search while its link stays readable', async () => {++ const reports = launchReports(t.sources);++ await t.pull();++ const cluster = await t.db().maybe('SELECT cluster_id FROM news_source_items WHERE sources_item_id = $1', [reports.a.id]);++ const created = await t.api('/stories', { json: { cluster: cluster.cluster_id, headline: 'Visibility Example for sharing' } });++ assert.strictEqual(created.status, 201, created.text);++ const story = created.json().story;++ const body = [++ 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center on Tuesday morning, starting a long cruise toward Jupiter and its icy moon Europa. [1, 2]',++ 'The mission is scheduled to reach Jupiter in 2030, and scientists expect the probe to fly past Europa many times to study the ocean beneath its ice shell. [2, 3]',++ ].join('\n\n');++ const revised = await t.api(`/stories/${story.id}/revisions`, { json: { body, expected_revision: 0 } });++ assert.strictEqual(revised.status, 201, revised.text);++ const published = await t.api(`/stories/${story.id}/publish`, { json: {} });++ assert.strictEqual(published.status, 200, published.text);++ assert.ok((await t.get('/')).text.includes('Visibility Example for sharing'));++ const narrowed = await t.get(`/api/v1/story/${story.id}/share`, {++ as: t.editor, method: 'PUT', json: { audience: 'link' }, headers: { 'x-requested-with': 'ov-share' },++ });++ assert.strictEqual(narrowed.status, 200, narrowed.text);++ assert.strictEqual((await t.db().maybe('SELECT audience FROM news_stories WHERE id = $1', [story.id])).audience, 'private');++ assert.ok(!(await t.get('/')).text.includes('Visibility Example for sharing'));++ assert.ok(!(await t.get('/sitemaps/stories.xml')).text.includes(story.slug));++ const direct = await t.get(`/stories/${story.slug}`);++ assert.strictEqual(direct.status, 200);++ assert.ok(direct.headers.get('cache-control').includes('no-store'));++ assert.ok(direct.text.includes('noindex'));++ assert.ok((await t.events('news.index_document.deleted')).length >= 1);++ const privateShare = await t.get(`/api/v1/story/${story.id}/share`, {++ as: t.editor, method: 'PUT', json: { audience: 'people' }, headers: { 'x-requested-with': 'ov-share' },++ });++ assert.strictEqual(privateShare.status, 200, privateShare.text);++ assert.strictEqual((await t.get(`/api/v1/stories/${story.id}`)).status, 404);++ const outsider = t.network.addUser('comment-outsider');++ assert.strictEqual((await t.get(`/stories/${story.slug}/comments`, { as: outsider, method: 'POST', form: { message: 'test' } })).status, 404);++ });++ } 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
+-- Each editor's new story drafts start Only me, or Specific people until names are added in the sheet.
+CREATE TABLE IF NOT EXISTS news_share_defaults (
+ owner_subject text COLLATE "C" PRIMARY KEY,
+ audience text NOT NULL CHECK (audience IN ('private', 'people')),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
@@ -16,6 +16,7 @@ const editorial = (why) => ({ keep: why });
@@ -16,6 +16,7 @@ const editorial = (why) => ({ keep: why });
const TABLES = [
const TABLES = [
{ table: 'news_story_drafts', subject: 'owner', file: 'story-drafts.json', order: 'updated_at', columns: ['entity_id', 'base_revision', 'content', 'fields', 'created_at', 'updated_at'] },
{ table: 'news_story_drafts', subject: 'owner', file: 'story-drafts.json', order: 'updated_at', columns: ['entity_id', 'base_revision', 'content', 'fields', 'created_at', 'updated_at'] },
+ { table: 'news_share_defaults', subject: 'owner_subject', file: 'story-share-defaults.json', columns: ['audience', 'updated_at'] },
{ table: 'subject_projections', subject: 'subject', file: 'profile.json', order: 'refreshed_at' },
{ table: 'subject_projections', subject: 'subject', file: 'profile.json', order: 'refreshed_at' },
{ table: 'news_stories', subject: 'created_by', file: 'stories.json', order: 'first_published_at', columns: ['id', 'slug', 'working_headline', 'topic_id', 'state', 'first_published_at', 'published_at'], erase: editorial('published journalism keeps its editor byline as the publication record') },
{ table: 'news_stories', subject: 'created_by', file: 'stories.json', order: 'first_published_at', columns: ['id', 'slug', 'working_headline', 'topic_id', 'state', 'first_published_at', 'published_at'], erase: editorial('published journalism keeps its editor byline as the publication record') },
{ table: 'news_perspectives', subject: 'created_by', file: 'perspectives.json', order: 'created_at', columns: ['id', 'story_id', 'label', 'description', 'position', 'created_at'], erase: editorial('a published perspective is part of the story') },
{ table: 'news_perspectives', subject: 'created_by', file: 'perspectives.json', order: 'created_at', columns: ['id', 'story_id', 'label', 'description', 'position', 'created_at'], erase: editorial('a published perspective is part of the story') },
@@ -87,7 +87,7 @@ async function createApp(opts = {}) {
@@ -87,7 +87,7 @@ async function createApp(opts = {}) {
const discussion = createDiscussion({ store, publication, community, log });
const discussion = createDiscussion({ store, publication, community, log });
const ingest = createIngest({ store, config, clusters, outbox, sources, discussion, log });
const ingest = createIngest({ store, config, clusters, outbox, sources, discussion, log });
// Sharing (openvibe-sdk/share): a story's share row, its people and the one access check for the story page.
// Sharing (openvibe-sdk/share): a story's share row, its people and the one access check for the story page.
- const sharing = opts.sharing || createSharing({ db: store.db, config, outbox, now: store.now, fetchImpl: opts.fetchImpl, log });
+ const sharing = opts.sharing || createSharing({ db: store.db, config, outbox, publication, now: store.now, fetchImpl: opts.fetchImpl, log });
const stories = createStories({ store, config, publication, clusters, outbox, ai, discussion, log, sharing });
const stories = createStories({ store, config, publication, clusters, outbox, ai, discussion, log, sharing });
ingest.setStories(stories);
ingest.setStories(stories);
const topics = createTopics({ store });
const topics = createTopics({ store });
@@ -126,7 +126,7 @@ function createPublication({ store, config, outbox, indexnow }) {
@@ -126,7 +126,7 @@ function createPublication({ store, config, outbox, indexnow }) {
const rec = authorshipOf(rev);
const rec = authorshipOf(rev);
const facts = {
const facts = {
state: gateState(story, state),
state: gateState(story, state),
- visibility: 'public',
+ visibility: story.audience === 'public' ? 'public' : 'private',
canonicalUrl: storyUrl(story),
canonicalUrl: storyUrl(story),
text: plain(rev),
text: plain(rev),
citationCount: sup.independent,
citationCount: sup.independent,
@@ -181,7 +181,7 @@ function createPublication({ store, config, outbox, indexnow }) {
@@ -181,7 +181,7 @@ function createPublication({ store, config, outbox, indexnow }) {
}
}
function snapshot(story) {
function snapshot(story) {
- return story ? { state: story.state, visibility: 'public', revision: story.published_revision, url: storyUrl(story) } : null;
+ return story ? { state: story.state, visibility: story.audience === 'public' ? 'public' : 'private', revision: story.published_revision, url: storyUrl(story) } : null;
}
}
/**
/**
@@ -312,6 +312,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -312,6 +312,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
cluster = await clusters.resolve(cluster.id);
cluster = await clusters.resolve(cluster.id);
}
}
const topic = await topicFrom(input.topic);
const topic = await topicFrom(input.topic);
+ const draftAudience = sharing && /^usr_/.test(String(subject)) ? await sharing.defaultAudience(subject) : 'private';
return await store.tx(async () => {
return await store.tx(async () => {
const now = store.now();
const now = store.now();
const id = newStoryId(now);
const id = newStoryId(now);
@@ -320,7 +321,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
@@ -320,7 +321,7 @@ function createStories({ store, config, publication, clusters, outbox, ai = null
VALUES (?, ?, ?, ?, ?, 'draft', 0, ?, ?, ?)`).run(id, slug, headline, cluster ? cluster.id : null, topic ? topic.id : null, subject, now, now);
VALUES (?, ?, ?, ?, ?, 'draft', 0, ?, ?, ?)`).run(id, slug, headline, cluster ? cluster.id : null, topic ? topic.id : null, subject, now, now);
const story = await q.byId.get(id);
const story = await q.byId.get(id);
// A new story is a draft: Only me until its editor shares it or publishes it.
// A new story is a draft: Only me until its editor shares it or publishes it.
- if (sharing) await sharing.register({ subject: /^usr_/.test(String(subject)) ? subject : null }, id, 'private');
+ if (sharing) await sharing.register({ subject: /^usr_/.test(String(subject)) ? subject : null }, id, draftAudience);
if (cluster) {
if (cluster) {
const items = (await clusters.members(cluster.id)).filter((it) => it.status === 'active')
const items = (await clusters.members(cluster.id)).filter((it) => it.status === 'active')
.sort((a, b) => String(a.published_at || '').localeCompare(String(b.published_at || '')) || a.first_seen_at - b.first_seen_at || (a.id < b.id ? -1 : 1));
.sort((a, b) => String(a.published_at || '').localeCompare(String(b.published_at || '')) || a.first_seen_at - b.first_seen_at || (a.id < b.id ? -1 : 1));
@@ -74,7 +74,7 @@ function itemDto(it) {
@@ -74,7 +74,7 @@ function itemDto(it) {
}
}
function createApi(ctx) {
function createApi(ctx) {
- const { config, stories, clusters, briefs, publication, reading, topics, ingest, viewers, limits } = ctx;
+ const { config, stories, clusters, briefs, publication, reading, topics, ingest, viewers, limits, sharing } = ctx;
const router = express.Router();
const router = express.Router();
router.use(cors(config.apiCorsOrigins));
router.use(cors(config.apiCorsOrigins));
router.use(viewers.middleware());
router.use(viewers.middleware());
@@ -177,6 +177,7 @@ function createApi(ctx) {
@@ -177,6 +177,7 @@ function createApi(ctx) {
return { story: await storyDto(story), published: m ? reading.storyJson(m) : null };
return { story: await storyDto(story), published: m ? reading.storyJson(m) : null };
}
}
if (!story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) throw new ApiError(404, 'story.not_found', 'No such story');
if (!story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) throw new ApiError(404, 'story.not_found', 'No such story');
+ if (sharing && !await sharing.canView(req.viewer, story.id)) throw new ApiError(404, 'story.not_found', 'No such story');
return { published: reading.storyJson(await reading.storyModel(story)) };
return { published: reading.storyJson(await reading.storyModel(story)) };
}));
}));
@@ -20,7 +20,7 @@ const express = require('express');
@@ -20,7 +20,7 @@ const express = require('express');
const { renderPage } = require('../render/layout');
const { renderPage } = require('../render/layout');
const editor = require('../render/editor');
const editor = require('../render/editor');
const { csrfToken, checkCsrf } = require('../auth/forms');
const { csrfToken, checkCsrf } = require('../auth/forms');
-const { asApiError } = require('./errors');
+const { ApiError, asApiError } = require('./errors');
const access = require('../domain/access');
const access = require('../domain/access');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
@@ -28,7 +28,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -28,7 +28,7 @@ const cache = require('openvibe-shared/cache-policy');
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
const one = (v) => (Array.isArray(v) ? (v.includes('1') ? '1' : v[v.length - 1]) : v);
function createEditorRoutes(ctx) {
function createEditorRoutes(ctx) {
- const { config, store, stories, clusters, topics, ingest, publication, viewers, publicRoutes, ai, limits } = ctx;
+ const { config, store, stories, clusters, topics, ingest, publication, viewers, publicRoutes, ai, limits, sharing } = ctx;
const router = express.Router();
const router = express.Router();
const form = express.urlencoded({ extended: false, limit: '300kb' });
const form = express.urlencoded({ extended: false, limit: '300kb' });
router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (req, res, next) => {
router.use(['/edit', '/clusters'], viewers.middleware({ services: false }), (req, res, next) => {
@@ -84,10 +84,19 @@ function createEditorRoutes(ctx) {
@@ -84,10 +84,19 @@ function createEditorRoutes(ctx) {
clusters: await Promise.all((await clusters.recent({ limit: 40 })).map(async (c) => ({ ...c, count: (await clusters.members(c.id)).length }))),
clusters: await Promise.all((await clusters.recent({ limit: 40 })).map(async (c) => ({ ...c, count: (await clusters.members(c.id)).length }))),
stories: await stories.listAll({ limit: 100 }), flags, runs: await ingest.runs({ limit: 20 }), sources: await ingest.sourceStatus(),
stories: await stories.listAll({ limit: 100 }), flags, runs: await ingest.runs({ limit: 20 }), sources: await ingest.sourceStatus(),
cursor: await ingest.cursor(), csrf: csrf(req), message: flashOf(req), aiEnabled: Boolean(ai && ai.enabled),
cursor: await ingest.cursor(), csrf: csrf(req), message: flashOf(req), aiEnabled: Boolean(ai && ai.enabled),
+ shareDefault: await sharing.defaultAudience(req.viewer.subject),
pullOn: Boolean(config.worker.enabled && config.sources.pullIntervalMs),
pullOn: Boolean(config.worker.enabled && config.sources.pullIntervalMs),
}));
}));
}));
}));
+ router.post('/edit/share-default', B('news.story.revise'), wrap(async (req, res) => {
+ const b = input(req.body);
+ if (!['private', 'people'].includes(b.audience)) throw new ApiError(422, 'share.default_invalid', 'Choose Only me or Specific people for new drafts');
+ await sharing.setDefaultAudience(req.viewer.subject, b.audience);
+ const changed = b.apply === '1' ? await sharing.applyDefault(req.viewer.subject, b.audience) : 0;
+ done(res, '/edit', `Draft default saved${changed ? `; ${changed} of your drafts changed` : ''}.`);
+ }));
+
router.post('/edit/pull', B('news.ingest.pull'), wrap(async (req, res) => {
router.post('/edit/pull', B('news.ingest.pull'), wrap(async (req, res) => {
const r = await ingest.pull();
const r = await ingest.pull();
done(res, '/edit', r.ok ? `Pulled: ${JSON.stringify(r.counts)}` : `Pull failed (${r.error}): ${r.detail}`);
done(res, '/edit', r.ok ? `Pulled: ${JSON.stringify(r.counts)}` : `Pull failed (${r.error}): ${r.detail}`);
@@ -299,7 +299,7 @@ function createPublicRoutes(ctx) {
@@ -299,7 +299,7 @@ function createPublicRoutes(ctx) {
const group = ['outlet', 'perspective'].includes(req.query.group) ? req.query.group : 'number';
const group = ['outlet', 'perspective'].includes(req.query.group) ? req.query.group : 'number';
const decision = preview ? await publication.decide(story, m.rev, { state: 'draft' }) : m.decision;
const decision = preview ? await publication.decide(story, m.rev, { state: 'draft' }) : m.decision;
const crumbs = [{ name: 'OpenVibe.News', url: '/' }, ...(m.topic ? [{ name: m.topic.name, url: publication.topicPath(m.topic) }] : []), { name: m.headline }];
const crumbs = [{ name: 'OpenVibe.News', url: '/' }, ...(m.topic ? [{ name: m.topic.name, url: publication.topicPath(m.topic) }] : []), { name: m.headline }];
- const cacheable = !preview && (story.state === 'published' || story.state === 'retracted');
+ const cacheable = !preview && story.audience === 'public' && (story.state === 'published' || story.state === 'retracted');
const comments = preview ? null : await commentsFor(req, story, m.headline);
const comments = preview ? null : await commentsFor(req, story, m.headline);
const signedIn = req.viewer.kind === 'user' && Boolean(req.viewer.subject);
const signedIn = req.viewer.kind === 'user' && Boolean(req.viewer.subject);
send(req, res, 200, {
send(req, res, 200, {
@@ -364,7 +364,7 @@ function createPublicRoutes(ctx) {
@@ -364,7 +364,7 @@ function createPublicRoutes(ctx) {
}
}
if (asJson) {
if (asJson) {
const m = await reading.storyModel(story);
const m = await reading.storyModel(story);
- cacheHeaders(res, { cacheable: req.viewer.kind === 'anonymous', robots: m.decision.robots });
+ cacheHeaders(res, { cacheable: req.viewer.kind === 'anonymous' && story.audience === 'public', robots: m.decision.robots });
return res.json(reading.storyJson(m));
return res.json(reading.storyJson(m));
}
}
return await renderStory(req, res, { story });
return await renderStory(req, res, { story });
@@ -374,6 +374,7 @@ function createPublicRoutes(ctx) {
@@ -374,6 +374,7 @@ function createPublicRoutes(ctx) {
router.post('/stories/:slug/comments', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false }), ctx.limits.signedIn('news.discussion.comment'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
router.post('/stories/:slug/comments', rateLimit({ windowMs: 60_000, max: 20, standardHeaders: true, legacyHeaders: false }), ctx.limits.signedIn('news.discussion.comment'), express.urlencoded({ extended: false, limit: '32kb' }), wrap(async (req, res) => {
const story = await stories.bySlug(req.params.slug);
const story = await stories.bySlug(req.params.slug);
if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) return notFound(req, res);
if (!story || !story.published_revision || (story.state !== 'published' && story.state !== 'retracted')) return notFound(req, res);
+ if (!access.isEditor(config, req.viewer) && sharing && !await sharing.canView(req.viewer, story.id)) return notFound(req, res);
const path = publication.storyPath(story);
const path = publication.storyPath(story);
if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(path)}`);
if (req.viewer.kind !== 'user' || !req.viewer.subject) return res.redirect(303, `/auth/login?next=${encodeURIComponent(path)}`);
if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the story and try again.', { href: path, label: 'Back to the story' });
if (!checkCsrf(config, req.viewer, req.body && req.body._csrf)) return messagePage(req, res, 403, 'Form expired', 'Reload the story and try again.', { href: path, label: 'Back to the story' });
@@ -35,10 +35,14 @@ function why(reason) {
@@ -35,10 +35,14 @@ function why(reason) {
return r.rule + (r.from ? ` from ${r.from}` : '');
return r.rule + (r.from ? ` from ${r.from}` : '');
}
}
-function dashboard({ clusters, stories, flags, runs, sources, cursor, csrf, message, aiEnabled, pullOn }) {
+function dashboard({ clusters, stories, flags, runs, sources, cursor, csrf, message, aiEnabled, pullOn, shareDefault = 'private' }) {
return String(h`<h1>Editor desk</h1>
return String(h`<h1>Editor desk</h1>
${flash(message)}
${flash(message)}
<p class="meta">Stories are written by editors from source items. Nothing is published until an editor publishes it${aiEnabled ? '; AI drafts wait for a person’s review' : ''}.</p>
<p class="meta">Stories are written by editors from source items. Nothing is published until an editor publishes it${aiEnabled ? '; AI drafts wait for a person’s review' : ''}.</p>
+<form method="post" action="/edit/share-default" class="card">${csrfField(csrf)}
+<label>New story drafts ${select('audience', [['private', 'Only me'], ['people', 'Specific people']], shareDefault)}</label>
+<label><input type="checkbox" name="apply" value="1"> Apply to all my existing drafts</label>
+<button type="submit">Save default</button><p class="meta">Add names in each draft’s Share sheet before others can read it.</p></form>
<section><h2>Open flags</h2>${flags.length ? h`<ul>${flags.map((f) => h`<li><a href="/edit/stories/${f.story_id}">${f.working_headline}</a> — ${f.kind.replace('_', ' ')}${f.pending_revision ? h` (pending revision ${f.pending_revision})` : ''} · ${time(f.created_at)}</li>`)}</ul>` : h`<p class="empty">None.</p>`}</section>
<section><h2>Open flags</h2>${flags.length ? h`<ul>${flags.map((f) => h`<li><a href="/edit/stories/${f.story_id}">${f.working_headline}</a> — ${f.kind.replace('_', ' ')}${f.pending_revision ? h` (pending revision ${f.pending_revision})` : ''} · ${time(f.created_at)}</li>`)}</ul>` : h`<p class="empty">None.</p>`}</section>
<section><h2>Stories</h2>${stories.length ? h`<table><thead><tr><th>Headline</th><th>State</th><th>Revision</th><th>Updated</th></tr></thead><tbody>${stories.map((s) => h`<tr><td><a href="/edit/stories/${s.id}">${s.working_headline}</a></td><td>${STATE_LABEL[s.state]}</td><td>${s.published_revision || '—'}</td><td>${time(s.updated_at)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No stories yet. Open one from a cluster below.</p>`}</section>
<section><h2>Stories</h2>${stories.length ? h`<table><thead><tr><th>Headline</th><th>State</th><th>Revision</th><th>Updated</th></tr></thead><tbody>${stories.map((s) => h`<tr><td><a href="/edit/stories/${s.id}">${s.working_headline}</a></td><td>${STATE_LABEL[s.state]}</td><td>${s.published_revision || '—'}</td><td>${time(s.updated_at)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No stories yet. Open one from a cluster below.</p>`}</section>
<section><h2>Recent clusters</h2>${clusters.length ? h`<table><thead><tr><th>Cluster</th><th>Items</th><th>Latest</th></tr></thead><tbody>${clusters.map((c) => h`<tr><td><a href="/clusters/${c.id}">${c.label}</a></td><td>${c.count}</td><td>${time(c.window_end)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No source items have arrived yet.</p>`}</section>
<section><h2>Recent clusters</h2>${clusters.length ? h`<table><thead><tr><th>Cluster</th><th>Items</th><th>Latest</th></tr></thead><tbody>${clusters.map((c) => h`<tr><td><a href="/clusters/${c.id}">${c.label}</a></td><td>${c.count}</td><td>${time(c.window_end)}</td></tr>`)}</tbody></table>` : h`<p class="empty">No source items have arrived yet.</p>`}</section>
@@ -10,8 +10,7 @@
@@ -10,8 +10,7 @@
* - network.json Network's identity lookups (@name → subject, and a person's card for the sheet)
* - network.json Network's identity lookups (@name → subject, and a person's card for the sheet)
* - events.publish News' own outbox, so share.* envelopes go out with every news.* event
* - events.publish News' own outbox, so share.* envelopes go out with every news.* event
* - notify Network's notification push; a refusal is logged, never allowed to stall the queue
* - notify Network's notification push; a refusal is logged, never allowed to stall the queue
- * - onAudienceChange keeps news_stories.audience in step (public→public, link→public but unlisted in intent? no: the
- * story page reads the share row; the column mirrors public/private for the lists)
+ * - onAudienceChange keeps news_stories.audience in step; only Everyone is public to lists and Search
* - onOwnerChange moves news_stories.created_by to the new owner
* - onOwnerChange moves news_stories.created_by to the new owner
*
*
* A draft is Only me. The sheet on it can name someone ("look at this before I post it"); the
* A draft is Only me. The sheet on it can name someone ("look at this before I post it"); the
@@ -25,8 +24,8 @@ const SERVICE = 'news';
@@ -25,8 +24,8 @@ const SERVICE = 'news';
const KIND = 'story';
const KIND = 'story';
const AUDIENCES = ['public', 'link', 'people', 'private'];
const AUDIENCES = ['public', 'link', 'people', 'private'];
/** News' visibility words to the SDK's, and back. */
/** News' visibility words to the SDK's, and back. */
-const TO_SDK = { public: 'public', unlisted: 'link', private: 'private' };
-const FROM_SDK = { public: 'public', link: 'public', people: 'private', private: 'private' };
+const TO_SDK = { public: 'public', link: 'link', unlisted: 'link', people: 'people', private: 'private' };
+const FROM_SDK = { public: 'public', link: 'private', people: 'private', private: 'private' };
const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
@@ -38,7 +37,7 @@ function shareViewer(v) {
@@ -38,7 +37,7 @@ function shareViewer(v) {
return null;
return null;
}
}
-function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
+function createSharing({ db, config, outbox, publication, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
async function publish(envelope) { await outbox.emit(envelope); }
async function publish(envelope) { await outbox.emit(envelope); }
const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
const tokens = config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
@@ -95,9 +94,13 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
@@ -95,9 +94,13 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
/** The row's audience change keeps the column the lists read in step (a story page reads the row itself). */
/** The row's audience change keeps the column the lists read in step (a story page reads the row itself). */
async function onAudienceChange(ref, audience, tx) {
async function onAudienceChange(ref, audience, tx) {
if (ref.service !== SERVICE || ref.kind !== KIND) return;
if (ref.service !== SERVICE || ref.kind !== KIND) return;
- const row = await tx.maybe('SELECT state FROM news_stories WHERE id = $1', [ref.id]);
+ const row = await tx.maybe('SELECT * FROM news_stories WHERE id = $1', [ref.id]);
if (!row) return;
if (!row) return;
await tx.exec('UPDATE news_stories SET audience = $1, updated_at = $2 WHERE id = $3', [FROM_SDK[audience] || 'private', now(), ref.id]);
await tx.exec('UPDATE news_stories SET audience = $1, updated_at = $2 WHERE id = $3', [FROM_SDK[audience] || 'private', now(), ref.id]);
+ if (row.state === 'published' && publication) {
+ const changed = await tx.maybe('SELECT * FROM news_stories WHERE id = $1', [ref.id]);
+ await publication.syncIndex(changed);
+ }
}
}
async function onOwnerChange(ref, from, to, tx) {
async function onOwnerChange(ref, from, to, tx) {
@@ -118,6 +121,33 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
@@ -118,6 +121,33 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
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 news_share_defaults WHERE owner_subject = $1', [owner]);
+ return row ? row.audience : 'private';
+ }
+ async function setDefaultAudience(owner, audience) {
+ if (!['private', 'people'].includes(audience)) throw new Error('Invalid story draft default');
+ await db.exec(`INSERT INTO news_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 (!['private', 'people'].includes(audience)) throw new Error('Invalid story draft default');
+ const rows = await db.many(`SELECT id FROM news_stories WHERE created_by = $1 AND state IN ('draft', 'unpublished')`, [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,
@@ -125,6 +155,7 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
@@ -125,6 +155,7 @@ function createSharing({ db, config, outbox, now = Date.now, fetchImpl = globalT
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 },
new file mode 100644
new file mode 100644
@@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
+'use strict';
+const assert = require('assert');
+const { boot, check, done, launchReports } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const make = async (headline) => {
+ const r = await t.api('/stories', { json: { headline } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json().story;
+ };
+ const save = (audience, apply = false, extra = {}) => t.get('/edit/share-default', {
+ as: t.editor, method: 'POST', form: { _csrf: t.csrf(t.editor), audience, ...(apply ? { apply: '1' } : {}) }, ...extra,
+ });
+ try {
+ await check('the editor desk saves a personal draft default; new drafts can override it', async () => {
+ const desk = await t.get('/edit', { as: t.editor });
+ assert.strictEqual(desk.status, 200);
+ assert.ok(desk.text.includes('Apply to all my existing drafts'));
+ assert.strictEqual(await t.ctx.sharing.defaultAudience(t.editor.subject), 'private');
+ assert.strictEqual((await save('people')).status, 303);
+ assert.strictEqual(await t.ctx.sharing.defaultAudience(t.editor.subject), 'people');
+ const draft = await make('A draft for named readers');
+ assert.strictEqual((await t.ctx.sharing.shareOf(draft.id)).audience, 'people');
+ const stranger = t.network.addUser('stranger');
+ const gate = await t.get(`/stories/${draft.slug}`, { as: stranger });
+ assert.strictEqual(gate.status, 404);
+ assert.ok(gate.text.includes('data-ov-share-gate') && !gate.text.includes(draft.working_headline));
+ const override = await t.get(`/api/v1/story/${draft.id}/share`, {
+ as: t.editor, method: 'PUT', json: { audience: 'private' }, headers: { 'x-requested-with': 'ov-share' },
+ });
+ assert.strictEqual(override.status, 200, override.text);
+ assert.strictEqual((await t.ctx.sharing.shareOf(draft.id)).audience, 'private');
+ });
+
+ await check('apply to all changes this editor’s drafts, not published or another editor’s story', async () => {
+ await save('private');
+ const own = await make('Own unfinished story');
+ const published = await make('Published editorial story');
+ const other = await make('Someone else owns this draft');
+ const nextOwner = t.network.addUser('another-editor');
+ await t.db().exec("UPDATE news_stories SET state = 'published' WHERE id = $1", [published.id]);
+ await t.ctx.sharing.syncAudience(published.id, 'public');
+ await t.db().exec('UPDATE news_stories SET created_by = $1 WHERE id = $2', [nextOwner.subject, other.id]);
+ await t.db().exec("UPDATE shares SET owner_subject = $1::jsonb WHERE service = 'news' AND kind = 'story' AND resource_id = $2",
+ [JSON.stringify({ type: 'user', id: nextOwner.subject }), other.id]);
+ const applied = await save('people', true);
+ assert.strictEqual(applied.status, 303, applied.text);
+ assert.strictEqual((await t.ctx.sharing.shareOf(own.id)).audience, 'people');
+ assert.strictEqual((await t.ctx.sharing.shareOf(published.id)).audience, 'public');
+ assert.strictEqual((await t.ctx.sharing.shareOf(other.id)).audience, 'private');
+ });
+
+ await check('invalid default and missing form token are refused', async () => {
+ assert.strictEqual((await save('public')).status, 422);
+ assert.strictEqual((await save('private', false, { form: { _csrf: 'wrong', audience: 'private' } })).status, 403);
+ });
+
+ await check('a published story leaving Everyone drops from lists and Search while its link stays readable', async () => {
+ const reports = launchReports(t.sources);
+ await t.pull();
+ const cluster = await t.db().maybe('SELECT cluster_id FROM news_source_items WHERE sources_item_id = $1', [reports.a.id]);
+ const created = await t.api('/stories', { json: { cluster: cluster.cluster_id, headline: 'Visibility Example for sharing' } });
+ assert.strictEqual(created.status, 201, created.text);
+ const story = created.json().story;
+ const body = [
+ 'NASA launched the Europa Clipper spacecraft on a Falcon Heavy rocket from Kennedy Space Center on Tuesday morning, starting a long cruise toward Jupiter and its icy moon Europa. [1, 2]',
+ 'The mission is scheduled to reach Jupiter in 2030, and scientists expect the probe to fly past Europa many times to study the ocean beneath its ice shell. [2, 3]',
+ ].join('\n\n');
+ const revised = await t.api(`/stories/${story.id}/revisions`, { json: { body, expected_revision: 0 } });
+ assert.strictEqual(revised.status, 201, revised.text);
+ const published = await t.api(`/stories/${story.id}/publish`, { json: {} });
+ assert.strictEqual(published.status, 200, published.text);
+ assert.ok((await t.get('/')).text.includes('Visibility Example for sharing'));
+ const narrowed = await t.get(`/api/v1/story/${story.id}/share`, {
+ as: t.editor, method: 'PUT', json: { audience: 'link' }, headers: { 'x-requested-with': 'ov-share' },
+ });
+ assert.strictEqual(narrowed.status, 200, narrowed.text);
+ assert.strictEqual((await t.db().maybe('SELECT audience FROM news_stories WHERE id = $1', [story.id])).audience, 'private');
+ assert.ok(!(await t.get('/')).text.includes('Visibility Example for sharing'));
+ assert.ok(!(await t.get('/sitemaps/stories.xml')).text.includes(story.slug));
+ const direct = await t.get(`/stories/${story.slug}`);
+ assert.strictEqual(direct.status, 200);
+ assert.ok(direct.headers.get('cache-control').includes('no-store'));
+ assert.ok(direct.text.includes('noindex'));
+ assert.ok((await t.events('news.index_document.deleted')).length >= 1);
+ const privateShare = await t.get(`/api/v1/story/${story.id}/share`, {
+ as: t.editor, method: 'PUT', json: { audience: 'people' }, headers: { 'x-requested-with': 'ov-share' },
+ });
+ assert.strictEqual(privateShare.status, 200, privateShare.text);
+ assert.strictEqual((await t.get(`/api/v1/stories/${story.id}`)).status, 404);
+ const outsider = t.network.addUser('comment-outsider');
+ assert.strictEqual((await t.get(`/stories/${story.slug}/comments`, { as: outsider, method: 'POST', form: { message: 'test' } })).status, 404);
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((error) => { console.error(error); process.exit(1); });