source: Klonkt/src/routes/guardian.js@ e27b8db

main
Last change on this file since e27b8db was e27b8db, checked in by Robin Genis <roboburr@โ€ฆ>, 6 weeks ago

Afspelen in de app als tweede gated feature, en het gat in de gate

Bij het uitzoeken van de YouTube-vraag bleek de gate lek. De web-Krant bouwt de
speler uit de inhoud van de post via timelineEmbedHtml, en dat pad raakte
gateEmbeds nooit. Een ward wiens guardians niets hadden toegestaan kreeg dus de
volledige YouTube-speler op het web, terwijl de app niets liet zien: het zware
ding open, het lichte dicht. Precies omgekeerd.

Nu zijn het twee besluiten, want het zijn twee dingen. Zien dat er een filmpje
is, is niet hetzelfde als het scherm afstaan aan de motor van een derde partij,
compleet met eindscherm en volgende-video. shaer:externalEmbeds houdt de kaart,
shaer:externalPlayback de speler, allebei standaard uit voor een ward, en
afspelen vereist de kaart: je kunt niet spelen wat je niet mag zien.

En het antwoord op Robins vraag over de links: die vallen er ook onder. De gate
verborg tot nu toe alleen het plaatje terwijl de kale link eronder gewoon
aantikbaar bleef, dus de deur stond open met een doek eroverheen. Staat de gate
dicht, dan toont de kaart zich nog wel maar is hij geen deur meer.

De server bepaalt wat gespeeld mag worden, niet de client: hij levert
shaer:playerUrl mee, alleen bij een open gate en alleen in de privacy-variant
(youtube-nocookie met rel=0, of de eigen speler van de PeerTube-instance). De
app houdt zo geen lijst van hosts bij; hij speelt wat hij krijgt aangereikt.

Changed files:
src/config/database.js

  • kolom sites.external_playback

src/services/guardianship/notes.js

  • externalPlaybackAllowed naast externalEmbedsAllowed

src/services/guardianship/gated.js

  • shaer:externalPlayback in de feature-tabel

src/services/ActivityPubService.js

  • timelineEmbed voegt shaer:playerUrl toe als afspelen mag; playerUrlFor kent alleen privacy-varianten en weigert de rest

src/routes/activitypub.js

  • shaer:capabilities op de owner-only inbox-read: wat mag dit account
  • de embed draagt de speler-URL alleen bij een open playback-gate

src/routes/posts.js

  • het gat gedicht: de speler-iframe op de web-Krant valt nu onder de gate

src/routes/guardian.js

  • de voorstel-route is feature-bewust; het lokale pad stuurt nu ook door

src/assets/js/guardian.js

  • tweede knop in het paneel, alleen zichtbaar als de kaart al aan staat

src/services/i18n.js

  • de labels in nl, en, de

test/gated-settings.test.js

  • drie tests: de speler-URL rijdt alleen mee bij een open gate, een pagina die we niet framen blijft een thumbnail, en afspelen vereist de kaart

remarks: 280 tests groen. Niets geforceerd: beide gates staan standaard uit
voor een ward en twee van de drie guardians moeten nog steeds akkoord gaan.

-robo
Co-Authored-By: Claude Opus 5 <noreply@โ€ฆ>

  • Property mode set to 100644
File size: 33.4 KB
Lineย 
1/**
2 * The Guardian PWA (FEP-633c): a separate, installable corner of Klonkt for
3 * guardians. One place to add and manage wards, a message centre for
4 * incoming help requests and adoption traffic, and its own push channel
5 * (alert types 'help' and 'guardian', web-push slice reused).
6 *
7 * Everything is scoped to a site the logged-in user OWNS: the guardian acts
8 * as one of their own actors (?site=slug picks one when they own several).
9 * Views carry no inline scripts (CSP): logic lives in /assets/js/guardian.js.
10 */
11import express from 'express';
12import crypto from 'crypto';
13import bcrypt from 'bcryptjs';
14import path from 'path';
15import { fileURLToPath } from 'url';
16import db from '../config/database.js';
17import { requireAuth } from '../middleware/auth.js';
18import AP from '../services/ActivityPubService.js';
19import * as Guardianship from '../services/guardianship/index.js';
20import { t as i18nT, resolveLang } from '../services/i18n.js';
21import { injectCspNonce, renderNoteBody, formatDateTime } from '../middleware/render.js';
22import { emojiName } from '../services/NoteRender.js';
23
24const router = express.Router();
25const __dir = path.dirname(fileURLToPath(import.meta.url));
26
27/** The acting site: ?site=slug when owned, else the user's first site. */
28function siteForUser(req) {
29 const userId = req.session.user.id;
30 const want = String(req.query.site || req.body?.site || '').trim();
31 if (want) {
32 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
33 if (s) return s;
34 }
35 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
36}
37
38/** Everything the dashboard shows, one shape for page and API. */
39function uiStrings(L) {
40 const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
41 'pending', 'active', 'retract', 'release', 'release_confirm', 'open', 'push_unavailable',
42 'embeds_on', 'embeds_off', 'embeds_propose', 'embeds_waiting',
43 'accept', 'reject', 'complete', 'awaiting_others', 'coguard',
44 // The per-ward panel: everything about one child in one place.
45 'settings_title', 'panel_open', 'panel_close', 'panel_help', 'panel_help_empty',
46 'panel_follow', 'panel_follow_empty', 'panel_posts', 'panel_posts_empty',
47 'panel_actions', 'badge_help', 'badge_follow', 'badge_follow_one', 'help_empty',
48 // Releasing a ward: a deliberate two-step answer, never one click.
49 'release_title', 'release_effect', 'release_local', 'release_step_down',
50 'release_last', 'release_unknown', 'release_yes', 'release_no',
51 // Availability (FEP-633c 3.6): the dots, the step-away, the lapse.
52 'avail_available', 'avail_away', 'avail_dormant', 'panel_guards', 'panel_guards_remote',
53 'lapse_propose', 'lapse_line', 'lapse_tally', 'lapse_note', 'lapse_agree', 'lapse_disagree', 'voted',
54 'away_title', 'away_sub', 'away_week', 'away_month', 'away_done',
55 // A gated-setting proposal from a fellow guardian (5.6).
56 'gated_title', 'gated_line_on', 'gated_line_off', 'gated_agree', 'gated_disagree',
57 'play_propose', 'play_on', 'play_off'];
58 const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
59 s.wave = i18nT(L, 'guardian.wave');
60 s.waved = i18nT(L, 'guardian.waved');
61 return s;
62}
63
64function dashboardState(site, L) {
65 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
66 const me = AP.actorId(base, site.slug);
67 const help = db.prepare(
68 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
69 emoji_json, actor_emoji_json, media_json, quote_json, embed_json
70 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
71 ).all(site.slug).map((h) => ({
72 ...h,
73 // The dashboard is built in the browser, so it gets the body finished: the
74 // same partial de Krant and Berichten use. A ๐Ÿ›Ÿ often carries a screenshot
75 // and a link to the post it is about; both belong in the card.
76 body_html: renderNoteBody(h, L),
77 name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
78 // In the site's own timezone, the same as everywhere else in Klonkt. The
79 // PWA used to slice the raw UTC string, so a 20:20 call for help read 18:20.
80 when_text: formatDateTime(h.published || h.created_at),
81 }));
82 return {
83 site: site.slug,
84 me,
85 // Committed wards, each carrying the gated settings a guardian may change.
86 // `embeds` is null for a ward we do not host: that setting lives on the
87 // ward's own server, so we show it as not-adjustable rather than lying.
88 // `guardians` (FEP-633c 3.6): the fellow guardians of a LOCAL ward with
89 // their availability; null for a remote ward, whose server tracks it.
90 wards: Guardianship.listWards(site.slug).map((w) => ({
91 ...w,
92 embeds: wardEmbedSetting(w.other_uri),
93 playback: wardPlaybackSetting(w.other_uri),
94 guardians: wardGuardianStatuses(w.other_uri),
95 })),
96 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
97 // Running lapses (3.6.3) this guardian or its local wards are party to.
98 lapses: Guardianship.availability.lapseQueueItems(site.slug, me, Date.now()),
99 // Gated-setting proposals another guardian opened on a ward we share
100 // (5.6), forwarded here by the ward's server. Without answering these the
101 // threshold is never met and the proposal simply expires.
102 gatedReviews: Guardianship.gated.listGatedReviews(site.slug).map((r) => ({
103 id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
104 })),
105 help,
106 strings: uiStrings(L),
107 };
108}
109
110/** The guardians of a ward WE host, with availability (3.6.1: owner-only in
111 * spirit; the co-guardians are among the owners of the relationship). Null
112 * for a remote ward: its server tracks availability, not us. */
113function wardGuardianStatuses(wardUri) {
114 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
115 if (!base || !String(wardUri || '').startsWith(`${base}/`)) return null;
116 const slug = String(wardUri).trim().replace(/\/+$/, '').split('/').pop();
117 try {
118 const uris = Guardianship.listGuardians(slug).map((g) => ({ uri: g.other_uri, handle: g.other_handle }));
119 const st = Object.fromEntries(
120 Guardianship.availability.statusesFor(slug, uris.map((u) => u.uri), Date.now()).map((s) => [s.id, s]),
121 );
122 return uris.map((u) => ({
123 uri: u.uri,
124 handle: u.handle,
125 availability: (st[u.uri] || {})['shaer:availability'] || 'active',
126 awayUntil: (st[u.uri] || {})['shaer:awayUntil'] || null,
127 lapse: (st[u.uri] || {})['shaer:lapse'] || null,
128 }));
129 } catch { return null; }
130}
131
132// โ”€โ”€ The PWA page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
133router.get('/', requireAuth, (req, res) => {
134 const site = siteForUser(req);
135 const L = resolveLang(req);
136 if (!site) return res.status(404).send('No site for this account.');
137 const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
138 // This standalone PWA page is rendered directly (not through renderPage), so
139 // the CSP nonce must be injected here โ€” otherwise strict-dynamic blocks
140 // guardian.js and the whole dashboard is dead (buttons do nothing).
141 res.render('pages/guardian', {
142 state: dashboardState(site, L),
143 sites,
144 lang: L,
145 t: (k, v) => i18nT(L, k, v),
146 cspNonce: res.locals.cspNonce,
147 }, (err, html) => {
148 if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
149 res.send(injectCspNonce(html, res.locals.cspNonce));
150 });
151});
152
153// โ”€โ”€ JSON state for refreshes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
154router.get('/api/state', requireAuth, (req, res) => {
155 const site = siteForUser(req);
156 if (!site) return res.status(404).json({ error: 'no_site' });
157 res.json(dashboardState(site, resolveLang(req)));
158});
159
160// โ”€โ”€ Meekijken (FEP-633c ยง5, interop-hoofdroute): a committed guardian FOLLOWS
161// its wards, so their posts (incl. followers-only) are DELIVERED to the
162// guardian's inbox โ†’ timeline. The follow is the mechanism; no new fetch.
163// First contact also backfills the ward's recent PUBLIC posts as a cold
164// start so the corner is not empty before delivery catches up.
165function ensureWardConnections(site) {
166 let wards;
167 try { wards = Guardianship.listWards(site.slug); } catch { return; }
168 for (const w of wards) {
169 const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
170 .get(site.slug, w.other_uri);
171 if (already) continue;
172 // Follow (guardian's server auto-accepts today; ยง5.3 gating is a later fase).
173 AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
174 // Cold start: pull recent public posts now so oma sees something at once.
175 AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
176 }
177}
178
179// โ”€โ”€ The wards' corner: your wards' posts, read-only. No reply, no share; a
180// guardian watches, it does not publish (Robins besluit).
181router.get('/api/feed', requireAuth, (req, res) => {
182 const site = siteForUser(req);
183 if (!site) return res.status(404).json({ error: 'no_site' });
184 ensureWardConnections(site);
185 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
186 // Only show the wards you actually guard (the timeline can hold more).
187 const items = AP.getTimeline(site.slug, 60, 0)
188 .filter((p) => wardUris.has(p.author_uri))
189 .map((p) => ({
190 id: p.id,
191 author: p.author_handle || p.author_name || p.author_uri,
192 authorUri: p.author_uri, // the grouping key: which child's panel this belongs in
193 authorName: p.author_name,
194 authorIcon: p.author_icon,
195 content: p.content,
196 url: p.url,
197 published: p.published || p.created_at,
198 when_text: formatDateTime(p.published || p.created_at),
199 cw: p.cw || null,
200 media: p.media_json ? JSON.parse(p.media_json) : [],
201 }));
202 res.json({ items, following: wardUris.size });
203});
204
205// โ”€โ”€ Follow-gating (FEP-633c ยง5.3): pending follows on MY wards, for me to
206// approve. Ward and guardian are co-located on the family Klonkt here, so
207// the guardian reads its wards' pending follows locally.
208function wardSlugsOf(site) {
209 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
210 return Guardianship.listWards(site.slug)
211 .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
212 .filter(Boolean);
213}
214
215router.get('/api/follow-requests', requireAuth, (req, res) => {
216 const site = siteForUser(req);
217 if (!site) return res.status(404).json({ error: 'no_site' });
218 const items = [];
219 const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
220 // wardUri is the grouping key for the per-ward panel: the handle is for
221 // reading, the URI is what identifies the child across both cases below.
222 // Local wards (guardian co-located): read the pending follows directly.
223 for (const w of wardSlugsOf(site)) {
224 for (const f of Guardianship.follows.listForWard(w.slug)) {
225 items.push({ id: f.id, ward: `@${w.slug}@${host}`, wardUri: w.uri, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
226 }
227 }
228 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
229 for (const rev of Guardianship.follows.listReviews(site.slug)) {
230 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
231 items.push({ id: rev.id, ward: wardName, wardUri: rev.ward_uri, follower: rev.follower_handle || rev.follower_uri, followerIcon: rev.follower_icon, remote: true, created: rev.created_at });
232 }
233 res.json({ items });
234});
235
236router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
237 const site = siteForUser(req);
238 if (!site) return res.status(404).json({ error: 'no_site' });
239 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
240 const me = AP.actorId(base, site.slug);
241 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
242
243 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
244 // which tallies quorum and returns the Accept(Follow) to the follower.
245 const review = Guardianship.follows.getReview(site.slug, req.params.id);
246 if (review) {
247 try { await AP.sendFollowDecision(site, review, decision); }
248 catch { return res.status(502).json({ error: 'delivery' }); }
249 Guardianship.follows.removeReview(site.slug, req.params.id);
250 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
251 }
252
253 // Local ward: decide directly (quorum on this instance).
254 const pending = Guardianship.follows.getPending(req.params.id);
255 if (!pending) return res.status(404).json({ error: 'gone' });
256 const allGuardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
257 if (!allGuardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
258 // Acting from the dashboard is an answer (3.6), and the quorum runs over
259 // the available set (3.5): both applied here, the same as over the wire.
260 Guardianship.availability.oneAnswer(me, Date.now());
261 const guardians = Guardianship.availability.availableSet(pending.ward_slug, allGuardians, Date.now());
262 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
263 try {
264 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
265 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
266 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
267 res.json({ ok: true, outcome: r.outcome });
268});
269
270// โ”€โ”€ Wave (FEP-633c ยง5, shaer:wave): a gentle "thinking of you" from a
271// guardian to a ward. A private direct note, never a feed post. Warmth
272// without publishing (Robins besluit).
273router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
274 const site = siteForUser(req);
275 if (!site) return res.status(404).json({ error: 'no_site' });
276 const wardUri = String(req.body?.ward || '').trim();
277 // Only wave at a ward you actually guard.
278 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
279 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
280 const text = String(req.body?.text || '').trim().slice(0, 200) || '๐Ÿ‘‹ thinking of you';
281 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
282 if (!r) return res.status(502).json({ error: 'delivery' });
283 res.json({ ok: true, delivered: r.delivered });
284});
285
286// โ”€โ”€ Adopt a ward: handle โ†’ resolve โ†’ C2S Offer through the same pipeline
287// the Shaer apps use (one path, one behavior).
288router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
289 const site = siteForUser(req);
290 if (!site) return res.status(404).json({ error: 'no_site' });
291 const handle = String(req.body?.handle || '').trim();
292 if (!handle) return res.status(400).json({ error: 'empty_handle' });
293 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
294 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
295 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
296 const me = AP.actorId(base, site.slug);
297 const r = await AP.ingestOutboxActivity(site, req.session.user, {
298 type: 'Offer',
299 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
300 });
301 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
302 // offer is recorded and delivery is retried in the background.
303 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
304 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
305});
306
307// โ”€โ”€ Answer an offer (co-guardian accept/reject, or the candidate's final
308// "complete"). All three are a C2S Accept/Reject on the offer id; the
309// handshake module decides when it commits (ยง3.1).
310// โ”€โ”€ Step away (FEP-633c 3.6.1): the guardian declares itself unavailable โ”€โ”€
311// One direct note with shaer:away and an endTime to every ward, the same
312// path Shaer takes over C2S. Wards on this instance are applied directly (a
313// local inbox never receives its own delivery); the rest travels S2S.
314router.post('/api/away', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
315 const site = siteForUser(req);
316 if (!site) return res.status(404).json({ error: 'no_site' });
317 const days = Math.min(365, Math.max(1, parseInt(req.body?.days, 10) || 0));
318 if (!days) return res.status(400).json({ error: 'away_needs_an_end' });
319 const wards = Guardianship.listWards(site.slug).map((w) => w.other_uri);
320 if (!wards.length) return res.status(409).json({ error: 'no_wards' });
321 const until = Date.now() + days * 24 * 3600 * 1000;
322 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
323 const me = AP.actorId(base, site.slug);
324 let applied = 0;
325 for (const uri of wards) {
326 const wslug = uri.startsWith(`${base}/`) ? uri.replace(/\/+$/, '').split('/').pop() : null;
327 if (wslug && Guardianship.listGuardians(wslug).some((g) => g.other_uri === me)) {
328 Guardianship.availability.declareAway(wslug, me, until);
329 applied++;
330 }
331 }
332 const L = resolveLang(req);
333 const text = i18nT(L, 'guardian.away_msg', { date: new Date(until).toLocaleDateString('nl-NL') });
334 const r = await AP.deliverDirectNote(site, { recipients: wards, text, awayUntil: until }).catch(() => null);
335 if (!applied && !(r && r.id)) return res.status(502).json({ error: 'away_failed' });
336 res.json({ ok: true, until });
337});
338
339// โ”€โ”€ Propose a lapse (FEP-633c 3.6.3) against a dormant co-guardian โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
340// The same C2S pipeline the Shaer apps would use: an Offer of shaer:Lapse.
341// A local ward opens directly; a remote ward gets the proposal delivered,
342// because the ward's server is the one that tallies and enforces.
343router.post('/api/lapse', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
344 const site = siteForUser(req);
345 if (!site) return res.status(404).json({ error: 'no_site' });
346 const ward = String(req.body?.ward || '').trim();
347 const target = String(req.body?.target || '').trim();
348 if (!ward || !target) return res.status(400).json({ error: 'missing_ward_or_target' });
349 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === ward)) {
350 return res.status(403).json({ error: 'not_my_ward' });
351 }
352 const r = await AP.ingestOutboxActivity(site, req.session.user, {
353 type: 'Offer', object: { type: 'shaer:Lapse', 'shaer:ward': ward, object: target },
354 });
355 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'lapse_failed' });
356 res.json({ ok: true, lapse: r.id });
357});
358
359// โ”€โ”€ Answer a forwarded gated-setting proposal (FEP-633c 5.6) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
360// The decision belongs to the ward's server, so the answer travels there as an
361// Accept/Reject on the offer id, exactly like a gated follow's decision.
362router.post('/api/gated/:id', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
363 const site = siteForUser(req);
364 if (!site) return res.status(404).json({ error: 'no_site' });
365 const review = Guardianship.gated.getGatedReview(site.slug, req.params.id);
366 if (!review) return res.status(404).json({ error: 'gone' });
367 const agree = req.body?.answer !== 'reject';
368 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
369 const me = AP.actorId(base, site.slug);
370 const activity = {
371 id: `${me}#gated-${Date.now().toString(36)}`,
372 type: agree ? 'Accept' : 'Reject', actor: me, to: [review.ward_uri], object: review.id,
373 };
374 try { await AP.deliverToActor(site, review.ward_uri, activity); }
375 catch { return res.status(502).json({ error: 'delivery' }); }
376 Guardianship.gated.removeGatedReview(site.slug, review.id);
377 res.json({ ok: true, answer: agree ? 'accept' : 'reject' });
378});
379
380router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
381 const site = siteForUser(req);
382 if (!site) return res.status(404).json({ error: 'no_site' });
383 const offerId = String(req.body?.offer || '').trim();
384 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
385 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
386 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
387 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
388 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
389});
390
391// โ”€โ”€ PWA assets served no-cache, so an update is never masked by the 1-year
392// /assets cache or a stuck install (that was the whole "nothing works after
393// a deploy" bug). Small files; the browser revalidates and gets a 304 when
394// unchanged, the fresh file when changed.
395function pwaAsset(rel, type) {
396 return (req, res) => {
397 res.set('Cache-Control', 'no-cache');
398 res.type(type);
399 res.sendFile(path.join(__dir, '..', 'assets', rel));
400 };
401}
402router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
403router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
404
405// โ”€โ”€ Manage: release a committed ward (local Undo; federation is Fase 4). โ”€โ”€
406/**
407 * What actually happens if this guardian releases this ward?
408 *
409 * Releasing is not one action but two very different ones, and the difference
410 * is the number of guardians the child has left (FEP-633c):
411 * - more than one โ†’ ยง3.3, you step down and the child stays a ward;
412 * - you are the last โ†’ ยง3.4, that is emancipation, and the FEP is explicit
413 * that no single guardian decides it alone (three consenting adults, or a
414 * majority plus two witnesses).
415 * On top of that, today's release is LOCAL: the Undo is not federated yet
416 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
417 * A guardian pressing the button would otherwise believe the child is released.
418 *
419 * Answered on demand rather than in the dashboard state: for a ward we do not
420 * host this reaches out to that ward's server, and nobody should pay for that
421 * on every refresh.
422 */
423router.get('/wards/release-check', requireAuth, async (req, res) => {
424 const site = siteForUser(req);
425 if (!site) return res.status(404).json({ error: 'no_site' });
426 const uri = String(req.query.uri || '').trim();
427 if (!uri) return res.status(400).json({ error: 'empty_uri' });
428 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
429 return res.status(403).json({ error: 'not_my_ward' });
430 }
431 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
432 const local = !!base && uri.startsWith(`${base}/`);
433 let guardians = null; // null = we could not find out; say so rather than guess
434 if (local) {
435 const slug = uri.replace(/\/+$/, '').split('/').pop();
436 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
437 } else {
438 const doc = await AP.fetchActor(uri).catch(() => null);
439 const g = doc && doc['shaer:guardians'];
440 if (Array.isArray(g)) guardians = g.length;
441 else if (typeof g === 'string') guardians = 1;
442 else if (g && Array.isArray(g.items)) guardians = g.items.length;
443 else if (doc) guardians = 0; // the actor answered and names no guardians
444 }
445 res.json({
446 guardians,
447 last: guardians === null ? null : guardians <= 1,
448 local,
449 });
450});
451
452router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
453 const site = siteForUser(req);
454 if (!site) return res.status(404).json({ error: 'no_site' });
455 const uri = String(req.body?.uri || '').trim();
456 if (!uri) return res.status(400).json({ error: 'empty_uri' });
457 // Ending a guardianship is an Undo of the Relationship that travels to the
458 // ward and the other guardians (ยง3.2), not a local delete. Same call the
459 // Guardian apps reach over C2S, so the two cannot drift apart.
460 const r = await Guardianship.endGuardianship(site, uri);
461 if (r.status >= 400) return res.status(r.status).json({ error: r.error });
462 res.json({ ok: true, delivered: r.delivered, guardiansLeft: r.guardiansLeft });
463});
464
465/**
466 * The external-embeds setting of a ward we host: true/false when a guardian has
467 * decided, null when it is still on auto (which means off for a ward) or when
468 * the ward lives elsewhere and the setting is not ours to show.
469 */
470function wardEmbedSetting(uri) { return wardGateSetting(uri, 'external_embeds'); }
471/** The playback gate of a ward we host (5.6): the heavier sibling. */
472function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
473function wardGateSetting(uri, column) {
474 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
475 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
476 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
477 const row = slug ? db.prepare(`SELECT ${column === 'external_playback' ? 'external_playback' : 'external_embeds'} AS v FROM sites WHERE slug = ?`).get(slug) : null;
478 if (!row) return null;
479 return row.v === null || row.v === undefined ? false : row.v === 1;
480}
481
482// โ”€โ”€ Gated feature: may this ward see external (non-fediverse) embeds? โ”€โ”€
483// The first real gated setting (FEP-633c ยง5-style). The gate itself is applied
484// server-side when the feed is serialised, so this endpoint is the only way it
485// can move, and only a committed guardian of THAT ward may move it.
486router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
487 req.body = { ...req.body, feature: req.body?.feature === 'shaer:externalPlayback' ? 'shaer:externalPlayback' : 'shaer:externalEmbeds' };
488 return proposeGated(req, res);
489});
490function proposeGated(req, res) {
491 const site = siteForUser(req);
492 if (!site) return res.status(404).json({ error: 'no_site' });
493 const uri = String(req.body?.uri || '').trim();
494 const allow = req.body?.allow === true;
495 if (!uri) return res.status(400).json({ error: 'empty_uri' });
496 // Only a guardian of this ward, and only for a ward we host: a setting on a
497 // remote ward belongs to that ward's own server (federating it is Fase 4).
498 const isMyWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === uri);
499 if (!isMyWard) return res.status(403).json({ error: 'not_your_ward' });
500 // ยง5.6: propose it to the WARD'S server, wherever that is. The ward's server
501 // tallies (a majority of its guardians, ยง3.5) and enforces. Co-location is
502 // just the case where that server happens to be this one, so it takes the
503 // same road: propose, then let the tally decide. Anything else would make a
504 // guardian on the ward's own instance more powerful than one elsewhere.
505 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
506 const me = AP.actorId(base, site.slug);
507 const feature = req.body.feature; // normalised by the route above
508 const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
509 const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
510 const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
511 const localWard = localSlug ? db.prepare('SELECT slug FROM sites WHERE slug = ?').get(localSlug) : null;
512 if (localWard) {
513 Guardianship.gated.rememberGatedOffer(offerId, localWard.slug, feature, allow);
514 const r = Guardianship.gated.recordGatedVote(localWard.slug, feature, me, allow);
515 // Same forward as the S2S path: without it the other guardians never learn
516 // the proposal exists and a threshold of two can never be met.
517 if (r.state === 'open') {
518 for (const g of Guardianship.listGuardians(localWard.slug).map((x) => x.other_uri)) {
519 if (g === me) continue;
520 AP.deliverToActor(site, g, { ...offer, to: [g] }).catch(() => { /* queued */ });
521 }
522 }
523 return res.json({ ok: true, allow, state: r.state, need: r.need, of: r.of });
524 }
525 AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
526 res.json({ ok: true, allow, state: 'open', federated: true });
527});
528
529// โ”€โ”€ The installable identity: own scope so the Guardian corner installs as
530// its own app next to the site PWA.
531router.get('/manifest.webmanifest', (req, res) => {
532 const site = res.locals.site;
533 res.set('Cache-Control', 'no-cache');
534 res.json({
535 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
536 name: 'Klonkt Guardian',
537 short_name: 'Guardian',
538 description: 'Ward management and help requests for guardians.',
539 scope: '/guardian/',
540 start_url: '/guardian?source=pwa',
541 display: 'standalone',
542 display_override: ['standalone', 'minimal-ui'],
543 orientation: 'any',
544 background_color: '#141a24',
545 theme_color: '#ff6b35',
546 lang: site?.language || 'nl',
547 icons: [
548 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
549 ],
550 });
551});
552
553// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
554router.get('/icon.svg', (req, res) => {
555 const svg = `<?xml version="1.0" encoding="UTF-8"?>
556<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
557 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
558 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
559</svg>`;
560 res.set('Content-Type', 'image/svg+xml');
561 res.set('Cache-Control', 'public, max-age=86400');
562 res.send(svg);
563});
564
565// โ”€โ”€ Losse guardians (Guardian 2): uitnodigen en aansluiten โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
566// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
567// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
568// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
569
570router.post('/invite', requireAuth, (req, res) => {
571 const token = crypto.randomBytes(16).toString('base64url');
572 db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
573 .run(token, req.session.user.id);
574 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
575 const url = `${base}/guardian/join/${token}`;
576 res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
577 <h2>Invite a guardian</h2>
578 <p>Share this link. It lets one person create a guardian account here:</p>
579 <p><a href="${url}">${url}</a></p>
580 <p><a href="/guardian">Back</a></p></body>`);
581});
582
583function joinForm(token, error) {
584 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
585 <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
586 <h2>Become a guardian</h2>
587 <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
588 ${error ? `<p style="color:#b00">${error}</p>` : ''}
589 <form method="post" action="/guardian/join/${token}">
590 <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
591 style="width:100%;padding:10px" autocapitalize="none"></p>
592 <p><input name="password" type="password" placeholder="password" required minlength="8"
593 style="width:100%;padding:10px"></p>
594 <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
595 </form></body>`;
596}
597
598router.get('/join/:token', (req, res) => {
599 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
600 .get(req.params.token);
601 if (!inv) return res.status(404).send('This invite is no longer valid.');
602 res.send(joinForm(req.params.token));
603});
604
605router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
606 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
607 .get(req.params.token);
608 if (!inv) return res.status(404).send('This invite is no longer valid.');
609 const name = String(req.body.name || '').trim().toLowerCase();
610 const password = String(req.body.password || '');
611 if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
612 if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
613 if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
614 return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
615 }
616 const userId = crypto.randomUUID();
617 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
618 .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
619 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
620 .run(crypto.randomUUID(), name, name, userId);
621 db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
622 .run(userId, req.params.token);
623 req.session.user = { id: userId, username: name, role: 'member' };
624 res.redirect('/guardian');
625});
626
627export default router;
Note: See TracBrowser for help on using the repository browser.