| 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 | */
|
|---|
| 11 | import express from 'express';
|
|---|
| 12 | import db from '../config/database.js';
|
|---|
| 13 | import { requireAuth } from '../middleware/auth.js';
|
|---|
| 14 | import AP from '../services/ActivityPubService.js';
|
|---|
| 15 | import * as Guardianship from '../services/guardianship/index.js';
|
|---|
| 16 | import { t as i18nT, resolveLang } from '../services/i18n.js';
|
|---|
| 17 |
|
|---|
| 18 | const router = express.Router();
|
|---|
| 19 |
|
|---|
| 20 | /** The acting site: ?site=slug when owned, else the user's first site. */
|
|---|
| 21 | function siteForUser(req) {
|
|---|
| 22 | const userId = req.session.user.id;
|
|---|
| 23 | const want = String(req.query.site || req.body?.site || '').trim();
|
|---|
| 24 | if (want) {
|
|---|
| 25 | const s = db.prepare('SELECT * FROM sites WHERE slug = ? AND owner_id = ?').get(want, userId);
|
|---|
| 26 | if (s) return s;
|
|---|
| 27 | }
|
|---|
| 28 | return db.prepare('SELECT * FROM sites WHERE owner_id = ? ORDER BY id LIMIT 1').get(userId);
|
|---|
| 29 | }
|
|---|
| 30 |
|
|---|
| 31 | /** Everything the dashboard shows, one shape for page and API. */
|
|---|
| 32 | function uiStrings(L) {
|
|---|
| 33 | const keys = ['sent', 'failed', 'network', 'pending', 'retract', 'release'];
|
|---|
| 34 | return Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
|
|---|
| 35 | }
|
|---|
| 36 |
|
|---|
| 37 | function dashboardState(site, L) {
|
|---|
| 38 | const wards = Guardianship.listWards(site.slug);
|
|---|
| 39 | const help = db.prepare(
|
|---|
| 40 | `SELECT object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, content, published, created_at
|
|---|
| 41 | FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
|
|---|
| 42 | ).all(site.slug);
|
|---|
| 43 | return {
|
|---|
| 44 | site: site.slug,
|
|---|
| 45 | wards: wards.filter((w) => w.status === 'accepted'),
|
|---|
| 46 | pendingOffers: wards.filter((w) => w.status === 'offered'),
|
|---|
| 47 | help,
|
|---|
| 48 | strings: uiStrings(L),
|
|---|
| 49 | };
|
|---|
| 50 | }
|
|---|
| 51 |
|
|---|
| 52 | // ── The PWA page ─────────────────────────────────────────────────────────
|
|---|
| 53 | router.get('/', requireAuth, (req, res) => {
|
|---|
| 54 | const site = siteForUser(req);
|
|---|
| 55 | const L = resolveLang(req);
|
|---|
| 56 | if (!site) return res.status(404).send('No site for this account.');
|
|---|
| 57 | const sites = db.prepare('SELECT slug, title FROM sites WHERE owner_id = ? ORDER BY id').all(req.session.user.id);
|
|---|
| 58 | res.render('pages/guardian', {
|
|---|
| 59 | state: dashboardState(site, L),
|
|---|
| 60 | sites,
|
|---|
| 61 | lang: L,
|
|---|
| 62 | t: (k, v) => i18nT(L, k, v),
|
|---|
| 63 | cspNonce: res.locals.cspNonce,
|
|---|
| 64 | });
|
|---|
| 65 | });
|
|---|
| 66 |
|
|---|
| 67 | // ── JSON state for refreshes ─────────────────────────────────────────────
|
|---|
| 68 | router.get('/api/state', requireAuth, (req, res) => {
|
|---|
| 69 | const site = siteForUser(req);
|
|---|
| 70 | if (!site) return res.status(404).json({ error: 'no_site' });
|
|---|
| 71 | res.json(dashboardState(site, resolveLang(req)));
|
|---|
| 72 | });
|
|---|
| 73 |
|
|---|
| 74 | // ── Adopt a ward: handle → resolve → C2S Offer through the same pipeline
|
|---|
| 75 | // the Shaer apps use (one path, one behavior).
|
|---|
| 76 | router.post('/adopt', requireAuth, express.json({ limit: '4kb' }), async (req, res) => {
|
|---|
| 77 | const site = siteForUser(req);
|
|---|
| 78 | if (!site) return res.status(404).json({ error: 'no_site' });
|
|---|
| 79 | const handle = String(req.body?.handle || '').trim();
|
|---|
| 80 | if (!handle) return res.status(400).json({ error: 'empty_handle' });
|
|---|
| 81 | const wardUri = /^https?:\/\//i.test(handle) ? handle : await AP.webfingerResolve(handle).catch(() => null);
|
|---|
| 82 | if (!wardUri) return res.status(404).json({ error: 'not_found' });
|
|---|
| 83 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 84 | const me = AP.actorId(base, site.slug);
|
|---|
| 85 | const r = await AP.ingestOutboxActivity(site, req.session.user, {
|
|---|
| 86 | type: 'Offer',
|
|---|
| 87 | object: { type: 'Relationship', subject: wardUri, relationship: 'shaer:Guardian', object: me },
|
|---|
| 88 | });
|
|---|
| 89 | if (!r || r.status >= 400) return res.status(r?.status || 500).json({ error: r?.error || 'offer_failed' });
|
|---|
| 90 | res.json({ ok: true, ward: wardUri });
|
|---|
| 91 | });
|
|---|
| 92 |
|
|---|
| 93 | // ── Manage: retract a pending offer / release a ward ─────────────────────
|
|---|
| 94 | router.post('/wards/remove', requireAuth, express.json({ limit: '4kb' }), (req, res) => {
|
|---|
| 95 | const site = siteForUser(req);
|
|---|
| 96 | if (!site) return res.status(404).json({ error: 'no_site' });
|
|---|
| 97 | const uri = String(req.body?.uri || '').trim();
|
|---|
| 98 | if (!uri) return res.status(400).json({ error: 'empty_uri' });
|
|---|
| 99 | Guardianship.removeRelation(site.slug, 'guardian', uri);
|
|---|
| 100 | res.json({ ok: true });
|
|---|
| 101 | });
|
|---|
| 102 |
|
|---|
| 103 | // ── The installable identity: own scope so the Guardian corner installs as
|
|---|
| 104 | // its own app next to the site PWA.
|
|---|
| 105 | router.get('/manifest.webmanifest', (req, res) => {
|
|---|
| 106 | const site = res.locals.site;
|
|---|
| 107 | res.set('Cache-Control', 'no-cache');
|
|---|
| 108 | res.json({
|
|---|
| 109 | id: `klonkt-guardian-${site?.slug || 'guardian'}`,
|
|---|
| 110 | name: 'Klonkt Guardian',
|
|---|
| 111 | short_name: 'Guardian',
|
|---|
| 112 | description: 'Ward management and help requests for guardians.',
|
|---|
| 113 | scope: '/guardian/',
|
|---|
| 114 | start_url: '/guardian?source=pwa',
|
|---|
| 115 | display: 'standalone',
|
|---|
| 116 | display_override: ['standalone', 'minimal-ui'],
|
|---|
| 117 | orientation: 'any',
|
|---|
| 118 | background_color: '#141a24',
|
|---|
| 119 | theme_color: '#ff6b35',
|
|---|
| 120 | lang: site?.language || 'nl',
|
|---|
| 121 | icons: [
|
|---|
| 122 | { src: '/guardian/icon.svg', sizes: 'any', type: 'image/svg+xml' },
|
|---|
| 123 | ],
|
|---|
| 124 | });
|
|---|
| 125 | });
|
|---|
| 126 |
|
|---|
| 127 | // The buoy mark, in the guardian accent (mirrors the site favicon pattern).
|
|---|
| 128 | router.get('/icon.svg', (req, res) => {
|
|---|
| 129 | const svg = `<?xml version="1.0" encoding="UTF-8"?>
|
|---|
| 130 | <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
|---|
| 131 | <rect width="64" height="64" rx="14" fill="#ff6b35"/>
|
|---|
| 132 | <text x="50%" y="50%" dy="0.35em" text-anchor="middle" font-size="36">🛟</text>
|
|---|
| 133 | </svg>`;
|
|---|
| 134 | res.set('Content-Type', 'image/svg+xml');
|
|---|
| 135 | res.set('Cache-Control', 'public, max-age=86400');
|
|---|
| 136 | res.send(svg);
|
|---|
| 137 | });
|
|---|
| 138 |
|
|---|
| 139 | export default router;
|
|---|