source: Klonkt/src/routes/guardian.js@ 6bc2e31b

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

Paid-post pagina krijgt de normale header, en Release a Ward vraagt eerst

Twee kleine dingen uit de backlog.

shaer-eaf: de pagina-s in de unlock-flow (paid-result en paid-passkey) stonden op
bodyClass on-special. Die klasse is bedoeld voor de fediverse/beheer-achtige
pagina-s en zet in de topnav dezelfde behandeling aan als bij admin: een kale
terug-naar-site link i.p.v. de site-header. Maar dit zijn gewoon bezoekerspagina-s
in het midden van een aankoop. Nu on-post, wat de normale (compacte) header geeft.

shaer-o4a: een ward loslaten ging met een enkele tik, zonder vraag. Dat is een
zware en slecht terug te draaien actie: je stopt als guardian, ziet hun berichten
niet meer, krijgt geen hulpverzoeken meer en beslist niet meer over volgverzoeken,
en terugkomen kan alleen met een nieuwe aanvraag die de ward accepteert. Er komt
nu een bevestiging die precies dat opsomt, in alle drie de talen.

Changed files:
src/routes/paid.js

  • vier renders van on-special naar on-post (paid-result en paid-passkey)

src/services/i18n.js

  • guardian.release_confirm toegevoegd in nl, en, de

src/routes/guardian.js

  • release_confirm meegegeven aan de PWA-strings

src/assets/js/guardian.js

  • Loslaten vraagt eerst om bevestiging, met de handle in de tekst

remarks: 190 tests groen. De paid-flow is hier niet live te reproduceren (die
routes 404-en zonder Patreon-config), dus dat deel is via de topnav-logica
geverifieerd en niet in de browser.

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

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