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

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

Guardian-PWA: cache-bust op de assets (stale JS was de "fire-and-forget")

Root van de klachten (geen verzend-bericht, lijkt fire-and-forget, en aan de
ward-kant "komt niet aan"): /assets wordt met een 1-jaar-cache geserveerd, dus
de browser bleef de oude guardian.js draaien na een deploy. Die oude versie
las S.pendingOffers (nu S.offers), dus de "Verzonden aanvragen"-sectie bleef
leeg en er kwam geen feedback. De server-kant werkte al: een echte offer
sound-fabrics -> beta wordt bezorgd en door beta opgeslagen (geverifieerd).

Fix: de guardian.js/css krijgen een ?v=<mtime>-versie die per deploy bumpt
(proces-restart herberekent de mtime), zodat een update meteen doorkomt.

Changed files:
src/routes/guardian.js

  • ASSET_V uit de bestand-mtime; meegegeven aan de render

src/views/pages/guardian.ejs

  • ?v= op guardian.css en guardian.js

remarks: bestaande browsers met de oude JS gecached: eenmalig hard-refreshen
(of de PWA herinstalleren). Daarna vangt de versie-query het af. De rest van
/assets houdt de 1-jaar-cache; alleen deze PWA-in-ontwikkeling wordt gebust.

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

  • Property mode set to 100644
File size: 8.0 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 fs from 'fs';
13import path from 'path';
14import { fileURLToPath } from 'url';
15import db from '../config/database.js';
16import { requireAuth } from '../middleware/auth.js';
17import AP from '../services/ActivityPubService.js';
18import * as Guardianship from '../services/guardianship/index.js';
19import { t as i18nT, resolveLang } from '../services/i18n.js';
20
21const router = express.Router();
22
23// Cache-bust the PWA assets: /assets is served with a 1-year cache, so without
24// a per-build version the browser keeps running an old guardian.js/css after a
25// deploy (that was exactly the "no feedback / not arriving" bug). The version
26// is the file's mtime at process start; a redeploy restarts us and bumps it.
27const __dir = path.dirname(fileURLToPath(import.meta.url));
28function assetVersion(rel) {
29 try { return Math.floor(fs.statSync(path.join(__dir, '..', 'assets', rel)).mtimeMs).toString(36); }
30 catch { return '0'; }
31}
32const ASSET_V = { js: assetVersion('js/guardian.js'), css: assetVersion('css/guardian.css') };
33
34/** The acting site: ?site=slug when owned, else the user's first site. */
35function siteForUser(req) {
36 const userId = req.session.user.id;
37 const want = String(req.query.site || req.body?.site || '').trim();
38 if (want) {
39 const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
40 if (s) return s;
41 }
42 return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
43}
44
45/** Everything the dashboard shows, one shape for page and API. */
46function uiStrings(L) {
47 const keys = ['sent', 'sent_retry', 'sending', 'not_found', 'failed', 'network',
48 'pending', 'active', 'retract', 'release', 'open', 'push_unavailable',
49 'accept', 'reject', 'complete', 'awaiting_others', 'coguard'];
50 return Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
51}
52
53function dashboardState(site, L) {
54 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
55 const me = AP.actorId(base, site.slug);
56 const help = db.prepare(
57 `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at
58 FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
59 ).all(site.slug);
60 return {
61 site: site.slug,
62 me,
63 wards: Guardianship.listWards(site.slug), // committed wards
64 offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
65 help,
66 strings: uiStrings(L),
67 };
68}
69
70// ── The PWA page ─────────────────────────────────────────────────────────
71router.get('/', requireAuth, (req, res) => {
72 const site = siteForUser(req);
73 const L = resolveLang(req);
74 if (!site) return res.status(404).send('No site for this account.');
75 const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
76 res.render('pages/guardian', {
77 state: dashboardState(site, L),
78 sites,
79 lang: L,
80 assetV: ASSET_V,
81 t: (k, v) => i18nT(L, k, v),
82 cspNonce: 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// ── Adopt a ward: handle → resolve → C2S Offer through the same pipeline
94// the Shaer apps use (one path, one behavior).
95router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
96 const site = siteForUser(req);
97 if (!site) return res.status(404).json({ error: 'no_site' });
98 const handle = String(req.body?.handle || '').trim();
99 if (!handle) return res.status(400).json({ error: 'empty_handle' });
100 const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
101 if (!wardUri) return res.status(404).json({ error: 'not_found' }); // the handle does not resolve to an account
102 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
103 const me = AP.actorId(base, site.slug);
104 const r = await AP.ingestOutboxActivity(site, req.session.user, {
105 type: 'Offer',
106 object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
107 });
108 // 403/400 = a real refusal (e.g. you are a ward yourself); anything else the
109 // offer is recorded and delivery is retried in the background.
110 if (!r || (r.status >= 400 && r.status !== 502)) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
111 res.json({ ok: true, ward: wardUri, delivered: r.delivered !== false });
112});
113
114// ── Answer an offer (co-guardian accept/reject, or the candidate's final
115// "complete"). All three are a C2S Accept/Reject on the offer id; the
116// handshake module decides when it commits (§3.1).
117router.post('/offer', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
118 const site = siteForUser(req);
119 if (!site) return res.status(404).json({ error: 'no_site' });
120 const offerId = String(req.body?.offer || '').trim();
121 const answer = req.body?.answer === 'reject' ? 'Reject' : 'Accept';
122 if (!offerId) return res.status(400).json({ error: 'empty_offer' });
123 const r = await AP.ingestOutboxActivity(site, req.session.user, { type: answer, object: offerId });
124 if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'answer_failed' });
125 res.json({ ok: true, committed: !!r.committed, readyToCommit: !!r.readyToCommit });
126});
127
128// ── Manage: release a committed ward (local Undo; federation is Fase 4). ──
129router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
130 const site = siteForUser(req);
131 if (!site) return res.status(404).json({ error: 'no_site' });
132 const uri = String(req.body?.uri || '').trim();
133 if (!uri) return res.status(400).json({ error: 'empty_uri' });
134 Guardianship.removeRelation(site.slug, 'guardian', uri);
135 res.json({ ok: true });
136});
137
138// ── The installable identity: own scope so the Guardian corner installs as
139// its own app next to the site PWA.
140router.get('/manifest.webmanifest', (req, res) => {
141 const site = res.locals.site;
142 res.set('Cache-Control', 'no-cache');
143 res.json({
144 id: `klonkt-guardian-${site?.slug || 'guardian'}`,
145 name: 'Klonkt Guardian',
146 short_name: 'Guardian',
147 description: 'Ward management and help requests for guardians.',
148 scope: '/guardian/',
149 start_url: '/guardian?source=pwa',
150 display: 'standalone',
151 display_override: ['standalone', 'minimal-ui'],
152 orientation: 'any',
153 background_color: '#141a24',
154 theme_color: '#ff6b35',
155 lang: site?.language || 'nl',
156 icons: [
157 { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
158 ],
159 });
160});
161
162// The buoy mark, in the guardian accent (mirrors the site favicon pattern).
163router.get('/icon.svg', (req, res) => {
164 const svg = `<?xml version="1.0" encoding="UTF-8"?>
165<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
166 <rect width="64" height="64" rx="14" fill="#ff6b35"/>
167 <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">&#128735;</text>
168</svg>`;
169 res.set('Content-Type', 'image/svg+xml');
170 res.set('Cache-Control', 'public, max-age=86400');
171 res.send(svg);
172});
173
174export default router;
Note: See TracBrowser for help on using the repository browser.