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

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

De Guardian PWA liep twee uur achter

Op sound-fabrics staat de tijdzone op Europe/Amsterdam, maar een hulpvraag van
20:20 stond in de PWA als 18:20. Het dashboard bouwt zijn kaarten in de browser
en sneed de rauwe UTC-string af (slice(0,16)) in plaats van hem om te rekenen.
De server geeft de tijd nu geformatteerd mee, met dezelfde formatDateTime die de
Krant en Berichten gebruiken; het afsnijden blijft alleen als terugval staan.

In formatDateTime zat een tweede probleem, dat nog niet zichtbaar was. SQLite
schrijft CURRENT_TIMESTAMP als UTC zonder dat erbij te zeggen ("2026-07-28
18:20:33"), en new Date() leest een string in die vorm als LOKALE tijd. Dat gaat
goed zolang de machine op UTC staat, wat nu toevallig zo is. Zet de VPS ooit op
Amsterdam en elke opgeslagen datum in de hele app schuift twee uur op. De parser
zegt nu expliciet UTC.

Onderweg bleek Berichten en de PWA ook niet dezelfde tijd te tonen voor dezelfde
post: 20:12 tegenover 20:20. Berichten liet zien wanneer wij de post ontvingen,
de PWA en de Krant wanneer hij geschreven is. Berichten toont nu ook de
publicatietijd. Sorteren en de "nieuw sinds je laatste bezoek"-stip blijven op
de ontvangsttijd: een post die laat federeert is nog steeds nieuw voor jou.

Changed files:
src/middleware/render.js

  • parseStamp leest een tijdstempel zonder zone als UTC
  • formatDateTime geexporteerd voor oppervlakken buiten de EJS-pagina's

src/routes/guardian.js

  • when_text bij hulpvragen en bij de tijdlijn van de wards

src/assets/js/guardian.js

  • when() gebruikt dat veld, afsnijden alleen nog als terugval

src/services/ActivityPubService.js

  • getNotifications geeft published mee naast created_at

src/views/partials/msg-item.ejs

  • toont de publicatietijd, met created_at als terugval

New file:
test/timestamps.test.js

  • de tijdzone-instelling wordt toegepast, en een SQLite-tijdstempel hangt niet af van de tijdzone van de machine

remarks: geverifieerd op de wegwerp-database met Europe/Amsterdam: Berichten en
de PWA tonen allebei 28 jul 2026, 20:20 voor dezelfde post.

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

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