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

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

Een hulpvraag hoort in Berichten, niet in de Krant

Een ๐Ÿ›Ÿ van een ward kwam bij de guardian op twee plekken binnen: als mention in
Berichten en de Guardian PWA, maar ook als gewone post in de Krant. Op
sound-fabrics.com stonden vijf van de zes hulpvragen in allebei.

De oorzaak zat in de inbox: de tijdlijn-insert vroeg alleen "is dit een
top-level post van iemand die ik volg" en keek niet naar wie de post geadresseerd
was. Dat is nu belongsInTimeline: een directe note is aan iemand persoonlijk
gericht, dus een bericht en geen post. Dat dekt meteen de wave en de gewone DM,
die om dezelfde reden in de Krant terechtkwamen. De self-heal ruimt de al
opgeslagen exemplaren op, beperkt tot de twee soorten die achteraf nog te
herkennen zijn; een publieke mention van iemand die je volgt is wel een post en
blijft staan.

Tweede helft: de weergave gelijkgetrokken. De Krant rendert een post met
emoji's, een quote- of linkkaart en de media; Berichten liet daar niks van zien
(zelfs de shortcodes bleven staan, want ap_mentions had geen emoji_json) en de
Guardian PWA plakte de kale content in een div. Die opmaak zat bovendien in de
<style> van de Krant zelf, dus een post buiten de Krant kwam sowieso ongestyled
binnen.

Nu is er รฉรฉn partial, note-body, met de opmaak in shared-styles ernaast. Alle
drie de oppervlakken gebruiken hem: de PWA bouwt zijn kaarten in de browser en
krijgt de body server-side gerenderd mee. ap_mentions en ap_interactions kregen
de kolommen die daarvoor nodig zijn, gevuld bij binnenkomst, met de quote- of
linkkaart out of band zoals de tijdlijn dat al deed.

En passant: de embed-gate stond alleen op de C2S-read, dus een ward zag in de
web-Krant nog steeds linkvoorbeelden die de guardians hadden uitgezet. Die gate
zit nu ook op /news en /messages.

Changed files:
src/services/ActivityPubService.js

  • belongsInTimeline: een directe note is geen tijdlijn-post
  • self-heal v21 verwijdert al opgeslagen hulpvragen en waves uit ap_timeline
  • resolveCard: quote of linkvoorbeeld, รฉรฉn kaart, out of band opgelost
  • mentions en replies slaan emoji's, media en die kaart op
  • getNotifications geeft die kolommen door aan Berichten

src/config/database.js

  • kolommen op ap_mentions en ap_interactions voor emoji's, media, quote, embed

src/middleware/render.js

  • renderNoteBody: dezelfde partial als string, voor niet-EJS oppervlakken

src/routes/posts.js

  • gateEmbeds op /news en /messages (FEP-633c gated feature)

src/routes/guardian.js

  • hulpvragen krijgen body_html en name_html mee

src/assets/js/guardian.js

  • kaart rendert die body in plaats van de kale content

src/assets/css/guardian.css

  • opmaak voor de gedeelde post-body in de kleuren van de PWA

src/views/partials/tl-item.ejs

  • body vervangen door de gedeelde partial

src/views/partials/msg-item.ejs

  • idem, plus een ๐Ÿ›Ÿ-markering bij een hulpvraag

src/views/partials/shared-styles.ejs

  • .tl-content, .tl-quote* en .tl-media* hierheen verhuisd

src/views/pages/news.ejs

  • die regels weggehaald, alleen Krant-eigen opmaak blijft

src/services/i18n.js

  • msg.help_request in nl, en, de

New file:
src/views/partials/note-body.ejs

  • de body van een post: content, quote/linkkaart, media

test/help-request-timeline.test.js

  • een ๐Ÿ›Ÿ blijft uit de Krant en in Berichten

test/note-body-shared.test.js

  • รฉรฉn renderer, en beide views gaan er doorheen

remarks: geverifieerd op een wegwerp-database in de browser: de hulpvraag
verdwijnt bij het opstarten uit de Krant en staat mรฉt quote en capture in
Berichten en de PWA. Nog niet uitgerold.

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

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