source: Klonkt/src/routes/guardian.js@ 70677e96

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

Guardian-PWA: een paneel per kind

/guardian was een platte lijst van zeven secties: de wards' berichten, de
volgverzoeken, de hulpvragen, adopteren, verzonden aanvragen, wards, meldingen.
Wie een kind wilde overzien moest tussen die secties heen en weer, en nergens
stond bij elkaar wat er over dat ene kind speelt. Een guardian denkt niet per
functie maar per kind.

Nu is de wards-lijst de ingang: elk kind is een regel die opent naar een paneel
met zijn instellingen, zijn volgverzoeken, zijn hulpvragen en zijn recente
berichten. De regel zelf draagt de tellers (hulpvragen, volgverzoeken), zodat
een dicht paneel nooit iets verbergt dat een antwoord nodig heeft; dat is
belangrijk voor de volgverzoeken, want daar zit de follow-gating in.

De hulpvragen blijven daarnaast bovenaan staan, over alle kinderen heen, want
dat is waar deze app voor bestaat en het moet opvallen zonder dat je eerst een
kind opent. Ze verschijnen dus bewust twee keer: bovenaan de recente, in het
paneel de volledige geschiedenis van dat kind.

De hulpvraag is geen alarm. Er wordt niemand automatisch gewaarschuwd; een
guardian kan hem stil oplossen. De kaart is daarom rustig gehouden.

Changed files:
src/views/pages/guardian.ejs

  • feed-section en follow-section weg als losse secties

src/assets/js/guardian.js

  • helpCard, feedCard en followCard losgetrokken zodat beide plekken ze delen
  • wardPanel: instellingen, volgverzoeken, hulpvragen, berichten, acties
  • openPanels onthoudt wat open staat, zodat verversen niet dichtklapt
  • feed en volgverzoeken vullen nu een cache en hertekenen de wards-lijst

src/assets/css/guardian.css

  • opmaak voor het paneel en de tellers op de regel

src/routes/guardian.js

  • authorUri en wardUri meegeven: de sleutels waarop gegroepeerd wordt
  • de nieuwe labels toegevoegd aan uiStrings

src/services/i18n.js

  • 39 strings voor het paneel in nl, en, de

New file:
test/guardian-panel.test.js

  • de client schrijft nergens naar een verdwenen sectie
  • elke paneelsectie heeft zijn groepeersleutel
  • elk label dat de client gebruikt wordt ook geserveerd

remarks: geverifieerd in de browser op een wegwerp-database met twee kinderen:
elk paneel toont alleen zijn eigen volgverzoek, hulpvraag en post, een geopend
paneel blijft open over een ververs-ronde heen, en op 375px loopt niets buiten
beeld. De afhandel-lifecycle (oppakken/afgehandeld, shaer-jxi) en luid publiek
als tweede gated feature (shaer-3kp) komen hierna in dit paneel.

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

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