source: Klonkt/src/routes/guardian.js@ 439f095

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

De gebruikerskant van 3.6: de ward ziet zijn vangnet, de guardian handelt

Drie oppervlakken rondgemaakt op de beschikbaarheid die er sinds vanmorgen
server-side in zit.

Berichten (de ward): de guardians-balk toont per guardian de buddy-list-stip
met het label erbij: beschikbaar, afwezig tot een datum, offline. Het kind ziet
de echte omvang van zijn vangnet, niet alleen de namen. Owner-only omdat de
pagina dat is.

De Guardian-PWA (de guardian): in het paneel per kind staan de mede-guardians
met dezelfde stippen; op een slapende verschijnt de bewuste, zeldzame
vervolgstap "Voorstel: loslaten bij afwezigheid", die langs dezelfde
C2S-pijplijn loopt als de Shaer-apps (Offer van shaer:Lapse, lokaal ward opent
direct, remote ward krijgt het voorstel bezorgd). Lopende lapses staan als
kaart bij de aanvragen, met Eens/Oneens over de bestaande offer-draad en de zin
die het frame bewaakt. En "Even afwezig": een week of een maand, een directe
note met shaer:away naar alle wards, lokaal direct toegepast.

Shaer (beide apps): de lapse-kaart toont stemknoppen alleen aan leden van de
set. De ward kijkt mee naar wat zijn guardians beslissen; het is daar niet de
rechter, om precies de reden uit de editor's note van 3.6.3.

Onderweg gerepareerd: parseStamp kende alleen strings, waardoor een epoch-ms
endTime als lege datum rendde ("unavailable till" zonder datum).

Changed files:
src/routes/posts.js

  • /messages geeft de guardians hun beschikbaarheid mee

src/views/pages/messages.ejs

  • de stip en het label per guardian, met de opmaak erbij

src/routes/guardian.js

  • dashboardState: mede-guardians met status per lokaal kind, plus lapses
  • POST /guardian/api/away en /guardian/api/lapse
  • de nieuwe labels in uiStrings

src/assets/js/guardian.js

  • de guardians-sectie in het paneel, de lapse-kaart, de afwezig-knoppen

src/views/pages/guardian.ejs

  • de "Even afwezig"-sectie

src/assets/css/guardian.css

  • de stippen en de lapse-kaart

src/middleware/render.js

  • parseStamp accepteert epoch ms

src/services/i18n.js

  • de labels en teksten in nl, en, de

remarks: end-to-end in de browser nagelopen op de wegwerp-database: het kind
ziet oma afwezig-tot, opa offline en guard beschikbaar; de guardian opent het
paneel, stelt de lapse voor op de slapende opa (kaart verschijnt, eigen stem
geteld), drukt "A week", en bij het kind staat guard meteen op afwezig tot
5 augustus. 276 tests groen. Niet uitgerold.

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

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