Changeset f1c50f9 in Klonkt for src/routes
- Timestamp:
- 07/25/2026 10:37:37 AM (7 weeks ago)
- Branches:
- main
- Children:
- 97bacf2
- Parents:
- 787e0d9
- Location:
- src/routes
- Files:
-
- 1 deleted
- 1 edited
-
guardian.js (modified) (4 diffs)
-
guardian2.js (deleted)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/guardian.js
r787e0d9 rf1c50f9 10 10 */ 11 11 import express from 'express'; 12 import crypto from 'crypto'; 13 import bcrypt from 'bcryptjs'; 12 14 import path from 'path'; 13 15 import { fileURLToPath } from 'url'; … … 38 40 'pending', 'active', 'retract', 'release', 'open', 'push_unavailable', 39 41 'accept', 'reject', 'complete', 'awaiting_others', 'coguard']; 40 return Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)])); 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; 41 46 } 42 47 … … 84 89 if (!site) return res.status(404).json({ error: 'no_site' }); 85 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. 98 function 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). 114 router.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. 139 function 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 146 router.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 165 router.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). 198 router.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 }); 86 209 }); 87 210 … … 181 304 }); 182 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 311 router.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 324 function 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 339 router.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 346 router.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 183 368 export default router;
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)