source: Klonkt/src/routes/guardian.js@ 65abc85

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

Gated settings federeren: guardians beslissen samen, ook van een andere server

Ik had de knop alleen voor het co-located geval gebouwd, en dat is precies het
uitzonderingsgeval. In de echte opstelling staat de ward op de ene server en zijn
drie guardians op twee andere: er was dus nergens een knop. Dat botst met onze
eigen regel dat co-locatie een optimalisatie is en nooit de aanname.

Nu volgens FEP-633c 5.6 (deze week aan de spec toegevoegd): een guardian stelt
een wijziging voor met een Offer van een shaer:GatedSetting aan de server van de
WARD; de andere guardians antwoorden met Accept/Reject; de server van de ward
telt en handhaaft, want die serveert de feed. Co-locatie neemt dezelfde weg: ook
daar wordt voorgesteld en geteld, anders zou een guardian naast de deur meer te
zeggen hebben dan een op afstand.

De tally is een 3.5-beslissing en staat als pure functie apart: gesnapshotte set,
strikte meerderheid, venster van een dag. Omkeerbaar, dus race naar de drempel in
BEIDE richtingen (settelt ook zodra een meerderheid onhaalbaar is) en faalt dicht
op de deadline. Een Reject is een stem voor de andere waarde, geen schouderophalen.

New file:
src/services/guardianship/gated.js

  • tallyGatedSetting (puur), thresholdFor, featureColumn (onbekende features geweigerd i.p.v. geraden), recordGatedVote, en de Offer-vorm

test/gated-settings.test.js

  • 9 tests: drempel, vroeg settelen in beide richtingen, dicht op de deadline, vreemden tellen niet mee, geen guardians = niets toegekend, van gedachten veranderen vervangt je stem, en een onbekende feature raakt geen kolom

Changed files:
src/config/database.js

  • ap_gated_offers + ap_gated_votes

src/services/guardianship/handshake.js

  • inbox: Offer(shaer:GatedSetting) en Accept/Reject erop, met de stem van de voorsteller meegeteld (one-step-clausule)

src/services/guardianship/index.js

  • gated geexporteerd

src/routes/guardian.js

  • de knop stuurt een voorstel, lokaal en remote langs dezelfde weg

src/assets/js/guardian.js

  • knop bij ELKE ward, ook remote; toont 'wacht op de andere guardians'

src/services/i18n.js

  • embeds_propose / embeds_waiting in nl, en, de

remarks: 228 tests groen (was 219).

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

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