source: Klonkt/src/routes/guardian.js@ 742ba7e

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

Een ward loslaten vraagt eerst, en zegt wat het doet

Loslaten zat achter een window.confirm met een lap tekst erin. Dat is precies
het soort dialoog dat mensen wegklikken om ervan af te zijn, terwijl dit de
zwaarste knop in de app is. Nu is het een stap in het paneel: eerst de gevolgen,
dan ja of nee, met nee links zodat de uitweg de makkelijke is om te raken.

De waarschuwing wordt opgebouwd uit wat er echt aan de hand is, niet uit een
vaste zin, want loslaten betekent twee verschillende dingen. Blijven er andere
guardians over, dan stap jij op en blijft het kind een ward (FEP-633c 3.3). Ben
je de laatste, dan is het emancipatie, en 3.4 zegt uitdrukkelijk dat geen enkele
guardian daar alleen over gaat: drie instemmende volwassenen, of een meerderheid
met twee getuigen. Een nieuwe route zoekt dat op het moment van drukken op, want
voor een ward die wij niet hosten moet daarvoor die server bevraagd worden en
dat hoort niet bij elke ververs-ronde.

En het derde ding dat er hoorde te staan: het Undo federeert nog niet (fase 4 in
relations.js), dus vandaag haalt de knop de band alleen hier weg terwijl de
server van het kind je gewoon als guardian blijft noemen. Zonder die regel drukt
een guardian erop in de overtuiging dat het kind losgelaten is.

Changed files:
src/routes/guardian.js

  • GET /guardian/wards/release-check: telt de guardians van dit kind (lokaal uit de relaties, remote uit shaer:guardians van de actor) en weigert een ward die niet van jou is
  • de nieuwe labels toegevoegd aan uiStrings

src/assets/js/guardian.js

  • releaseStep: de waarschuwing plus ja/nee, in plaats van window.confirm
  • de site gaat mee in de check, anders kijkt de server naar de verkeerde

src/assets/css/guardian.css

  • --danger toegevoegd; die bestond niet, waardoor het kader en de knop onzichtbaar bleven
  • opmaak voor het waarschuwingsblok

src/services/i18n.js

  • 24 strings voor de waarschuwing in nl, en, de

test/guardian-panel.test.js

  • loslaten gaat in twee stappen en niet via window.confirm
  • elke CSS-variabele die de PWA gebruikt is er ook een die hij definieert

remarks: alle vier de gevallen in de browser nagelopen: laatste guardian,
meerdere guardians, onbereikbare server, en een ward die niet van je is. Nee
zet de knop terug zonder iets te doen, ja verwijdert alleen die ene relatie.

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

  • Property mode set to 100644
File size: 25.6 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 const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
52 s.wave = i18nT(L, 'guardian.wave');
53 s.waved = i18nT(L, 'guardian.waved');
54 return s;
55}
56
57function dashboardState(site, L) {
58 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
59 const me = AP.actorId(base, site.slug);
60 const help = db.prepare(
61 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at,
62 emoji_json, actor_emoji_json, media_json, quote_json, embed_json
63 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
64 ).all(site.slug).map((h) => ({
65 ...h,
66 // The dashboard is built in the browser, so it gets the body finished: the
67 // same partial de Krant and Berichten use. A ๐Ÿ›Ÿ often carries a screenshot
68 // and a link to the post it is about; both belong in the card.
69 body_html: renderNoteBody(h, L),
70 name_html: emojiName(h.actor_name || '', h.actor_emoji_json),
71 // In the site's own timezone, the same as everywhere else in Klonkt. The
72 // PWA used to slice the raw UTC string, so a 20:20 call for help read 18:20.
73 when_text: formatDateTime(h.published || h.created_at),
74 }));
75 return {
76 site: site.slug,
77 me,
78 // Committed wards, each carrying the gated settings a guardian may change.
79 // `embeds` is null for a ward we do not host: that setting lives on the
80 // ward's own server, so we show it as not-adjustable rather than lying.
81 wards: Guardianship.listWards(site.slug).map((w) => ({ ...w, embeds: wardEmbedSetting(w.other_uri) })),
82 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
83 help,
84 strings: uiStrings(L),
85 };
86}
87
88// โ”€โ”€ The PWA page โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
89router.get('/', requireAuth, (req, res) => {
90 const site = siteForUser(req);
91 const L = resolveLang(req);
92 if (!site) return res.status(404).send('No site for this account.');
93 const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
94 // This standalone PWA page is rendered directly (not through renderPage), so
95 // the CSP nonce must be injected here โ€” otherwise strict-dynamic blocks
96 // guardian.js and the whole dashboard is dead (buttons do nothing).
97 res.render('pages/guardian', {
98 state: dashboardState(site, L),
99 sites,
100 lang: L,
101 t: (k, v) => i18nT(L, k, v),
102 cspNonce: res.locals.cspNonce,
103 }, (err, html) => {
104 if (err) { console.error('[guardian] render error', err); return res.status(500).send('Internal Server Error'); }
105 res.send(injectCspNonce(html, res.locals.cspNonce));
106 });
107});
108
109// โ”€โ”€ JSON state for refreshes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
110router.get('/api/state', requireAuth, (req, res) => {
111 const site = siteForUser(req);
112 if (!site) return res.status(404).json({ error: 'no_site' });
113 res.json(dashboardState(site, resolveLang(req)));
114});
115
116// โ”€โ”€ Meekijken (FEP-633c ยง5, interop-hoofdroute): a committed guardian FOLLOWS
117// its wards, so their posts (incl. followers-only) are DELIVERED to the
118// guardian's inbox โ†’ timeline. The follow is the mechanism; no new fetch.
119// First contact also backfills the ward's recent PUBLIC posts as a cold
120// start so the corner is not empty before delivery catches up.
121function ensureWardConnections(site) {
122 let wards;
123 try { wards = Guardianship.listWards(site.slug); } catch { return; }
124 for (const w of wards) {
125 const already = db.prepare('SELECT 1 FROM ap_following WHERE slug = ? AND actor_uri = ?')
126 .get(site.slug, w.other_uri);
127 if (already) continue;
128 // Follow (guardian's server auto-accepts today; ยง5.3 gating is a later fase).
129 AP.followActor(site, w.other_uri).catch(() => { /* retried by the queue */ });
130 // Cold start: pull recent public posts now so oma sees something at once.
131 AP.backfillFromOutbox(site.slug, w.other_uri).catch(() => { /* best-effort */ });
132 }
133}
134
135// โ”€โ”€ The wards' corner: your wards' posts, read-only. No reply, no share; a
136// guardian watches, it does not publish (Robins besluit).
137router.get('/api/feed', requireAuth, (req, res) => {
138 const site = siteForUser(req);
139 if (!site) return res.status(404).json({ error: 'no_site' });
140 ensureWardConnections(site);
141 const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
142 // Only show the wards you actually guard (the timeline can hold more).
143 const items = AP.getTimeline(site.slug, 60, 0)
144 .filter((p) => wardUris.has(p.author_uri))
145 .map((p) => ({
146 id: p.id,
147 author: p.author_handle || p.author_name || p.author_uri,
148 authorUri: p.author_uri, // the grouping key: which child's panel this belongs in
149 authorName: p.author_name,
150 authorIcon: p.author_icon,
151 content: p.content,
152 url: p.url,
153 published: p.published || p.created_at,
154 when_text: formatDateTime(p.published || p.created_at),
155 cw: p.cw || null,
156 media: p.media_json ? JSON.parse(p.media_json) : [],
157 }));
158 res.json({ items, following: wardUris.size });
159});
160
161// โ”€โ”€ Follow-gating (FEP-633c ยง5.3): pending follows on MY wards, for me to
162// approve. Ward and guardian are co-located on the family Klonkt here, so
163// the guardian reads its wards' pending follows locally.
164function wardSlugsOf(site) {
165 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
166 return Guardianship.listWards(site.slug)
167 .map((w) => (w.other_uri.startsWith(base) ? { slug: w.other_uri.split('/').pop(), uri: w.other_uri } : null))
168 .filter(Boolean);
169}
170
171router.get('/api/follow-requests', requireAuth, (req, res) => {
172 const site = siteForUser(req);
173 if (!site) return res.status(404).json({ error: 'no_site' });
174 const items = [];
175 const host = (() => { try { return new URL(process.env.PUBLIC_BASE_URL || '').host; } catch { return ''; } })();
176 // wardUri is the grouping key for the per-ward panel: the handle is for
177 // reading, the URI is what identifies the child across both cases below.
178 // Local wards (guardian co-located): read the pending follows directly.
179 for (const w of wardSlugsOf(site)) {
180 for (const f of Guardianship.follows.listForWard(w.slug)) {
181 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 });
182 }
183 }
184 // Remote wards: the copies forwarded here as Offer(Follow) (cross-instance).
185 for (const rev of Guardianship.follows.listReviews(site.slug)) {
186 const wardName = (() => { try { const u = new URL(rev.ward_uri); return `@${u.pathname.split('/').pop()}@${u.host}`; } catch { return rev.ward_uri; } })();
187 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 });
188 }
189 res.json({ items });
190});
191
192router.post('/api/follow/:id', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
193 const site = siteForUser(req);
194 if (!site) return res.status(404).json({ error: 'no_site' });
195 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
196 const me = AP.actorId(base, site.slug);
197 const decision = req.body?.decision === 'reject' ? 'reject' : 'approve';
198
199 // Remote ward: a forwarded copy. Send my Accept/Reject back to the ward,
200 // which tallies quorum and returns the Accept(Follow) to the follower.
201 const review = Guardianship.follows.getReview(site.slug, req.params.id);
202 if (review) {
203 try { await AP.sendFollowDecision(site, review, decision); }
204 catch { return res.status(502).json({ error: 'delivery' }); }
205 Guardianship.follows.removeReview(site.slug, req.params.id);
206 return res.json({ ok: true, outcome: decision === 'reject' ? 'rejected' : 'sent' });
207 }
208
209 // Local ward: decide directly (quorum on this instance).
210 const pending = Guardianship.follows.getPending(req.params.id);
211 if (!pending) return res.status(404).json({ error: 'gone' });
212 const guardians = Guardianship.listGuardians(pending.ward_slug).map((g) => g.other_uri);
213 if (!guardians.includes(me)) return res.status(403).json({ error: 'not_a_guardian' });
214 const r = Guardianship.follows.decide(pending.id, me, decision, guardians);
215 try {
216 if (r.outcome === 'approved') { await AP.acceptGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
217 else if (r.outcome === 'rejected') { await AP.rejectGatedFollow(r.follow); Guardianship.follows.remove(r.follow.id); }
218 } catch (e) { return res.status(502).json({ error: 'delivery', outcome: r.outcome }); }
219 res.json({ ok: true, outcome: r.outcome });
220});
221
222// โ”€โ”€ Wave (FEP-633c ยง5, shaer:wave): a gentle "thinking of you" from a
223// guardian to a ward. A private direct note, never a feed post. Warmth
224// without publishing (Robins besluit).
225router.post('/api/wave', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
226 const site = siteForUser(req);
227 if (!site) return res.status(404).json({ error: 'no_site' });
228 const wardUri = String(req.body?.ward || '').trim();
229 // Only wave at a ward you actually guard.
230 const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
231 if (!wardUri || !isWard) return res.status(403).json({ error: 'not_your_ward' });
232 const text = String(req.body?.text || '').trim().slice(0, 200) || '๐Ÿ‘‹ thinking of you';
233 const r = await AP.deliverDirectNote(site, { recipients: [wardUri], text, wave: true }).catch(() => null);
234 if (!r) return res.status(502).json({ error: 'delivery' });
235 res.json({ ok: true, delivered: r.delivered });
236});
237
238// โ”€โ”€ Adopt a ward: handle โ†’ resolve โ†’ C2S Offer through the same pipeline
239// the Shaer apps use (one path, one behavior).
240router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
241 const site = siteForUser(req);
242 if (!site) return res.status(404).json({ error: 'no_site' });
243 const handle = String(req.body?.handle || '').trim();
244 if (!handle) return res.status(400).json({ error: 'empty_handle' });
245 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
246 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
247 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
248 const me = AP.actorId(base, site.slug);
249 const r = await AP.ingestOutboxActivity(site, req.session.user, {
250 type: 'Offer',
251 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
252 });
253 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
254 // offer is recorded and delivery is retried in the background.
255 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
256 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
257});
258
259// โ”€โ”€ Answer an offer (co-guardian accept/reject, or the candidate's final
260// "complete"). All three are a C2S Accept/Reject on the offer id; the
261// handshake module decides when it commits (ยง3.1).
262router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
263 const site = siteForUser(req);
264 if (!site) return res.status(404).json({ error: 'no_site' });
265 const offerId = String(req.body?.offer || '').trim();
266 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
267 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
268 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
269 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
270 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
271});
272
273// โ”€โ”€ PWA assets served no-cache, so an update is never masked by the 1-year
274// /assets cache or a stuck install (that was the whole "nothing works after
275// a deploy" bug). Small files; the browser revalidates and gets a 304 when
276// unchanged, the fresh file when changed.
277function pwaAsset(rel, type) {
278 return (req, res) => {
279 res.set('Cache-Control', 'no-cache');
280 res.type(type);
281 res.sendFile(path.join(__dir, '..', 'assets', rel));
282 };
283}
284router.get('/app.js', pwaAsset('js/guardian.js', 'application/javascript'));
285router.get('/app.css', pwaAsset('css/guardian.css', 'text/css'));
286
287// โ”€โ”€ Manage: release a committed ward (local Undo; federation is Fase 4). โ”€โ”€
288/**
289 * What actually happens if this guardian releases this ward?
290 *
291 * Releasing is not one action but two very different ones, and the difference
292 * is the number of guardians the child has left (FEP-633c):
293 * - more than one โ†’ ยง3.3, you step down and the child stays a ward;
294 * - you are the last โ†’ ยง3.4, that is emancipation, and the FEP is explicit
295 * that no single guardian decides it alone (three consenting adults, or a
296 * majority plus two witnesses).
297 * On top of that, today's release is LOCAL: the Undo is not federated yet
298 * (relations.js, fase 4), so the ward's server keeps listing this guardian.
299 * A guardian pressing the button would otherwise believe the child is released.
300 *
301 * Answered on demand rather than in the dashboard state: for a ward we do not
302 * host this reaches out to that ward's server, and nobody should pay for that
303 * on every refresh.
304 */
305router.get('/wards/release-check', requireAuth, async (req, res) => {
306 const site = siteForUser(req);
307 if (!site) return res.status(404).json({ error: 'no_site' });
308 const uri = String(req.query.uri || '').trim();
309 if (!uri) return res.status(400).json({ error: 'empty_uri' });
310 if (!Guardianship.listWards(site.slug).some((w) => w.other_uri === uri)) {
311 return res.status(403).json({ error: 'not_my_ward' });
312 }
313 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
314 const local = !!base && uri.startsWith(`${base}/`);
315 let guardians = null; // null = we could not find out; say so rather than guess
316 if (local) {
317 const slug = uri.replace(/\/+$/, '').split('/').pop();
318 try { guardians = Guardianship.listGuardians(slug).length; } catch { /* stays null */ }
319 } else {
320 const doc = await AP.fetchActor(uri).catch(() => null);
321 const g = doc && doc['shaer:guardians'];
322 if (Array.isArray(g)) guardians = g.length;
323 else if (typeof g === 'string') guardians = 1;
324 else if (g && Array.isArray(g.items)) guardians = g.items.length;
325 else if (doc) guardians = 0; // the actor answered and names no guardians
326 }
327 res.json({
328 guardians,
329 last: guardians === null ? null : guardians <= 1,
330 local,
331 federates: false, // the Undo does not travel yet (ยง3.2, fase 4)
332 });
333});
334
335router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
336 const site = siteForUser(req);
337 if (!site) return res.status(404).json({ error: 'no_site' });
338 const uri = String(req.body?.uri || '').trim();
339 if (!uri) return res.status(400).json({ error: 'empty_uri' });
340 Guardianship.removeRelation(site.slug, 'guardian', uri);
341 res.json({ ok: true });
342});
343
344/**
345 * The external-embeds setting of a ward we host: true/false when a guardian has
346 * decided, null when it is still on auto (which means off for a ward) or when
347 * the ward lives elsewhere and the setting is not ours to show.
348 */
349function wardEmbedSetting(uri) {
350 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
351 if (!base || !String(uri || '').startsWith(`${base}/`)) return null;
352 const slug = String(uri).trim().replace(/\/+$/, '').split('/').pop();
353 const row = slug ? db.prepare('SELECT external_embeds FROM sites WHERE slug = ?').get(slug) : null;
354 if (!row) return null;
355 return row.external_embeds === null || row.external_embeds === undefined ? false : row.external_embeds === 1;
356}
357
358// โ”€โ”€ Gated feature: may this ward see external (non-fediverse) embeds? โ”€โ”€
359// The first real gated setting (FEP-633c ยง5-style). The gate itself is applied
360// server-side when the feed is serialised, so this endpoint is the only way it
361// can move, and only a committed guardian of THAT ward may move it.
362router.post('/wards/embeds', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
363 const site = siteForUser(req);
364 if (!site) return res.status(404).json({ error: 'no_site' });
365 const uri = String(req.body?.uri || '').trim();
366 const allow = req.body?.allow === true;
367 if (!uri) return res.status(400).json({ error: 'empty_uri' });
368 // Only a guardian of this ward, and only for a ward we host: a setting on a
369 // remote ward belongs to that ward's own server (federating it is Fase 4).
370 const isMyWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === uri);
371 if (!isMyWard) return res.status(403).json({ error: 'not_your_ward' });
372 // ยง5.6: propose it to the WARD'S server, wherever that is. The ward's server
373 // tallies (a majority of its guardians, ยง3.5) and enforces. Co-location is
374 // just the case where that server happens to be this one, so it takes the
375 // same road: propose, then let the tally decide. Anything else would make a
376 // guardian on the ward's own instance more powerful than one elsewhere.
377 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
378 const me = AP.actorId(base, site.slug);
379 const feature = 'shaer:externalEmbeds';
380 const offerId = `${me}/gated/${Date.now().toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
381 const offer = Guardianship.gated.buildGatedOffer(offerId, me, uri, feature, allow);
382 const localSlug = (base && uri.startsWith(`${base}/`)) ? uri.replace(/\/+$/, '').split('/').pop() : null;
383 const localWard = localSlug ? db.prepare('SELECT slug FROM sites WHERE slug = ?').get(localSlug) : null;
384 if (localWard) {
385 Guardianship.gated.rememberGatedOffer(offerId, localWard.slug, feature, allow);
386 const r = Guardianship.gated.recordGatedVote(localWard.slug, feature, me, allow);
387 return res.json({ ok: true, allow, state: r.state, need: r.need, of: r.of });
388 }
389 AP.deliverToActor(site, uri, offer).catch(() => { /* queued, best-effort */ });
390 res.json({ ok: true, allow, state: 'open', federated: true });
391});
392
393// โ”€โ”€ The installable identity: own scope so the Guardian corner installs as
394// its own app next to the site PWA.
395router.get('/manifest.webmanifest', (req, res) => {
396 const site = res.locals.site;
397 res.set('Cache-Control', 'no-cache');
398 res.json({
399 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
400 name: 'Klonkt Guardian',
401 short_name: 'Guardian',
402 description: 'Ward management and help requests for guardians.',
403 scope: '/guardian/',
404 start_url: '/guardian?source=pwa',
405 display: 'standalone',
406 display_override: ['standalone', 'minimal-ui'],
407 orientation: 'any',
408 background_color: '#141a24',
409 theme_color: '#ff6b35',
410 lang: site?.language || 'nl',
411 icons: [
412 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
413 ],
414 });
415});
416
417// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
418router.get('/icon.svg', (req, res) => {
419 const svg = `<?xml version="1.0" encoding="UTF-8"?>
420<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
421 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
422 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
423</svg>`;
424 res.set('Content-Type', 'image/svg+xml');
425 res.set('Cache-Control', 'public, max-age=86400');
426 res.send(svg);
427});
428
429// โ”€โ”€ Losse guardians (Guardian 2): uitnodigen en aansluiten โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
430// De familie nodigt oma uit; zij kiest naam + wachtwoord en heeft daarmee een
431// guardian-only account: user + minimale site (guardian_only=1). Alles wat al
432// per slug werkt (actor, inbox, offers, push, deze PWA) werkt dan meteen.
433
434router.post('/invite', requireAuth, (req, res) => {
435 const token = crypto.randomBytes(16).toString('base64url');
436 db.prepare('INSERT INTO ap_guardian_invites (token, created_by) VALUES (?,?)')
437 .run(token, req.session.user.id);
438 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
439 const url = `${base}/guardian/join/${token}`;
440 res.send(`<!doctype html><meta charset="utf-8"><body style="font-family:sans-serif;max-width:480px;margin:40px auto">
441 <h2>Invite a guardian</h2>
442 <p>Share this link. It lets one person create a guardian account here:</p>
443 <p><a href="${url}">${url}</a></p>
444 <p><a href="/guardian">Back</a></p></body>`);
445});
446
447function joinForm(token, error) {
448 return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
449 <body style="font-family:sans-serif;max-width:420px;margin:40px auto">
450 <h2>Become a guardian</h2>
451 <p>Watch over someone you care about. Pick a name and a password; that is all.</p>
452 ${error ? `<p style="color:#b00">${error}</p>` : ''}
453 <form method="post" action="/guardian/join/${token}">
454 <p><input name="name" placeholder="your name (grandma)" required pattern="[a-z0-9_-]{1,32}"
455 style="width:100%;padding:10px" autocapitalize="none"></p>
456 <p><input name="password" type="password" placeholder="password" required minlength="8"
457 style="width:100%;padding:10px"></p>
458 <p><button style="width:100%;padding:12px">Create my guardian account</button></p>
459 </form></body>`;
460}
461
462router.get('/join/:token', (req, res) => {
463 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
464 .get(req.params.token);
465 if (!inv) return res.status(404).send('This invite is no longer valid.');
466 res.send(joinForm(req.params.token));
467});
468
469router.post('/join/:token', express.urlencoded({ extended: false }), (req, res) => {
470 const inv = db.prepare('SELECT * FROM ap_guardian_invites WHERE token = ? AND used_at IS NULL')
471 .get(req.params.token);
472 if (!inv) return res.status(404).send('This invite is no longer valid.');
473 const name = String(req.body.name || '').trim().toLowerCase();
474 const password = String(req.body.password || '');
475 if (!/^[a-z0-9_-]{1,32}$/.test(name)) return res.status(400).send(joinForm(req.params.token, 'Only lowercase letters, digits, - and _.'));
476 if (password.length < 8) return res.status(400).send(joinForm(req.params.token, 'Password: at least 8 characters.'));
477 if (db.prepare('SELECT 1 FROM sites WHERE slug = ?').get(name) || db.prepare('SELECT 1 FROM users WHERE username = ?').get(name)) {
478 return res.status(409).send(joinForm(req.params.token, 'That name is taken, pick another.'));
479 }
480 const userId = crypto.randomUUID();
481 db.prepare('INSERT INTO users (id, username, email, password_hash, role) VALUES (?,?,?,?,?)')
482 .run(userId, name, `${name}@guardian.invalid`, bcrypt.hashSync(password, 10), 'member');
483 db.prepare('INSERT INTO sites (id, slug, title, owner_id, is_primary, guardian_only) VALUES (?,?,?,?,0,1)')
484 .run(crypto.randomUUID(), name, name, userId);
485 db.prepare('UPDATE ap_guardian_invites SET used_by = ?, used_at = CURRENT_TIMESTAMP WHERE token = ?')
486 .run(userId, req.params.token);
487 req.session.user = { id: userId, username: name, role: 'member' };
488 res.redirect('/guardian');
489});
490
491export default router;
Note: See TracBrowser for help on using the repository browser.