source: Klonkt/src/routes/guardian.js@ 2a76184

main
Last change on this file since 2a76184 was 2a76184, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Guardians kunnen linkvoorbeelden aan- en uitzetten per ward

De eerste echte gated feature met een knop erbij. De gate bestond al server-side
(sites.external_embeds, uit voor een ward), maar er was geen enkele manier om
hem te bewegen: een ward zag dus nooit een linkvoorbeeld, en daar was niets aan
te doen behalve in de database duiken. Dat is precies de verkeerde plek voor een
beslissing die volgens het ontwerp bij de guardians hoort.

In de Guardian-PWA staat nu per ward een knop die de huidige stand toont en
omzet. Het endpoint accepteert het alleen van een COMMITTED guardian van die
ward, en alleen voor een ward die wij hosten: de instelling van een ward
elders hoort op de server van die ward thuis, dus die tonen we als
niet-instelbaar in plaats van te doen alsof.

Changed files:
src/routes/guardian.js

  • POST /guardian/wards/embeds, met controle op guardian-van-deze-ward en lokale ward
  • wardEmbedSetting(): huidige stand mee in de wards-lijst (null = niet van ons)

src/assets/js/guardian.js

  • knop per ward, toont de stand en zet hem om

src/services/i18n.js

  • guardian.embeds_on / embeds_off in nl, en, de

remarks: 219 tests groen. Federeren van deze instelling naar een remote ward is
een volgende stap (hoort bij shaer-3kp).

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

  • Property mode set to 100644
File size: 20.9 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 } from '../middleware/render.js';
22
23const router = express.Router();
24const __dir = path.dirname(fileURLToPath(import.meta.url));
25
26/** The acting site: ?site=slug when owned, else the user's first site. */
27function siteForUser(req) {
28 const userId = req.session.user.id;
29 const want = String(req.query.site || req.body?.site || '').trim();
30 if (want) {
31 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
32 if (s) return s;
33 }
34 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
35}
36
37/** Everything the dashboard shows, one shape for page and API. */
38function uiStrings(L) {
39 const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
40 'pending', 'active', 'retract', 'release', 'release_confirm', 'open', 'push_unavailable',
41 'embeds_on', 'embeds_off',
42 'accept', 'reject', 'complete', 'awaiting_others', 'coguard'];
43 const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
44 s.wave = i18nT(L, 'guardian.wave');
45 s.waved = i18nT(L, 'guardian.waved');
46 return s;
47}
48
49function dashboardState(site, L) {
50 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
51 const me = AP.actorId(base, site.slug);
52 const help = db.prepare(
53 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at
54 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
55 ).all(site.slug);
56 return {
57 site: site.slug,
58 me,
59 // Committed wards, each carrying the gated settings a guardian may change.
60 // `embeds` is null for a ward we do not host: that setting lives on the
61 // ward's own server, so we show it as not-adjustable rather than lying.
62 wards: Guardianship.listWards(site.slug).map((w) => ({ ...w, embeds: wardEmbedSetting(w.other_uri) })),
63 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
64 help,
65 strings: uiStrings(L),
66 };
67}
68
69// ── The PWA page ─────────────────────────────────────────────────────────
70router.get('/', requireAuth, (req, res) => {
71 const site = siteForUser(req);
72 const L = resolveLang(req);
73 if (!site) return res.status(404).send('No site for this account.');
74 const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
75 // This standalone PWA page is rendered directly (not through renderPage), so
76 // the CSP nonce must be injected here — otherwise strict-dynamic blocks
77 // guardian.js and the whole dashboard is dead (buttons do nothing).
78 res.render('pages/guardian', {
79 state: dashboardState(site, L),
80 sites,
81 lang: L,
82 t: (k, v) => i18nT(L, k, v),
83 cspNonce: res.locals.cspNonce,
84 }, (err, html) => {
85 if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
86 res.send(injectCspNonce(html, res.locals.cspNonce));
87 });
88});
89
90// ── JSON state for refreshes ─────────────────────────────────────────────
91router.get('/api/state', requireAuth, (req, res) => {
92 const site = siteForUser(req);
93 if (!site) return res.status(404).json({ error: 'no_site' });
94 res.json(dashboardState(site, resolveLang(req)));
95});
96
97// ── Meekijken (FEP-633c §5, interop-hoofdroute): a committed guardian FOLLOWS
98// its wards, so their posts (incl. followers-only) are DELIVERED to the
99// guardian's inbox → timeline. The follow is the mechanism; no new fetch.
100// First contact also backfills the ward's recent PUBLIC posts as a cold
101// start so the corner is not empty before delivery catches up.
102function ensureWardConnections(site) {
103 let wards;
104 try { wards = Guardianship.listWards(site.slug); } catch { return; }
105 for (const w of wards) {
106 const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
107 .get(site.slug, w.other_uri);
108 if (already) continue;
109 // Follow (guardian's server auto-accepts today; §5.3 gating is a later fase).
110 AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
111 // Cold start: pull recent public posts now so oma sees something at once.
112 AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
113 }
114}
115
116// ── The wards' corner: your wards' posts, read-only. No reply, no share; a
117// guardian watches, it does not publish (Robins besluit).
118router.get('/api/feed', requireAuth, (req, res) => {
119 const site = siteForUser(req);
120 if (!site) return res.status(404).json({ error: 'no_site' });
121 ensureWardConnections(site);
122 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
123 // Only show the wards you actually guard (the timeline can hold more).
124 const items = AP.getTimeline(site.slug, 60, 0)
125 .filter((p) => wardUris.has(p.author_uri))
126 .map((p) => ({
127 id: p.id,
128 author: p.author_handle || p.author_name || p.author_uri,
129 authorName: p.author_name,
130 authorIcon: p.author_icon,
131 content: p.content,
132 url: p.url,
133 published: p.published || p.created_at,
134 cw: p.cw || null,
135 media: p.media_json ? JSON.parse(p.media_json) : [],
136 }));
137 res.json({ items, following: wardUris.size });
138});
139
140// ── Follow-gating (FEP-633c §5.3): pending follows on MY wards, for me to
141// approve. Ward and guardian are co-located on the family Klonkt here, so
142// the guardian reads its wards' pending follows locally.
143function wardSlugsOf(site) {
144 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
145 return Guardianship.listWards(site.slug)
146 .map((w) => (w.other_uri.startsWith(base) ? w.other_uri.split('/').pop() : null))
147 .filter(Boolean);
148}
149
150router.get('/api/follow-requests', requireAuth, (req, res) => {
151 const site = siteForUser(req);
152 if (!site) return res.status(404).json({ error: 'no_site' });
153 const items = [];
154 const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
155 // Local wards (guardian co-located): read the pending follows directly.
156 for (const wardSlug of wardSlugsOf(site)) {
157 for (const f of Guardianship.follows.listForWard(wardSlug)) {
158 items.push({ id: f.id, ward: `@${wardSlug}@${host}`, follower: f.follower_handle || f.follower_name || f.follower_uri, followerIcon: f.follower_icon, remote: false, created: f.created_at });
159 }
160 }
161 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
162 for (const rev of Guardianship.follows.listReviews(site.slug)) {
163 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
164 items.push({ id: rev.id, ward: wardName, follower: rev.follower_handle || rev.follower_uri, followerIcon: rev.follower_icon, remote: true, created: rev.created_at });
165 }
166 res.json({ items });
167});
168
169router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
170 const site = siteForUser(req);
171 if (!site) return res.status(404).json({ error: 'no_site' });
172 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
173 const me = AP.actorId(base, site.slug);
174 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
175
176 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
177 // which tallies quorum and returns the Accept(Follow) to the follower.
178 const review = Guardianship.follows.getReview(site.slug, req.params.id);
179 if (review) {
180 try { await AP.sendFollowDecision(site, review, decision); }
181 catch { return res.status(502).json({ error: 'delivery' }); }
182 Guardianship.follows.removeReview(site.slug, req.params.id);
183 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
184 }
185
186 // Local ward: decide directly (quorum on this instance).
187 const pending = Guardianship.follows.getPending(req.params.id);
188 if (!pending) return res.status(404).json({ error: 'gone' });
189 const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
190 if (!guardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
191 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
192 try {
193 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
194 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
195 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
196 res.json({ ok: true, outcome: r.outcome });
197});
198
199// ── Wave (FEP-633c §5, shaer:wave): a gentle "thinking of you" from a
200// guardian to a ward. A private direct note, never a feed post. Warmth
201// without publishing (Robins besluit).
202router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
203 const site = siteForUser(req);
204 if (!site) return res.status(404).json({ error: 'no_site' });
205 const wardUri = String(req.body?.ward || '').trim();
206 // Only wave at a ward you actually guard.
207 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
208 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
209 const text = String(req.body?.text || '').trim().slice(0, 200) || '👋 thinking of you';
210 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
211 if (!r) return res.status(502).json({ error: 'delivery' });
212 res.json({ ok: true, delivered: r.delivered });
213});
214
215// ── Adopt a ward: handle → resolve → C2S Offer through the same pipeline
216// the Shaer apps use (one path, one behavior).
217router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
218 const site = siteForUser(req);
219 if (!site) return res.status(404).json({ error: 'no_site' });
220 const handle = String(req.body?.handle || '').trim();
221 if (!handle) return res.status(400).json({ error: 'empty_handle' });
222 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
223 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
224 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
225 const me = AP.actorId(base, site.slug);
226 const r = await AP.ingestOutboxActivity(site, req.session.user, {
227 type: 'Offer',
228 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
229 });
230 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
231 // offer is recorded and delivery is retried in the background.
232 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
233 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
234});
235
236// ── Answer an offer (co-guardian accept/reject, or the candidate's final
237// "complete"). All three are a C2S Accept/Reject on the offer id; the
238// handshake module decides when it commits (§3.1).
239router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
240 const site = siteForUser(req);
241 if (!site) return res.status(404).json({ error: 'no_site' });
242 const offerId = String(req.body?.offer || '').trim();
243 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
244 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
245 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
246 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
247 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
248});
249
250// ── PWA assets served no-cache, so an update is never masked by the 1-year
251// /assets cache or a stuck install (that was the whole "nothing works after
252// a deploy" bug). Small files; the browser revalidates and gets a 304 when
253// unchanged, the fresh file when changed.
254function pwaAsset(rel, type) {
255 return (req, res) => {
256 res.set('Cache-Control', 'no-cache');
257 res.type(type);
258 res.sendFile(path.join(__dir, '..', 'assets', rel));
259 };
260}
261router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
262router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
263
264// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
265router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
266 const site = siteForUser(req);
267 if (!site) return res.status(404).json({ error: 'no_site' });
268 const uri = String(req.body?.uri || '').trim();
269 if (!uri) return res.status(400).json({ error: 'empty_uri' });
270 Guardianship.removeRelation(site.slug, 'guardian', uri);
271 res.json({ ok: true });
272});
273
274/**
275 * The external-embeds setting of a ward we host: true/false when a guardian has
276 * decided, null when it is still on auto (which means off for a ward) or when
277 * the ward lives elsewhere and the setting is not ours to show.
278 */
279function wardEmbedSetting(uri) {
280 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
281 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
282 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
283 const row = slug ? db.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get(slug) : null;
284 if (!row) return null;
285 return row.external_embeds === null || row.external_embeds === undefined ? false : row.external_embeds === 1;
286}
287
288// ── Gated feature: may this ward see external (non-fediverse) embeds? ──
289// The first real gated setting (FEP-633c §5-style). The gate itself is applied
290// server-side when the feed is serialised, so this endpoint is the only way it
291// can move, and only a committed guardian of THAT ward may move it.
292router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
293 const site = siteForUser(req);
294 if (!site) return res.status(404).json({ error: 'no_site' });
295 const uri = String(req.body?.uri || '').trim();
296 const allow = req.body?.allow === true;
297 if (!uri) return res.status(400).json({ error: 'empty_uri' });
298 // Only a guardian of this ward, and only for a ward we host: a setting on a
299 // remote ward belongs to that ward's own server (federating it is Fase 4).
300 const isMyWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === uri);
301 if (!isMyWard) return res.status(403).json({ error: 'not_your_ward' });
302 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
303 const slug = (base && uri.startsWith(`${base}/`)) ? uri.trim().replace(/\/+$/, '').split('/').pop() : null;
304 const ward = slug ? db.prepare('SELECT id, slug FROM sites WHERE slug = ?').get(slug) : null;
305 if (!ward) return res.status(400).json({ error: 'remote_ward_not_supported' });
306 db.prepare('UPDATE sites SET external_embeds = ? WHERE id = ?').run(allow ? 1 : 0, ward.id);
307 res.json({ ok: true, allow });
308});
309
310// ── The installable identity: own scope so the Guardian corner installs as
311// its own app next to the site PWA.
312router.get('/manifest.webmanifest', (req, res) => {
313 const site = res.locals.site;
314 res.set('Cache-Control', 'no-cache');
315 res.json({
316 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
317 name: 'Klonkt Guardian',
318 short_name: 'Guardian',
319 description: 'Ward management and help requests for guardians.',
320 scope: '/guardian/',
321 start_url: '/guardian?source=pwa',
322 display: 'standalone',
323 display_override: ['standalone', 'minimal-ui'],
324 orientation: 'any',
325 background_color: '#141a24',
326 theme_color: '#ff6b35',
327 lang: site?.language || 'nl',
328 icons: [
329 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
330 ],
331 });
332});
333
334// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
335router.get('/icon.svg', (req, res) => {
336 const svg = `<?xml version="1.0" encoding="UTF-8"?>
337<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
338 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
339 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
340</svg>`;
341 res.set('Content-Type', 'image/svg+xml');
342 res.set('Cache-Control', 'public, max-age=86400');
343 res.send(svg);
344});
345
346// ── Losse guardians (Guardian 2): uitnodigen en aansluiten ───────────────
347// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
348// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
349// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
350
351router.post('/invite', requireAuth, (req, res) => {
352 const token = crypto.randomBytes(16).toString('base64url');
353 db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
354 .run(token, req.session.user.id);
355 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
356 const url = `${base}/guardian/join/${token}`;
357 res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
358 <h2>Invite a guardian</h2>
359 <p>Share this link. It lets one person create a guardian account here:</p>
360 <p><a href="${url}">${url}</a></p>
361 <p><a href="/guardian">Back</a></p></body>`);
362});
363
364function joinForm(token, error) {
365 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
366 <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
367 <h2>Become a guardian</h2>
368 <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
369 ${error ? `<p style="color:#b00">${error}</p>` : ''}
370 <form method="post" action="/guardian/join/${token}">
371 <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
372 style="width:100%;padding:10px" autocapitalize="none"></p>
373 <p><input name="password" type="password" placeholder="password" required minlength="8"
374 style="width:100%;padding:10px"></p>
375 <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
376 </form></body>`;
377}
378
379router.get('/join/:token', (req, res) => {
380 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
381 .get(req.params.token);
382 if (!inv) return res.status(404).send('This invite is no longer valid.');
383 res.send(joinForm(req.params.token));
384});
385
386router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
387 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
388 .get(req.params.token);
389 if (!inv) return res.status(404).send('This invite is no longer valid.');
390 const name = String(req.body.name || '').trim().toLowerCase();
391 const password = String(req.body.password || '');
392 if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
393 if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
394 if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
395 return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
396 }
397 const userId = crypto.randomUUID();
398 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
399 .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
400 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
401 .run(crypto.randomUUID(), name, name, userId);
402 db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
403 .run(userId, req.params.token);
404 req.session.user = { id: userId, username: name, role: 'member' };
405 res.redirect('/guardian');
406});
407
408export default router;
Note: See TracBrowser for help on using the repository browser.