source: Klonkt/src/routes/activitypub.js@ 5d0d3f35

main
Last change on this file since 5d0d3f35 was 5d0d3f35, checked in by Robin <roboburr@…>, 107 minutes ago

WebFinger op een did:key: welk account zegt dat het deze sleutel is

FEP-c390 zet het identiteitsbewijs op de actor en zegt niets over vinden. Wie
een papieren sleutel vasthoudt (shaer-jpxj) -- bij een verhuizing, of om
iemand na te lopen -- kan nu vragen welk account erbij hoort:

GET /.well-known/webfinger?resource=did:key:z6Mk...

RFC 7033 laat elke URI als resource toe, dus dit is gewoon WebFinger. Het
antwoord is een JRD met per account een self-link naar de actor; 404 als
niemand hier die sleutel draagt.

Alleen wat NU klopt: elke opgeslagen verklaring wordt opnieuw nagelopen tegen
de actor waar ze op staat (een verklaring voor een ander account, of een die
niet meer natelopen is, telt niet), en alleen openbare sites, net als bij
acct:. Twee accounts met dezelfde sleutel staan er allebei: dat is het oude en
het nieuwe na een verhuizing. De vrager leert niets dat niet al openbaar op
die actors staat. Cache 60 seconden, korter dan acct:, zodat weghalen snel
zichtbaar is.

test/webfinger-did.test.js: vier toetsen, met drie mutaties apart gedraaid
(geen eigen controle, ook besloten sites, geen did-tak), elk gevangen. Een
acct:-vraag gaat precies als vroeger. Volledige suite 1350 groen.

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

  • Property mode set to 100644
File size: 90.7 KB
Line 
1/**
2 * ActivityPub — public endpoints (Phase 1: discover + fetch).
3 *
4 * GET /.well-known/webfinger?resource=acct:<slug>@<host>
5 * GET /ap/users/:slug actor (content-negotiated: AP-JSON vs redirect to HTML profile)
6 * GET /ap/users/:slug/outbox OrderedCollection of Create(Note)
7 * GET /ap/users/:slug/followers count-only OrderedCollection
8 * GET /ap/users/:slug/featured pinned posts (Mastodon "Featured" tab)
9 * GET /ap/notes/:id a single Note
10 * POST /ap/users/:slug/inbox, /ap/inbox → 202 (Follow/Accept + signature verify: next step)
11 *
12 * Mounted before resolveSite; resolves the site by slug itself.
13 */
14import express from 'express';
15import { readFileSync } from 'fs';
16import db, { isoSql } from '../config/database.js';
17import AP from '../services/ActivityPubService.js';
18import { apReadLimiter, apInboxLimiter } from '../middleware/rate-limit.js';
19import { apEnabled } from '../services/SettingsService.js';
20import OAuth from '../services/OAuthService.js';
21import * as Guardianship from '../services/guardianship/index.js';
22import * as ConversationKey from '../services/conversation-key.js';
23import * as IdentityProof from '../services/IdentityProof.js';
24import * as Migration from '../services/MigrationService.js';
25import { getPrimarySite } from '../middleware/site.js';
26import multer from 'multer';
27import path from 'path';
28import fs from 'fs';
29import { randomUUID } from 'crypto';
30import { mediaDir } from '../config/paths.js';
31
32const router = express.Router();
33
34/**
35 * Welke pagina vraagt de lezer? (shaer-sk4)
36 *
37 * Hier stond `!!req.query.page` -- of de parameter er STAAT, niet welke. Daardoor
38 * gaf ?page=2 en ?page=99 allemaal pagina 1, en noemde het antwoord zichzelf ook
39 * nog pagina 1. Onleesbaar getal of geen parameter: dan de wortel.
40 */
41function paginaNr(req) {
42 if (req.query.page === undefined) return false;
43 const n = Math.floor(Number(req.query.page));
44 return Number.isFinite(n) && n > 0 ? n : 1;
45}
46// The whole fediverse layer can be turned off (solo "no federation" mode):
47// then /ap/*, WebFinger and NodeInfo are simply gone — the site is undiscoverable
48// and unfederatable. CRITICAL: this router is mounted at root (app.use(apRoutes)), so a
49// blanket res.status(404) here ran for EVERY request and 404'd the whole site when AP was
50// off. Use next('router') to SKIP this router entirely and let the normal routes handle it
51// (the /ap/* paths then fall through to the app's normal 404, which is correct).
52router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
53// Generous per-IP baseline over the AP-READ paths. The inbox POST gets an
54// additional, tighter cap inline (it triggers outbound fetches).
55//
56// PADGEBONDEN, niet router.use kaal (Barts 429-jacht, 9-8): deze router is op
57// de ROOT gemonteerd, dus een kale use() draait voor ELKE request van de hele
58// site -- pagina's, media, avatars, de PWA. De guardian-PWA met honderd
59// ward-avatars leegde zo in seconden een emmer die "voor /ap-reads" heette,
60// en hield hem leeg: vandaar een Too many requests die niet overging. De
61// kijkbuis die dit vond: een lege /ap-teller naast remaining: 0.
62router.use(['/ap', '/.well-known', '/nodeinfo'], apReadLimiter);
63let _ver = '1.0.0';
64try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
65
66const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
67const hostOf = (req) => { try { return new URL(baseUrl(req)).host; } catch { return req.get('host'); } };
68const publicSite = (slug) => db.prepare('SELECT * FROM sites WHERE slug = ? AND (is_public IS NULL OR is_public = 1)').get(slug);
69// The primary site, via the one source of truth in middleware/site.js — which
70// falls back to the oldest site when nothing carries the is_primary flag. This
71// route used to keep its own is_primary-only copy, so a fresh instance whose
72// site was never flagged served its HTML at / (that resolver falls back) while
73// WebFinger and the actor route insisted it had no primary at all.
74const primarySlug = () => { const s = getPrimarySite(); return s && s.slug; };
75// A hostname as a human types it and as DNS stores it are the same host:
76// `🩵.is.wildenvrij.nl` IS `xn--zz9h.is.wildenvrij.nl`. WHATWG URL does the IDNA,
77// so compare the ASCII form and never the bytes the client happened to send.
78const asciiHost = (h) => {
79 try { return new URL(`https://${h}`).host.toLowerCase(); } catch { return String(h).trim().toLowerCase(); }
80};
81
82// ── host-meta ─────────────────────────────────────────────────────
83// De klassieke eerste stap van WebFinger (RFC 6415): een client die het
84// webfinger-pad niet wil raden, vraagt hier de sjabloon op. Mastodon serveert
85// dit ook, en een client die ermee begint kreeg bij ons een 404 en gaf het dan
86// op -- terwijl de webfinger eronder gewoon werkte.
87//
88// Twee vormen, want beide worden in het wild gevraagd: XRD (het origineel) en
89// JRD (de JSON-variant, RFC 6415 §3).
90const lrddSjabloon = (req) => `${baseUrl(req)}/.well-known/webfinger?resource={uri}`;
91
92router.get('/.well-known/host-meta', (req, res) => {
93 res.type('application/xrd+xml; charset=utf-8');
94 res.set('Cache-Control', 'public, max-age=86400');
95 res.send(`<?xml version="1.0" encoding="UTF-8"?>
96<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">
97 <Link rel="lrdd" template="${lrddSjabloon(req)}"/>
98</XRD>`);
99});
100
101router.get('/.well-known/host-meta.json', (req, res) => {
102 res.type('application/jrd+json; charset=utf-8');
103 res.set('Cache-Control', 'public, max-age=86400');
104 res.send(JSON.stringify({ links: [{ rel: 'lrdd', template: lrddSjabloon(req) }] }));
105});
106
107// ── WebFinger ─────────────────────────────────────────────────────
108/**
109 * De `resource` uitpakken tot de gebruiker die bedoeld wordt.
110 *
111 * RFC 7033 schrijft een URI voor, en `acct:` is de nette vorm -- maar in het
112 * wild komen er vier spellingen langs, en drie daarvan wezen we af met een 400
113 * terwijl we prima wisten wie er bedoeld werd:
114 *
115 * acct:naam@host de nette vorm (Mastodon stuurt altijd deze)
116 * naam@host zonder schema
117 * @naam@host met het apenstaartje dat mensen intypen
118 *
119 * Coulant zijn kost hier niets: het antwoord noemt altijd de canonieke
120 * `acct:`-vorm terug, dus een slordige vraag levert geen slordig antwoord.
121 *
122 * De ACTOR-URI als resource (die Mastodon ook accepteert) hoort hier NIET bij,
123 * bewust: test/webfinger-bare-host.test.js legt vast dat die een 400 geeft.
124 * Dat is een uitgesproken keuze van eerder en geen vergetelheid, dus die draai
125 * ik niet om als bijvangst van een coulance-fix.
126 */
127function webfingerGebruiker(resource) {
128 const r = String(resource || '').trim();
129 if (!r) return null;
130 const acct = r.match(/^(?:acct:)?@?([^@/]+)@(.+)$/i);
131 return acct ? acct[1] : null;
132}
133
134/**
135 * Is dit een vraag naar de WORTEL van deze server?
136 *
137 * FEP-61cf laat de home instance webfingeren op "the root URL of the destination
138 * site" om ons token-endpoint te vinden. Dat is een URL en geen `acct:`, dus hij
139 * strandde hierboven op de 400 -- terwijl een ACTOR-URI met een pad wel degelijk
140 * een 400 hoort te blijven (dat legt test/webfinger-bare-host.test.js vast, en
141 * dat is een uitgesproken keuze van eerder). Vandaar: alleen origin + '/' telt,
142 * alles met een pad niet.
143 */
144function isEigenWortel(resource, req) {
145 const r = String(resource || '').trim();
146 if (!/^https?:\/\//i.test(r)) return false;
147 try {
148 const u = new URL(r);
149 if (u.pathname && u.pathname !== '/') return false;
150 if (u.search || u.hash) return false;
151 return asciiHost(u.host) === asciiHost(hostOf(req));
152 } catch { return false; }
153}
154
155router.get('/.well-known/webfinger', (req, res) => {
156 if (isEigenWortel(req.query.resource, req)) {
157 res.type('application/jrd+json; charset=utf-8');
158 res.set('Cache-Control', 'public, max-age=300');
159 return res.send(JSON.stringify({
160 subject: baseUrl(req) + '/',
161 links: [
162 // Waar een home instance ondertekend een token mag ophalen (FEP-61cf
163 // stap 2/3). Host-niveau en niet per site: het token zegt WIE er binnen
164 // is, niet waar hij binnen mag -- dat besluit valt bij de poort.
165 { rel: 'http://purl.org/openwebauth/v1', href: baseUrl(req) + '/owa/token' },
166 ],
167 }));
168 }
169 // ── Een did:key: welk account zegt dat het deze sleutel is? ──────
170 //
171 // FEP-c390 zet het bewijs op de actor en zegt niets over vinden. Wie een
172 // papieren sleutel vasthoudt (shaer-jpxj) en wil weten bij welk account hij
173 // hoort -- bij een verhuizing, of om iemand na te lopen -- vraagt het hier:
174 // RFC 7033 laat elke URI als resource toe, dus dit is gewoon WebFinger.
175 //
176 // Alleen wat NU klopt: elke verklaring wordt opnieuw nagelopen tegen de
177 // actor waar ze op staat, en alleen openbare sites tellen, net als bij acct:.
178 // Twee accounts met dezelfde sleutel zijn mogelijk en horen er dan allebei
179 // te staan: dat is precies het oude en het nieuwe account na een verhuizing.
180 // De vrager leert niets dat niet al op die actors staat.
181 const did = String(req.query.resource || '').trim();
182 if (IdentityProof.publicKeyFromDidKey(did)) {
183 const base = baseUrl(req);
184 // De LIKE voorselecteert alleen; base58 bevat geen % of _.
185 const actors = db.prepare('SELECT * FROM sites WHERE identity_proofs LIKE ? AND (is_public IS NULL OR is_public = 1) ORDER BY slug')
186 .all(`%${did}%`)
187 .filter((site) => {
188 const me = AP.actorId(base, site.slug);
189 return readProofs(site).some((b) => b && b.subject === did && IdentityProof.checkStatement(b, me).ok);
190 })
191 .map((site) => AP.actorId(base, site.slug));
192 if (!actors.length) return res.status(404).end();
193 res.type('application/jrd+json; charset=utf-8');
194 // Korter dan bij acct:: wie een sleutel net weghaalde, hoort dat snel terug te zien.
195 res.set('Cache-Control', 'public, max-age=60');
196 return res.send(JSON.stringify({
197 subject: did,
198 aliases: actors,
199 links: actors.map((href) => ({ rel: 'self', type: 'application/activity+json', href })),
200 }));
201 }
202 const user = webfingerGebruiker(req.query.resource);
203 if (!user) return res.status(400).type('text/plain').send('bad resource');
204 let site = publicSite(user);
205 // `acct:<host>@<host>` asks for this server's primary actor — the convention
206 // Shaer's Handle relies on so a Ward is reachable without knowing anyone's
207 // slug. Typing `🩵.is.wildenvrij.nl`, pasting `https://🩵.is.wildenvrij.nl`
208 // (which the client's URL parser silently punycodes) and sending the xn--
209 // form by hand are three spellings of one address; all arrive here with the
210 // host sitting in the user position, and all must find the same actor.
211 if (!site && asciiHost(user) === asciiHost(hostOf(req))) {
212 const slug = primarySlug();
213 if (slug) site = publicSite(slug);
214 }
215 if (!site) return res.status(404).end();
216 res.type('application/jrd+json; charset=utf-8');
217 res.set('Cache-Control', 'public, max-age=300');
218 const actorUri = AP.actorId(baseUrl(req), site.slug);
219 const profileUrl = baseUrl(req) + (site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`);
220 res.send(JSON.stringify({
221 subject: `acct:${site.slug}@${hostOf(req)}`,
222 aliases: [actorUri, profileUrl],
223 links: [
224 { rel: 'self', type: 'application/activity+json', href: actorUri },
225 { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl },
226 // FEP-61cf: hier stuurt een doelsite deze gebruiker heen om zich te
227 // bewijzen. Zonder deze regel valt zo'n site terug op /magic geraden --
228 // wat toevallig klopt, maar raden is geen afspraak.
229 { rel: 'http://purl.org/openwebauth/v1#redirect', href: baseUrl(req) + '/magic' },
230 ],
231 }));
232});
233
234// ── Actor ─────────────────────────────────────────────────────────
235router.get('/ap/users/:slug', (req, res) => {
236 const site = publicSite(req.params.slug);
237 if (!site) return res.status(404).end();
238 if (!AP.apWants(req)) {
239 // A browser hit the AP actor URL → send them to the human profile.
240 const human = site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`;
241 return res.redirect(302, baseUrl(req) + human);
242 }
243 site.primary_slug = primarySlug();
244 AP.sendAP(res, AP.buildActor(baseUrl(req), site));
245});
246
247// ── Outbox ────────────────────────────────────────────────────────
248router.get('/ap/users/:slug/outbox', async (req, res) => {
249 const site = publicSite(req.params.slug);
250 if (!site) return res.status(404).end();
251 // Authorized fetch (30-7): who is asking decides what they see.
252 // - the owner's own app (bearer) and a verified accepted follower or
253 // guardian get the friends-only history too, so a NEW friend's backfill
254 // brings the past along (Robins besluit: vrienden krijgen de
255 // geschiedenis mee);
256 // - a verified caller this instance BLOCKS gets an EMPTY collection, not
257 // even the public set: a block is a closed door, and a signed fetch is
258 // the caller knocking with their name on it;
259 // - everyone else gets the public collection, exactly as before.
260 const bearer = OAuth.verifyBearer(req.headers.authorization);
261 let verifiedActor = null;
262 if (!bearer && req.headers['signature']) {
263 const verified = await AP.verifyRequest(req).catch(() => null);
264 verifiedActor = verified && verified.id;
265 }
266 const audience = AP.outboxAudience(req.params.slug, {
267 bearerSlug: bearer ? bearer.site.slug : null,
268 verifiedActor,
269 });
270 if (audience === 'blocked') {
271 return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, [], [], { page: paginaNr(req) }), 'private, no-store');
272 }
273 // ECHT DOORBLADEREN (shaer-sk4). Hier stonden twintig posts uit SQL met een
274 // tweede kap van twintig eroverheen: alles daarvoor was niet op een volgende
275 // pagina maar helemaal onbereikbaar. outboxSlice pagineert over de UNION van
276 // posts en tracks, want die vlechten op datum en zijn met twee losse queries
277 // niet te offsetten.
278 //
279 // De tracks gaan mee voor iedereen die de deur door mag; de blocked-tak
280 // hierboven levert bewust een outbox ZONDER posts en zonder tracks.
281 const nr = paginaNr(req);
282 const { posts, tracks, totaal } = AP.outboxSlice(site.id, {
283 fanOnly: audience === 'friend',
284 offset: (Math.max(1, nr || 1) - 1) * AP.PAGINA_GROOTTE,
285 limit: AP.PAGINA_GROOTTE,
286 });
287 // FEP-1580: de instantie waar dit account naartoe verhuisd is krijgt de
288 // RAUWE inhoud, met [[track:]] en [[playlist:]] er nog in. Zij is een
289 // Klonkt en rendert die zelf tot een speler. De gebakken variant komt
290 // daar aan als tekstlink, en is bovendien onherstelbaar afgeknot: het
291 // bakken plakt hooguit vier titels aan.
292 const rauweInhoud = verifiedActor ? AP.isMoveTarget(site.slug, verifiedActor) : false;
293 const ob = AP.buildOutbox(baseUrl(req), site, posts, tracks, { page: nr, totalItems: totaal, alGesneden: true, rauweInhoud });
294 if (audience === 'friend') {
295 // The owner's app builds its feed from this leg, and every note here is
296 // by the site itself, so give it a byline too (avatar + name): de
297 // ingesloten actor in attributedTo, net als de tijdlijn.
298 const me = AP.selfAuthor(baseUrl(req), site);
299 // De kaart op je eigen post (shaer-k3f): dezelfde quote/preview die de
300 // tijdlijn voor andermans posts draagt, uit de snapshots die
301 // deliverCreate bij het publiceren opsloeg. Op note-id gekoppeld, want
302 // buildOutbox sorteert en mengt tracks erdoorheen. De embed alleen voor de
303 // BEARER en langs zijn eigen poort: een remote vriend krijgt hem niet
304 // (diens server resolvet en gate zelf bij ontvangst), en een ward zonder
305 // open embeds-poort krijgt hem hier net zo min als in de tijdlijn.
306 const byNote = new Map(posts.map((p) => [AP.noteId(baseUrl(req), p.id), p]));
307 const bearerEmbeds = bearer ? (() => {
308 const isWard = (() => { try { return Guardianship.listGuardians(bearer.site.slug).length > 0; } catch { return false; } })();
309 return Guardianship.externalEmbedsAllowed(bearer.site.external_embeds, isWard)
310 ? { playback: Guardianship.externalPlaybackAllowed(bearer.site.external_playback, isWard) } : null;
311 })() : null;
312 for (const it of ob.orderedItems) {
313 if (it && it.object && typeof it.object === 'object') {
314 it.object.attributedTo = AP.actorObject(
315 (typeof it.object.attributedTo === 'string' ? it.object.attributedTo : undefined) || AP.actorId(baseUrl(req), site.slug),
316 me,
317 );
318 const row = byNote.get(it.object.id);
319 if (row) {
320 it.object.quote = AP.quoteObject(row.quote_json);
321 if (bearerEmbeds) {
322 it.object.preview = AP.previewObject(row.embed_json, { playback: bearerEmbeds.playback });
323 }
324 }
325 }
326 }
327 }
328 AP.sendAP(res, ob, audience === 'friend' ? 'private, no-store' : undefined);
329});
330
331// ── Follow-QR (Robins verzoek, 31-7) ──────────────────────────────
332// The QR carries an HTTPS url, not the share: scheme: camera apps (Google
333// Lens voorop) treat unknown schemes as plain text and only offer to OPEN
334// https links (Robins melding, 31-7). The url lands on the interstitial
335// below, whose one big button fires the share: scheme — from a browser the
336// custom scheme DOES work (BROWSABLE intent-filter; Safari prompts).
337// Public on purpose: it encodes only the public handle, and the app's plain
338// image loaders carry no bearer.
339router.get('/ap/users/:slug/follow-qr.png', async (req, res) => {
340 const site = db.prepare('SELECT slug FROM sites WHERE slug = ?').get(req.params.slug);
341 if (!site) return res.status(404).end();
342 try {
343 const { default: QRCode } = await import('qrcode');
344 const png = await QRCode.toBuffer(`${baseUrl(req)}/ap/users/${encodeURIComponent(site.slug)}/follow`, { width: 600, margin: 1 });
345 res.set('Content-Type', 'image/png');
346 res.set('Cache-Control', 'public, max-age=86400');
347 res.send(png);
348 } catch (e) {
349 console.warn('[AP] follow-qr failed:', e && e.message);
350 res.status(500).end();
351 }
352});
353
354// The interstitial the QR opens: one big button into Shaer, and the handle
355// in plain sight for whoever has no Shaer (yet).
356router.get('/ap/users/:slug/follow', (req, res) => {
357 const site = db.prepare('SELECT slug, title FROM sites WHERE slug = ?').get(req.params.slug);
358 if (!site) return res.status(404).end();
359 const host = new URL(baseUrl(req)).host;
360 const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
361 const handle = `@${site.slug}@${host}`;
362 const name = esc(site.title || site.slug);
363 res.set('Cache-Control', 'public, max-age=3600');
364 res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
365<meta name="viewport" content="width=device-width, initial-scale=1">
366<title>Follow ${name}</title>
367<style>
368 body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
369 background: linear-gradient(160deg, #5A32E6, #2a1a5e); color: #fff; text-align: center; }
370 main { padding: 32px; max-width: 420px; }
371 h1 { font-size: 1.5rem; margin: 0 0 .4rem; }
372 .handle { opacity: .85; font-family: ui-monospace, monospace; word-break: break-all; }
373 a.go { display: block; margin: 28px auto 14px; padding: 16px 28px; border-radius: 999px; background: #fff; color: #2a1a5e;
374 font-weight: 700; font-size: 1.15rem; text-decoration: none; }
375 p.small { font-size: .85rem; opacity: .75; line-height: 1.5; }
376</style></head><body><main>
377 <h1>Follow ${name}</h1>
378 <div class="handle">${esc(handle)}</div>
379 <a class="go" href="share:social/follow/AP/${esc(handle)}">Open in Shaer</a>
380 <p class="small">No Shaer? Any fediverse app can follow ${esc(handle)}.</p>
381</main></body></html>`);
382});
383
384/** De byline-gegevens uit een tijdlijnrij, langs de emoji-poort. */
385function authorInfoFrom(r, prefix, gates) {
386 const info = {
387 name: r[`${prefix}name`] || undefined, handle: r[`${prefix}handle`] || undefined,
388 icon: r[`${prefix}icon`] || undefined, url: r[`${prefix}url`] || undefined,
389 emojis: (() => { try { return r[`${prefix}emoji_json`] ? JSON.parse(r[`${prefix}emoji_json`]) : undefined; } catch { return undefined; } })(),
390 };
391 return (info.name || info.handle || info.icon) ? gates.gateAuthor(info) : undefined;
392}
393
394// ── Een tijdlijnpost als AS2-item: EEN beschrijving van de kaartvorm ──
395//
396// Zelfde reden als messageItem hieronder: de volledige lezing en de
397// verschil-lezing bouwen dezelfde kaart, en twee beschrijvingen lopen uit de
398// pas zonder dat iemand het merkt.
399function timelineItem(t, { p, reactions }) {
400 const authorInfo = (r, prefix) => authorInfoFrom(r, prefix, p);
401 const {
402 embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, videoAllowed, quotesAllowed, emojiAllowed,
403 } = p;
404 const reacties = reactions || new Map();
405 const auteur = authorInfo(t, 'author_');
406 const booster = authorInfo(t, 'reblog_');
407 const boosterUri = t.reblog_url || t.reblog_handle || undefined;
408 return {
409 id: `${t.id}#create`,
410 // EEN BOOST IS EEN ANNOUNCE (shaer-nmw): een Create met een
411 // zijkanaal-property was onze uitvinding; de wrapper is de standaard, en
412 // elke AP-client leest hem al.
413 type: booster ? 'Announce' : 'Create',
414 actor: booster ? (AP.actorObject(boosterUri || t.author_uri, booster)) : t.author_uri,
415 published: t.published || t.created_at || undefined,
416 object: {
417 id: t.id,
418 type: 'Note',
419 // AS2 staat een INGESLOTEN actor toe; dan heeft elke client de byline,
420 // niet alleen de onze (shaer-nmw).
421 attributedTo: AP.actorObject(t.author_uri, auteur),
422 content: t.content,
423 url: t.url || undefined,
424 published: t.published || t.created_at || undefined,
425 sensitive: !!t.nsfw,
426 summary: t.cw || undefined,
427 // Friends' media travels along (media_json → AS2 attachment), so the
428 // client renders their images/audio like own outbox posts.
429 attachment: AP.gateAttachments(AP.timelineAttachments(t.media_json), { images: imagesAllowed, audio: musicAllowed, video: videoAllowed }),
430 // The note's preserved tags, so the client can render them: FEP-9098
431 // Emoji tags (:shortcode: → image) and FEP-e232 Link tags (quotes /
432 // inline object references). Combined into one `tag` array; omitted
433 // when the note has neither.
434 tag: (() => {
435 const tags = [...(emojiAllowed ? (AP.timelineEmojis(t.emoji_json) || []) : []), ...(AP.timelineObjectLinks(t.link_json) || [])];
436 return tags.length ? tags : undefined;
437 })(),
438 // Whether THIS account already liked/boosted the note, so the app's
439 // detail-view buttons show the current state (and can toggle/undo).
440 'shaer:liked': !!(reacties.get(t.id) || {}).liked,
441 'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
442 // FEP-044f: de geciteerde post als object, zodat de client een kaart
443 // rendert in plaats van een kale link. AS2 preview is diezelfde kaart
444 // voor een EXTERNE link: thumbnail, nooit de iframe van de aanbieder.
445 // Allebei weg zodra hun poort dicht staat; de speler in preview hangt
446 // aan de playback-poort.
447 quote: quotesAllowed ? AP.quoteObject(t.quote_json) : undefined,
448 preview: embedsAllowed ? AP.previewObject(t.embed_json, { playback: playbackAllowed }) : undefined,
449 },
450 };
451}
452
453/** Een inkomend antwoord op je eigen post als AS2-item. */
454function replyItem(m, { base, me, myHandle, p }) {
455 return {
456 id: `${m.object_uri}#create`,
457 type: 'Create',
458 actor: m.actor_uri,
459 published: AP.isoStamp(m.published || m.created_at),
460 object: {
461 id: m.object_uri,
462 type: 'Note',
463 attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
464 name: m.actor_name || undefined, handle: m.actor_handle || undefined,
465 icon: m.actor_icon || undefined, url: m.actor_url || undefined,
466 emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
467 }) : undefined),
468 content: AP.stripLeadingMentions(m.content),
469 inReplyTo: m.parent_uri || `${base}/ap/notes/${m.post_id}`,
470 published: AP.isoStamp(m.published || m.created_at),
471 to: [me],
472 tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
473 attachment: AP.timelineAttachments(m.media_json),
474 quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
475 preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
476 },
477 };
478}
479
480/** Wat deze lezer mag (FEP-633c 5.6), op EEN plek.
481 *
482 * De verschil-lezing draagt ze net zo goed: een antwoord zonder rechten zou
483 * de client naar zijn standaard laten terugvallen, en die standaard is
484 * 'alles mag'. Dan zet een gesloten poort zichzelf stil open. Dezelfde reden
485 * waarom een 304 de caps met rust laat.
486 */
487function capabilitiesOf(p, gate) {
488 return {
489 'shaer:externalEmbeds': p.embedsAllowed,
490 'shaer:externalPlayback': p.playbackAllowed,
491 // Leaving the app is the same decision as playing inside it: with the
492 // gate shut a link is shown but not followed, so the door is closed too
493 // and not just the picture over it.
494 'shaer:externalLinks': p.playbackAllowed,
495 // De rest van de familie (8-8): de app hoort VOORAF te weten wat hij mag
496 // aanbieden in plaats van het bij de eerste weigering te ontdekken. De
497 // (+) kaart leest shaer:compose al (Barts gate); de rest is er voor de
498 // schermen die nog komen. Serveren wat waar is kost hier niets.
499 'shaer:compose': p.composeAllowed,
500 'shaer:replies': p.repliesAllowed,
501 'shaer:messages': p.messagesAllowed,
502 'shaer:images': p.imagesAllowed,
503 'shaer:music': p.musicAllowed,
504 'shaer:video': p.videoAllowed,
505 'shaer:quoteCards': p.quotesAllowed,
506 'shaer:customEmoji': p.emojiAllowed,
507 'shaer:externalThreads': p.threadsAllowed,
508 'shaer:following': p.followingAllowed,
509 // Stond in de catalogus mét kolom, en ontbrak hier: de guardian zag de
510 // poort in zijn paneel en de app van het kind heeft er nooit van gehoord.
511 // Gevonden door de pariteitstest, niet door iemand die het toevallig zag.
512 'shaer:accountMove': gate('gate_account_move'),
513 };
514}
515
516// ── De poorten van een lezer, op EEN plek (FEP-633c) ─────────────
517//
518// De inbox-lezing rekende ze inline uit. Nu er meer lezingen zijn die
519// dezelfde poorten moeten eerbiedigen (de gesprekken, de geschiedenis), zou
520// dat evenveel kopieen worden -- en een poort die op een van die plekken
521// vergeten wordt, levert stil iets uit dat dicht hoorde te staan.
522function gatesFor(site) {
523 const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
524 const embeds = Guardianship.externalEmbedsAllowed(site.external_embeds, isWard);
525 const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
526 const emoji = gate('gate_custom_emoji');
527 return {
528 isWard,
529 embedsAllowed: embeds,
530 playbackAllowed: embeds && Guardianship.externalPlaybackAllowed(site.external_playback, isWard),
531 imagesAllowed: gate('gate_images'),
532 musicAllowed: gate('gate_music'),
533 videoAllowed: gate('gate_video'),
534 quotesAllowed: gate('gate_quote_cards'),
535 emojiAllowed: emoji,
536 messagesAllowed: gate('gate_messages'),
537 composeAllowed: gate('gate_compose'),
538 repliesAllowed: gate('gate_replies'),
539 threadsAllowed: gate('external_threads'),
540 followingAllowed: gate('gate_following'),
541 // Emoji dicht raakt ook de bylines: de plaatjes in een naam komen net zo
542 // goed van een vreemde server. De naam zelf blijft, met :shortcode: als tekst.
543 gateAuthor: (a) => (a && !emoji ? { ...a, emojis: undefined } : a),
544 };
545}
546
547/**
548 * De naam waaronder deze lezer zichzelf herkent in een Mention.
549 *
550 * Via deriveHandle op de actor-URI, niet uit de slug hier opgebouwd. Dit stond
551 * er als `@${slug}@${host}` met `@${slug}` als terugval, en die terugval is een
552 * HALVE naam: zonder host zegt @dev niets op een oppervlak waar iedereen @dev
553 * kan heten. Hij ging alleen af bij een onparseerbare PUBLIC_BASE_URL -- maar
554 * dan klopt elke URI die we bouwen al niet, en is de kale actor-URI (wat
555 * deriveHandle dan teruggeeft) eerlijker dan een naam die compleet lijkt.
556 */
557function ownHandle(base, slug) {
558 return AP.deriveHandle(AP.actorId(base, slug));
559}
560
561// ── Een bericht als AS2-item: EEN beschrijving van de kaartvorm ──
562//
563// Gebruikt door de inbox-lezing en door de gesprekslezingen. Twee keer
564// opschrijven is twee vormen die uit de pas kunnen lopen, en dat merk je pas
565// als een kaart ergens anders rendert dan waar je keek.
566function messageItem(m, { base, me, myHandle, p }) {
567 return {
568 id: `${m.object_uri}#create`,
569 type: 'Create',
570 actor: m.actor_uri,
571 published: AP.isoStamp(m.published || m.created_at),
572 object: {
573 id: m.object_uri,
574 type: 'Note',
575 attributedTo: AP.actorObject(m.actor_uri, (m.actor_name || m.actor_handle || m.actor_icon) ? p.gateAuthor({
576 name: m.actor_name || undefined, handle: m.actor_handle || undefined,
577 icon: m.actor_icon || undefined, url: m.actor_url || undefined,
578 emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
579 }) : undefined),
580 content: AP.stripLeadingMentions(m.content),
581 url: m.note_url || undefined,
582 // Waar dit een antwoord op is (Robins melding, 26-8). Zonder dit veld
583 // kwam elk antwoord binnen als het begin van een gesprek: de client kan
584 // een keten alleen teruglopen langs inReplyTo. De andere twee legs
585 // (replyItem uit ap_interactions, sentItem via buildNote) droegen hem
586 // al -- deze was de enige die hem niet eens opsloeg.
587 inReplyTo: m.in_reply_to || undefined,
588 published: AP.isoStamp(m.published || m.created_at),
589 // Addressed to us and to nobody we know of: the other recipients of a
590 // note to several people are not ours to see, so we serve what we know.
591 to: [me],
592 // The Mention is how the client recognises itself as the addressee and
593 // groups the note into a conversation. No FEP-e232 link tags here: a
594 // mention row keeps the resolved quote, not the raw tags.
595 tag: [{ type: 'Mention', href: me, name: myHandle }, ...(p.emojiAllowed ? (AP.timelineEmojis(m.emoji_json) || []) : [])],
596 attachment: AP.gateAttachments(AP.timelineAttachments(m.media_json), { images: p.imagesAllowed, audio: p.musicAllowed, video: p.videoAllowed }),
597 // FEP-633c: what kind of message this is. The wave is a gentle nudge from
598 // a guardian; the help request is the buoy. Both render differently.
599 'shaer:wave': m.wave ? true : undefined,
600 'shaer:helpRequest': m.help_request ? true : undefined,
601 quote: p.quotesAllowed ? AP.quoteObject(m.quote_json) : undefined,
602 preview: p.embedsAllowed ? AP.previewObject(m.embed_json, { playback: p.playbackAllowed }) : undefined,
603 },
604 };
605}
606
607/** Een eigen verzonden note als AS2-item, zelfde vorm als de inbox-leg. */
608function sentItem(n, { me, mine }) {
609 return {
610 id: `${n.id}#create`,
611 type: 'Create',
612 actor: me,
613 published: n.published,
614 // The leading mention anchor is addressing, not prose (the DM leg strips
615 // it the same way); the Mention tags built from the full content stay.
616 object: {
617 ...n, content: AP.stripLeadingMentions(n.content),
618 attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
619 },
620 };
621}
622
623// ── Gesprekken: eerst wie, dan pas wat (shaer-frontend-yso) ──────
624//
625// Twee lezingen naast de bestaande inbox-lezing, niet in de plaats ervan: de
626// apps in het veld lezen die nog. /conversations geeft EEN rij per tegenpartij
627// -- compleet van vorm, dus de avatarhemel kan niemand kwijtraken doordat een
628// ander druk was -- en /messages geeft een gesprek met een cursor, zodat een
629// 'load more' eerlijk kan verschijnen in plaats van dat de geschiedenis stil
630// ophoudt.
631//
632// Beide lopen langs dezelfde poorten als de inbox-lezing (gatesFor) en
633// dezelfde kaartvorm (messageItem/sentItem). Messages dicht sluit ook
634// hier vreemden en vrienden, maar nooit het guardian-kanaal en nooit de boei.
635function conversationItems(req, auth, refs) {
636 const base = baseUrl(req);
637 const P = gatesFor(auth.site);
638 const me = AP.actorId(base, auth.site.slug);
639 const ctx = { base, me, myHandle: ownHandle(base, auth.site.slug), p: P };
640 const mine = AP.selfAuthor(base, auth.site);
641 const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
642
643 const incoming = new Map(AP.messageRowsByUri(auth.site.slug, refs.filter((r) => r.direction === 'in').map((r) => r.ref))
644 .map((m) => [m.object_uri, m]));
645 // PAREN, geen losse lijst: een kop levert niet altijd een item op (dichte
646 // poort, ontbrekende rij), en dan zou de aanroeper op index koppelen en de
647 // telling aan het verkeerde gesprek hangen. Stil, en pas te zien als iemand
648 // een badge op de verkeerde naam ziet staan.
649 const pairs = [];
650 for (const r of refs) {
651 if (r.direction === 'in') {
652 const m = incoming.get(r.ref);
653 if (!m) continue;
654 if (!(P.messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))) continue;
655 pairs.push({ head: r, item: messageItem(m, ctx) });
656 } else {
657 const n = AP.getOutboxNote(base, r.ref);
658 // Je eigen woorden blijven van jou: een dichte messages-poort verbergt
659 // niet wat je zelf gezegd hebt.
660 if (n) pairs.push({ head: r, item: sentItem(n, { me, mine }) });
661 }
662 }
663 // WELK GESPREK, EN MET WIE (shaer-mbji). De client kon dat afleiden uit de
664 // adressering van de note -- maar een binnenkomer draagt die niet mee in zijn
665 // kaart, dus een bericht van Anna aan jou en Ben las als iets van Anna alleen.
666 // De server weet het: de sleutel staat in de kop. Twee termen, zodat een
667 // client die alleen de deelnemers wil niet een sleutel hoeft te ontleden.
668 for (const { head, item } of pairs) {
669 const obj = item && item.object;
670 if (!obj || !head.other) continue;
671 obj['shaer:conversation'] = head.other;
672 obj['shaer:participants'] = ConversationKey.participantsOfKey(head.other);
673 }
674 return pairs;
675}
676
677router.get('/ap/users/:slug/conversations', (req, res) => {
678 const auth = OAuth.verifyBearer(req.headers.authorization);
679 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
680 const heads = AP.conversationHeads(auth.site.slug);
681 const pairs = conversationItems(req, auth, heads);
682 const items = pairs.map((x) => x.item);
683 // Ongelezen per gesprek (shaer-frontend-3tx): een COUNT, geen bijgehouden
684 // getal. Hij hangt aan het NIEUWSTE kopje van elke persoon -- er kunnen er
685 // twee zijn (zie conversationHeads) en het aantal hoort bij het gesprek, niet
686 // bij een bericht.
687 //
688 // AS2 heeft geen term voor ongelezen; dit is per-lezer-interactiestatus,
689 // dezelfde categorie als shaer:liked. Niet in totalItems persen: dat betekent
690 // 'hoeveel er zijn' en niet 'hoeveel jij nog niet zag'.
691 // De poorten van DEZE lezer, niet die van de inbox-handler: die leeft in een
692 // andere functie en heette hier per ongeluk P.
693 const poorten = gatesFor(auth.site);
694 const ongelezen = AP.unreadPerConversation(auth.site.slug, {
695 messagesAllowed: poorten.messagesAllowed,
696 guardians: (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })(),
697 });
698 const gezien = new Set();
699 for (const { head, item } of pairs) {
700 if (gezien.has(head.other)) continue;
701 gezien.add(head.other);
702 const u = ongelezen.get(head.other);
703 if (!u) continue;
704 item.object['shaer:unread'] = u.n;
705 // Een zwaai is geen aantal maar een zetje van een guardian: eigen teken.
706 if (u.wave) item.object['shaer:unreadWave'] = true;
707 }
708 AP.sendAP(res, {
709 '@context': AP.AP_CONTEXT,
710 id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/conversations`,
711 type: 'OrderedCollection',
712 totalItems: items.length,
713 orderedItems: items,
714 'shaer:cursor': AP.feedCursor(auth.site.slug),
715 }, 'private, no-store');
716});
717
718router.get('/ap/users/:slug/messages', (req, res) => {
719 const auth = OAuth.verifyBearer(req.headers.authorization);
720 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
721 // EÉN OF MEER `with` (shaer-mbji). Eén is het gesprek met die persoon, zoals
722 // altijd; meer is de zachte groep van precies die mensen. De sleutel komt
723 // uit dezelfde functie als bij het schrijven, dus {Anna, Ben} en {Ben, Anna}
724 // vragen hetzelfde gesprek op.
725 const withs = [].concat(req.query.with || []).map(String);
726 if (!withs.length || !withs.every((u) => /^https?:\/\//i.test(u))) {
727 return res.status(400).json({ error: 'with must be one or more actor URIs' });
728 }
729 const other = ConversationKey.conversationKey(withs);
730 if (!other) return res.status(400).json({ error: 'with must be one or more actor URIs' });
731 const page = AP.conversationHistory(auth.site.slug, other, {
732 before: req.query.before ? String(req.query.before) : null,
733 limit: req.query.limit,
734 });
735 const items = conversationItems(req, auth, page.rows).map((x) => x.item);
736 // De paginagrootte reist mee in next: vroeg je om 30, dan hoort de volgende
737 // pagina er ook 30 te zijn. Zonder dit wordt hij stilletjes de standaard, en
738 // dan klopt het ritme van een 'load more' niet meer met wat de gebruiker ziet.
739 const size = req.query.limit ? `&limit=${encodeURIComponent(String(req.query.limit))}` : '';
740 // Elke deelnemer een eigen `with`, zodat de next-link hetzelfde gesprek
741 // opvraagt. De sleutel zelf er kaal in zetten zou bij een groep een spatie in
742 // de URL leggen, en wie die als één adres leest vraagt iets anders op.
743 const withQuery = ConversationKey.participantsOfKey(other).map((u) => `with=${encodeURIComponent(u)}`).join('&');
744 const self = `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/messages?${withQuery}`;
745 AP.sendAP(res, {
746 '@context': AP.AP_CONTEXT,
747 id: req.query.before ? `${self}${size}&before=${encodeURIComponent(String(req.query.before))}` : `${self}${size}`,
748 type: 'OrderedCollectionPage',
749 partOf: self,
750 orderedItems: items,
751 // De volgende pagina is de standaardvorm van 'er is meer' (AS2). Ontbreekt
752 // hij, dan is het gesprek op -- en dat mag de client weten zonder gokken,
753 // want anders kan een 'load more' niet eerlijk verschijnen.
754 next: page.more && page.oldest ? `${self}${size}&before=${encodeURIComponent(page.oldest)}` : undefined,
755 }, 'private, no-store');
756});
757
758// De bel (/inbox/wait) is weg (shaer-pq4, 10-8). Hij deed hetzelfde als de
759// WACHTENDE inbox-lezing hierboven, maar in twee rondjes in plaats van een:
760// eerst 'er is nieuws', dan alsnog de lezing. Die lezing kan het zelf, en
761// sinds ?changes=1 stuurt hij alleen nog het verschil.
762//
763// AP.onNews blijft bestaan: de Guardian-PWA hangt er ook aan.
764
765// The server blocklist is the source of truth for Shaer's "in Orbit":
766// clients read it here instead of keeping their own state. Actor-kind
767// blocks only (domain blocks are instance policy, not an Orbit member).
768//
769// FEP-1580 zet deze deur één spleet verder open: de bronkant MOET de blokkades
770// beschikbaar maken voor de instantie waar je NAARTOE verhuist, zodat je
771// zichtbaarheidsvoorkeuren meeverhuizen. De doelkant haalt ze als eerste op,
772// want ze bepalen wat de rest te zien krijgt. Geen nieuwe collectie: deze
773// bestond al en staat al op de actor, alleen de toegang verbreedt.
774router.get('/ap/users/:slug/blocked', async (req, res) => {
775 const auth = OAuth.verifyBearer(req.headers.authorization);
776 let slug = (auth && auth.site.slug === req.params.slug) ? auth.site.slug : null;
777 if (!slug && req.headers['signature']) {
778 const verified = await AP.verifyRequest(req).catch(() => null);
779 if (verified && verified.id && AP.isMoveTarget(req.params.slug, verified.id)) slug = req.params.slug;
780 }
781 if (!slug) return res.status(403).end();
782 const base = baseUrl(req);
783 const items = AP.listBlocks(slug)
784 .filter((b) => b.kind === 'actor')
785 .map((b) => b.target);
786 AP.sendAP(res, {
787 '@context': AP.AP_CONTEXT,
788 id: `${base}/ap/users/${slug}/blocked`,
789 type: 'OrderedCollection',
790 totalItems: items.length,
791 orderedItems: items,
792 }, 'private, no-store');
793});
794
795// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
796// The dashboard collections the Shaer clients read: pending adoption offers,
797// gated follows (empty in Klonkt for now) and the guardian's wards. Same
798// contract as the Shaer test daemon.
799function queueRoute(name, build) {
800 router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
801 const auth = OAuth.verifyBearer(req.headers.authorization);
802 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
803 const base = baseUrl(req);
804 const me = `${base}/ap/users/${auth.site.slug}`;
805 // 304 als er niets veranderde (Barts punt, 9-8). Zonder dit haalde een app
806 // bij elke actie de hele lijst opnieuw op -- een hulpvraag afvinken vroeg de
807 // honderd wards inclusief poorten terug.
808 AP.sendMaybe304(req, res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
809 });
810}
811queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
812queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
813// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
814// waiting on its guardians. Owner-only like the rest — who a child wants to
815// follow is nobody else's business.
816queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me));
817queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
818// Availability (FEP-633c 3.6.1) is never public: the ward reads its
819// guardians' real states here and nowhere else.
820queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
821
822// ── Het logboek (FEP-633c §4.2, shaer:log) ────────────────────────────
823// NAAST de wachtrijen en niet erin: alles onder shaer:queues wacht op een
824// antwoord, dit is wat er al besloten is. Eigen pad, dezelfde eigenaar-only
825// bearer. Het bestaat omdat een weigering anders alleen te merken viel doordat
826// er iets uit een lijst verdween, en "het is weg" is geen reden.
827router.get('/ap/users/:slug/log', (req, res) => {
828 const auth = OAuth.verifyBearer(req.headers.authorization);
829 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
830 const me = `${baseUrl(req)}/ap/users/${auth.site.slug}`;
831 AP.sendAP(res, {
832 '@context': AP.AP_CONTEXT,
833 ...Guardianship.logCollection(`${me}/log`, auth.site.slug, (s) => AP.listGuardianEvents(s, 50)),
834 }, 'private, no-store');
835});
836// De hulpvragen MET hun staat (5.2.1, shaer-lgo). De apps lazen ze uit de feed
837// en wisten dus niet of er al iemand op af was -- daarom bleef een afgehandeld
838// verzoek daar staan (Barts melding, 8-8).
839queueRoute('help', (id, slug) => Guardianship.helpCollection(id, slug));
840
841// ── Inbox read (owner only, AP C2S) ───────────────────────────────
842// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
843// scoped to this site) reads recent inbound posts (the timeline: accounts
844// they follow) as Create(Note) items, so an app (Shaer) can build a unified
845// feed. Anyone else gets 403; the inbox stays write-only for the public.
846router.get('/ap/users/:slug/inbox', async (req, res) => {
847 const auth = OAuth.verifyBearer(req.headers.authorization);
848 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
849 const base = baseUrl(req);
850 // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
851 // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
852 // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
853 // gedraagt de route zich exact zoals altijd.
854 //
855 // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
856 // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
857 // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
858 // ronde.
859 const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
860 if (req.query.since && wachtS > 0) {
861 const afbreken = new AbortController();
862 res.on('close', () => afbreken.abort()); // client hing op: niet doorgaan met wachten
863 const uit = await AP.waitForFeedChange(auth.site.slug, {
864 since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
865 });
866 if (res.writableEnded || afbreken.signal.aborted) return undefined;
867 // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
868 // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
869 // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
870 // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
871 // dat voor nieuws twee rondjes nodig heeft.
872 //
873 // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
874 // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
875 // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
876 // een lege merksteen sturen we dus gewoon de collectie.
877 if (!uit.changed && uit.cursor !== '0') {
878 res.set('Vary', 'Authorization');
879 return res.status(304).end();
880 }
881 }
882 // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
883 // world outside the fediverse is the guardians' call. The gate is applied
884 // here, at serialisation: a blocked embed is never sent, because an embed the
885 // client merely hides has still been delivered to the device.
886 // De poorten van deze lezer (gatesFor): een plek waar ze berekend worden,
887 // zodat de gesprekslezingen dezelfde stand eerbiedigen en niet hun eigen
888 // kopie krijgen die kan gaan afwijken.
889 const P = gatesFor(auth.site);
890 const {
891 embedsAllowed, playbackAllowed, imagesAllowed, musicAllowed, videoAllowed, quotesAllowed,
892 emojiAllowed, messagesAllowed, composeAllowed, repliesAllowed, threadsAllowed,
893 followingAllowed, gateAuthor,
894 } = P;
895 // De rechten-lijst hieronder vraagt er nog een paar rechtstreeks op.
896 const gate = (col) => Guardianship.wardGateAllowed(auth.site[col], P.isWard);
897 // ── Standaardvormen naast het dialect (shaer-nmw) ────────────────
898 //
899 // Een lezer die AS2 kent heeft nu genoeg aan attributedTo (ingesloten
900 // actor), quote (FEP-044f als object), preview (AS2 core) en de
901 // Announce-wrapper. De shaer:-velden blijven er nog naast staan voor apps
902 // in het veld; die gaan eruit als de clients om zijn.
903 // Wie ik ben en wie mijn guardians zijn: allebei de lezingen hieronder
904 // hebben ze nodig, dus een keer, hierboven.
905 const me = AP.actorId(base, auth.site.slug);
906 const myHandle = AP.deriveHandle(me); // een naam, of de kale URI -- nooit een halve
907 const guardianUris = (() => { try { return new Set(Guardianship.listGuardians(auth.site.slug).map((g) => g.other_uri)); } catch { return new Set(); } })();
908 // ── Alleen het VERSCHIL, als de client daarom vraagt (shaer-pq4) ──
909 //
910 // De wachtende lezing zei tot nu toe alleen DAT er iets veranderde, waarna de
911 // client alles opnieuw las: vier legs van zestig met al hun media-, quote- en
912 // embed-JSON, voor een enkel nieuw bericht. ap_feed_state houdt per object al
913 // bij wat er wanneer veranderde, dus het verschil lag er klaar en werd alleen
914 // nooit uitgedeeld (feedChangesSince had geen enkele aanroeper).
915 //
916 // OPT-IN met ?changes=1, en dat is geen franje: een app in het veld stuurt
917 // `since` al mee en vervangt haar hele feed door wat er terugkomt. Zou
918 // `since` opeens een verschil betekenen, dan wist die app zichzelf leeg.
919 //
920 // Het antwoord is een OrderedCollectionPage met partOf, want dat is wat het
921 // IS -- een deel, geen collectie. Een generieke lezer ziet dat verschil ook.
922 if (req.query.changes && req.query.since) {
923 const veranderd = AP.feedChangesSince(auth.site.slug, String(req.query.since));
924 const levend = veranderd.filter((c) => c.kind !== 'deleted').map((c) => c.object_uri);
925 const tl = new Map(AP.timelineRowsByIds(auth.site.slug, levend).map((r) => [r.id, r]));
926 const mn = new Map(AP.messageRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u))).map((r) => [r.object_uri, r]));
927 const rp = new Map(AP.replyRowsByUri(auth.site.slug, levend.filter((u) => !tl.has(u) && !mn.has(u))).map((r) => [r.object_uri, r]));
928 const reacties = AP.getReactionsFor(auth.site.slug, [...tl.keys()]);
929 const ctx = { base, me, myHandle, p: P };
930 const items = [];
931 for (const c of veranderd) {
932 if (c.kind === 'deleted') {
933 // Een verwijdering reisde tot nu toe als AFWEZIGHEID mee: de volledige
934 // lezing bevatte hem simpelweg niet meer. Die volledigheid is precies
935 // wat hier wegvalt, dus zonder grafsteen zou een weggehaalde post voor
936 // altijd in de app blijven staan -- en dat faalt stil. AS2 heeft er een
937 // vorm voor, en de rij lag er al.
938 items.push({ type: 'Delete', actor: me, object: { id: c.object_uri, type: 'Tombstone' } });
939 continue;
940 }
941 const t = tl.get(c.object_uri);
942 if (t) { items.push(timelineItem(t, { p: P, reactions: reacties })); continue; }
943 const m = mn.get(c.object_uri);
944 if (m) {
945 if (messagesAllowed || m.help_request || guardianUris.has(m.actor_uri)) items.push(messageItem(m, ctx));
946 continue;
947 }
948 const r = rp.get(c.object_uri);
949 if (r) { items.push(replyItem(r, ctx)); continue; }
950 const n = AP.getOutboxNote(base, c.object_uri);
951 if (n) items.push(sentItem(n, { me, mine: AP.selfAuthor(base, auth.site) }));
952 }
953 return AP.sendAP(res, {
954 '@context': AP.AP_CONTEXT,
955 id: `${base}/ap/users/${encodeURIComponent(auth.site.slug)}/inbox?changes=1&since=${encodeURIComponent(String(req.query.since))}`,
956 type: 'OrderedCollectionPage',
957 partOf: `${base}/ap/users/${auth.site.slug}/inbox`,
958 orderedItems: items,
959 // De rechten gaan MEE. Zonder dit valt de client terug op zijn standaard,
960 // en die standaard is 'alles mag' -- dan zet een gesloten poort zichzelf
961 // stil open bij elke verschil-lezing. Dezelfde reden waarom een 304 de
962 // caps met rust laat.
963 'shaer:capabilities': capabilitiesOf(P, gate),
964 'shaer:cursor': AP.feedCursor(auth.site.slug),
965 }, 'private, no-store');
966 }
967 const rows = AP.getTimeline(auth.site.slug, 60);
968 // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
969 // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
970 // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
971 const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
972 const posts = rows.map((t) => timelineItem(t, { p: P, reactions: reacties }));
973 // The direct notes addressed to this account: a plain DM, a guardian's wave
974 // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
975 // they are not in the timeline; without them the app's Berichten shows only
976 // what you said yourself. Same shape as a post, so one parser handles both.
977 // Messages dicht (shaer-3ow) sluit vreemden en vrienden, maar NOOIT het
978 // guardian-kanaal: de zwaai en het gesprek na een hulpvraag zijn precies
979 // het kanaal dat het kind veilig houdt, en een poort die dat afsnijdt
980 // beschermt niemand. De hulpvraag zelf gaat aan de innamekant al altijd voor.
981 const messageCtx = { base, me, myHandle, p: P };
982 const messages = AP.getDirectMessages(auth.site.slug, 60)
983 .filter((m) => messagesAllowed || m.help_request || guardianUris.has(m.actor_uri))
984 .map((m) => messageItem(m, messageCtx));
985 // Inbound REPLIES on your own posts: stored as interactions (the web's
986 // comment machinery), never as mentions, so this read missed them and a
987 // friend's reply arrived everywhere except in your app (Robins melding,
988 // 30-7). Same shape as the other legs; media/quotes ride the stored JSON.
989 const replies = AP.getReplyMessages(auth.site.slug, 60).map((m) => replyItem(m, messageCtx));
990 // Your OWN sent notes (replies and direct messages, ap_outbox): without
991 // them a reply existed everywhere except in your own app, Messages showed
992 // half a conversation, and a retry ran into the duplicate guard (Robins
993 // melding, 30-7). Served like the other legs: same shape, one parser.
994 const mine = AP.selfAuthor(base, auth.site);
995 const sent = AP.getSentNotes(base, auth.site, 60).map((n) => ({
996 id: `${n.id}#create`,
997 type: 'Create',
998 actor: me,
999 published: n.published,
1000 // The leading mention anchor is addressing, not prose (the DM leg strips
1001 // it the same way); the Mention tags built from the full content stay.
1002 object: {
1003 ...n, content: AP.stripLeadingMentions(n.content),
1004 attributedTo: AP.actorObject(typeof n.attributedTo === 'string' ? n.attributedTo : me, mine),
1005 },
1006 }));
1007 // Newest first over all legs, so the app can keep treating this as one feed.
1008 const items = [...posts, ...messages, ...replies, ...sent].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
1009 AP.sendAP(res, {
1010 '@context': AP.AP_CONTEXT,
1011 id: `${base}/ap/users/${auth.site.slug}/inbox`,
1012 type: 'OrderedCollection',
1013 // What this account may do with what is in here (FEP-633c 5.6). Owner-only
1014 // by construction, and never on the public actor document: it says
1015 // something about a child, and only the child and its guardians need it.
1016 'shaer:capabilities': capabilitiesOf(P, gate),
1017 // Het merk van wat hierin zit. Geef hem terug als `since` om op het
1018 // volgende te wachten. NA het samenstellen bepaald, zodat hij precies dekt
1019 // wat je in handen hebt en niet iets dat er ondertussen bij kwam.
1020 'shaer:cursor': AP.feedCursor(auth.site.slug),
1021 totalItems: items.length,
1022 orderedItems: items,
1023 });
1024 return undefined;
1025});
1026
1027// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
1028// The actor advertises endpoints.uploadMedia; this implements it. A bearer
1029// scoped to this site uploads one image/audio/video (multipart field "file",
1030// AP convention) into the same store the reply editor uses, and gets back
1031// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
1032const AP_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
1033fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
1034const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
1035
1036// Wat een bijlage mag wegen, PER SOORT (Barts 600 MB, 22-9).
1037//
1038// Eén plafond voor alles kan hier niet meer. 32 MB was voor een foto en een
1039// spraakmemo royaal en voor bewegend beeld niets: een half uur 720p op een
1040// bitrate die op een scherm goed oogt (~1,5 Mbit/s) is ruim 350 MB, dus het
1041// plafond moet daar overheen. Datzelfde getal voor een JPEG laten gelden zou
1042// een half-gigabyte-foto toelaten, en dat is geen limiet meer.
1043//
1044// Multer kent de soort nog NIET als het zijn limiet zet -- fileFilter draait
1045// op de naam, niet op de inhoud -- dus daar staat het hoogste getal en de
1046// soort-controle volgt in de handler, zodra req.file.mimetype er is.
1047const MAX_VIDEO_BYTES = 600 * 1024 * 1024;
1048const MAX_AUDIO_BYTES = 64 * 1024 * 1024;
1049const MAX_PICTURE_BYTES = 16 * 1024 * 1024;
1050const maxBytesFor = (mime) => (mime.startsWith('video/') ? MAX_VIDEO_BYTES
1051 : mime.startsWith('audio/') ? MAX_AUDIO_BYTES : MAX_PICTURE_BYTES);
1052
1053const apMediaUpload = multer({
1054 storage: multer.diskStorage({
1055 destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
1056 filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
1057 }),
1058 limits: { fileSize: MAX_VIDEO_BYTES },
1059 fileFilter: (req, file, cb) => {
1060 const ext = path.extname(file.originalname || '').toLowerCase();
1061 if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
1062 cb(null, true);
1063 },
1064});
1065// ── Identiteitsbewijzen (FEP-c390, shaer-jpxj) ────────────────────
1066//
1067// De eigenaar biedt een verklaring aan die met een sleutel van ELDERS is
1068// ondertekend: "did:key:... is ook deze actor". Deze server ondertekent niets;
1069// hij controleert (IdentityProof.checkStatement) en publiceert onder
1070// `attachment` op de actor, waar iedereen hem zelf kan nalopen.
1071//
1072// Eén per did:key: een nieuwe verklaring met dezelfde sleutel vervangt de
1073// vorige, want een verhuizing of een vernieuwde datum is dezelfde bewering.
1074// Een handvol is genoeg; meer is geen identiteit meer maar een verzameling.
1075const MAX_IDENTITY_PROOFS = 3;
1076function readProofs(site) {
1077 try { const a = JSON.parse(site.identity_proofs || '[]'); return Array.isArray(a) ? a : []; } catch { return []; }
1078}
1079function writeProofs(site, list) {
1080 db.prepare('UPDATE sites SET identity_proofs = ? WHERE id = ?').run(list.length ? JSON.stringify(list) : null, site.id);
1081 // De volgers horen dat de actor veranderde; anders blijft hun kopie de oude.
1082 const vers = db.prepare('SELECT * FROM sites WHERE id = ?').get(site.id);
1083 if (vers) AP.deliverActorUpdate(vers).catch((e) => console.warn('[c390] actor-update:', e && e.message));
1084}
1085router.post('/ap/users/:slug/identityProofs', express.json({ limit: '16kb', type: ['application/json', 'application/activity+json', 'application/ld+json'] }), (req, res) => {
1086 const auth = OAuth.verifyBearer(req.headers.authorization);
1087 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
1088 const base = baseUrl(req);
1089 const me = AP.actorId(base, auth.site.slug);
1090 const stmt = req.body;
1091 const uitslag = IdentityProof.checkStatement(stmt, me);
1092 if (!uitslag.ok) return res.status(422).json({ error: uitslag.error });
1093 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(auth.site.id);
1094 const rest = readProofs(site).filter((b) => b && b.subject !== uitslag.subject);
1095 if (rest.length >= MAX_IDENTITY_PROOFS) return res.status(409).json({ error: 'too_many_identity_proofs', max: MAX_IDENTITY_PROOFS });
1096 writeProofs(site, [...rest, stmt]);
1097 res.status(201).json({ subject: uitslag.subject, alsoKnownAs: me });
1098});
1099router.delete('/ap/users/:slug/identityProofs', (req, res) => {
1100 const auth = OAuth.verifyBearer(req.headers.authorization);
1101 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
1102 const subject = String(req.query.subject || '');
1103 if (!IdentityProof.publicKeyFromDidKey(subject)) return res.status(400).json({ error: 'subject must be an Ed25519 did:key' });
1104 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(auth.site.id);
1105 const voor = readProofs(site);
1106 const na = voor.filter((b) => b && b.subject !== subject);
1107 if (na.length === voor.length) return res.status(404).json({ error: 'no_such_identity_proof' });
1108 writeProofs(site, na);
1109 res.status(204).end();
1110});
1111
1112router.post('/ap/users/:slug/uploadMedia', (req, res) => {
1113 const auth = OAuth.verifyBearer(req.headers.authorization);
1114 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
1115 apMediaUpload.single('file')(req, res, (err) => {
1116 // Te groot is 413 en geen 400: een client die zijn eigen grens niet kent
1117 // moet aan de STATUS kunnen zien dat dit over de maat ging en niet over de
1118 // vorm, en `limit` geeft hem het getal in plaats van een gok.
1119 if (err) {
1120 if (err.code === 'LIMIT_FILE_SIZE') return res.status(413).json({ error: 'File too large', limit: MAX_VIDEO_BYTES });
1121 return res.status(400).json({ error: err.message });
1122 }
1123 if (!req.file) return res.status(400).json({ error: 'No file' });
1124 const mime = String(req.file.mimetype || '');
1125 if (!/^(image|audio|video)\//.test(mime)) {
1126 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
1127 return res.status(400).json({ error: 'Media must be an image, audio or video file' });
1128 }
1129 // De soort-grens, nu de soort bekend is. Het bestand staat al op schijf --
1130 // multer schrijft tijdens het ontvangen -- dus opruimen hoort erbij.
1131 const maxBytes = maxBytesFor(mime);
1132 if (req.file.size > maxBytes) {
1133 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
1134 return res.status(413).json({ error: 'File too large', limit: maxBytes });
1135 }
1136 // A video gets a poster frame next to it (shaer-zowq), best-effort and
1137 // out of band: ffmpeg pulls one frame at 1s into <name>.poster.jpg. On a
1138 // machine without ffmpeg nothing happens and nothing breaks; the clients
1139 // fall back to extracting a frame natively.
1140 if (mime.startsWith('video/')) {
1141 // The bundled static build (ffmpeg-static) does the work, exactly like
1142 // VideoCoverService and AudioTranscoder already do: Klonkt SHIPS its
1143 // ffmpeg (Robins opmerking, 30-7), so nothing needs installing on any
1144 // machine. Soft dependency + best-effort: absent stays silent, and
1145 // FFMPEG_PATH can still override for an operator who wants a newer one.
1146 Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
1147 const bin = process.env.FFMPEG_PATH || ff.default;
1148 if (!bin) return;
1149 const poster = req.file.path + '.poster.jpg';
1150 execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-ss', '1', '-i', req.file.path, '-frames:v', '1', '-vf', "scale='min(640,iw)':-2", poster],
1151 { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] poster failed:', e.message); });
1152 }).catch(() => { /* never blocks the upload */ });
1153 }
1154 // Audio gets the same courtesy (Robins vraag, 30-7: vrolijk de kale
1155 // audio-tegel op): ffmpeg draws the waveform into <name>.poster.png.
1156 // White on transparent, so the tile's own gradient stays the backdrop
1157 // and every audio post keeps its own hue. The shape is bars, not the
1158 // raw hairy wave (Robins tweede vraag): peak and average sampled into
1159 // 57 columns (soft tip over bright core), blown up nearest-neighbor to
1160 // 14px bars, and drawgrid ERASES 5px gaps (c=black@0 + replace=1 writes
1161 // transparent pixels; h=2*ih keeps horizontal grid lines out of frame).
1162 if (mime.startsWith('audio/')) {
1163 Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
1164 const bin = process.env.FFMPEG_PATH || ff.default;
1165 if (!bin) return;
1166 const poster = req.file.path + '.poster.png';
1167 const graph = '[0:a]aformat=channel_layouts=mono,asplit[a][b];'
1168 + '[a]showwavespic=s=57x256:colors=white@0.5:filter=peak:scale=sqrt:draw=full[pk];'
1169 + '[b]showwavespic=s=57x256:colors=white:filter=average:scale=sqrt:draw=full[av];'
1170 + '[pk][av]overlay=format=auto,scale=798:256:flags=neighbor,drawgrid=w=14:h=2*ih:t=5:c=black@0:replace=1';
1171 execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-i', req.file.path, '-filter_complex', graph, '-frames:v', '1', poster],
1172 { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] waveform failed:', e.message); });
1173 }).catch(() => { /* never blocks the upload */ });
1174 }
1175 res.status(201).json({
1176 url: '/media/reply-media/' + req.file.filename,
1177 mediaType: mime,
1178 name: String(req.file.originalname || '').slice(0, 120),
1179 });
1180 });
1181});
1182
1183// ── Followers (count-only public, full for the owner) ─────────────
1184// A C2S bearer scoped to this site (the account owner) gets the real actor
1185// URIs so their own client can build a friends list; everyone else gets the
1186// count only (privacy).
1187// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
1188// Returns true and sets the response headers when the owner asked for it.
1189function wantsEnriched(req, res) {
1190 res.set('Vary', 'Prefer'); // enriched and bare are two representations
1191 if (AP.prefersEnriched(req.get('Prefer'))) {
1192 res.set('Preference-Applied', 'return=representation');
1193 return true;
1194 }
1195 return false;
1196}
1197
1198router.get('/ap/users/:slug/followers', (req, res) => {
1199 const auth = OAuth.verifyBearer(req.headers.authorization);
1200 const owner = auth && auth.site.slug === req.params.slug;
1201 const site = owner ? auth.site : publicSite(req.params.slug);
1202 if (!site) return res.status(404).end();
1203 if (owner) {
1204 const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
1205 // Default = bare references; enrich only when the client asks (FEP-9876).
1206 const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
1207 return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items, { page: paginaNr(req) }));
1208 }
1209 const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
1210 AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n, null, { page: paginaNr(req) }));
1211});
1212
1213// ── Following (count-only public, full for the owner) ─────────────
1214router.get('/ap/users/:slug/following', (req, res) => {
1215 const auth = OAuth.verifyBearer(req.headers.authorization);
1216 const owner = auth && auth.site.slug === req.params.slug;
1217 const site = owner ? auth.site : publicSite(req.params.slug);
1218 if (!site) return res.status(404).end();
1219 if (owner) {
1220 const enrich = wantsEnriched(req, res); // FEP-9876 opt-in
1221 let items = [];
1222 try {
1223 const uris = db.prepare("SELECT actor_uri FROM ap_following WHERE slug = ? AND status = 'accepted' ORDER BY created_at").all(site.slug).map((r) => r.actor_uri);
1224 items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
1225 } catch { /* table may not exist */ }
1226 return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items, { page: paginaNr(req) }));
1227 }
1228 let n = 0;
1229 try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
1230 AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n, null, { page: paginaNr(req) }));
1231});
1232
1233/**
1234 * Mag deze aanvrager alles van `slug` zien? Waar bij de eigenaar zelf, en waar
1235 * voor de actor waar `slug` naartoe verhuisd is (FEP-1580, Source Instance).
1236 *
1237 * Eén plek voor die vraag, want hij komt op meerdere collecties terug en twee
1238 * antwoorden op dezelfde vraag lopen vroeg of laat uiteen.
1239 */
1240async function magAlles(req, slug) {
1241 const auth = OAuth.verifyBearer(req.headers.authorization);
1242 if (auth && auth.site.slug === slug) return true;
1243 if (!req.headers['signature']) return false;
1244 const v = await AP.verifyRequest(req).catch(() => null);
1245 return !!(v && v.id && AP.isMoveTarget(slug, v.id));
1246}
1247
1248// ── FEP-1580: de vertaaltabel van een verhuizing ──────────────────
1249//
1250// Publiek leesbaar, want dat is het hele doel: een derde die een oude URI in
1251// zijn database heeft leest hier wat de nieuwe is. Zonder deze collectie blijft
1252// elke reactie op een verhuisd bericht naar een dood adres wijzen.
1253//
1254// Niet-publieke items komen er alleen in voor een lezer die ze mocht zien. De
1255// spec: Moves voor objecten die niet aan as:Public gericht zijn MOGEN NIET
1256// publiek getoond worden. Een lijst met de URIs van je fan-only posts is een
1257// lek, ook al staat de inhoud er niet bij.
1258router.get('/ap/users/:slug/migration', async (req, res) => {
1259 const site = publicSite(req.params.slug);
1260 if (!site) return res.status(404).end();
1261 let alles = false;
1262 const auth = OAuth.verifyBearer(req.headers.authorization);
1263 if (auth && auth.site.slug === site.slug) alles = true;
1264 else if (req.headers['signature']) {
1265 const v = await AP.verifyRequest(req).catch(() => null);
1266 // Een geverifieerde volger zat in het publiek van de fan-only posts, dus
1267 // die mag ook weten waar ze heen zijn.
1268 if (v && v.id && AP.outboxAudience(site.slug, { verifiedActor: v.id }) === 'friend') alles = true;
1269 }
1270 AP.sendAP(res, Migration.buildMigration(baseUrl(req), site, { page: paginaNr(req), alles }),
1271 alles ? 'private, no-store' : undefined);
1272});
1273
1274// De Moves die de vertaaltabel rechtvaardigen. Altijd publiek: een bewijs dat
1275// je moet kunnen nakijken heeft niets aan een slot.
1276//
1277// LET OP: zonder FEP-8b32 (shaer-j1v0) staat hier geen handtekening onder. De
1278// collectie is structureel goed en niet verifieerbaar, en een derde die de spec
1279// streng volgt mag hem daarom weigeren. Bewust geen leeg proof-veld erbij.
1280router.get('/ap/users/:slug/moves', (req, res) => {
1281 const site = publicSite(req.params.slug);
1282 if (!site) return res.status(404).end();
1283 AP.sendAP(res, Migration.buildMoves(baseUrl(req), site));
1284});
1285
1286// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
1287router.get('/ap/users/:slug/featured', (req, res) => {
1288 const site = publicSite(req.params.slug);
1289 if (!site) return res.status(404).end();
1290 // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
1291 // last-processed-first). So we emit it reversed (lowest pin priority first,
1292 // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
1293 // fan_only, ap_visibility, paid en excerpt MOETEN mee, om dezelfde reden als
1294 // in outboxSlice en backfillNewFollower (shaer-6oth, en Barts melding van
1295 // 15-8). buildNote beslist op deze velden, en een ontbrekende kolom is daar
1296 // `undefined` -- wat stilletjes het ruimste gedrag oplevert:
1297 //
1298 // zonder `paid` slaat buildNote zijn redactie over en gaat de VOLLEDIGE
1299 // tekst van een betaalde post mee. Deze collectie is
1300 // publiek en onbetekend opvraagbaar, dus dat is de post
1301 // gewoon te lezen. `excerpt` hoort erbij, anders valt de
1302 // teaser terug op de eerste alinea van precies de tekst
1303 // die verborgen moet blijven.
1304 // zonder ap_vis krijgt een quiet/unlisted post `to: as:Public` in plaats
1305 // van zijn volgers -- luider dan de schrijver koos.
1306 //
1307 // Het filter erbij: fan_only stond er al, ap_visibility ontbrak. Een post die
1308 // niet publiek bedoeld is hoort niet in een publieke collectie, ook niet als
1309 // hij vastgezet is.
1310 const posts = db.prepare(
1311 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at,
1312 fan_only, ap_visibility, paid, paid_min_cents, excerpt
1313 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
1314 AND IFNULL(ap_visibility, 'public') IN ('public', 'quiet')
1315 AND pinned IS NOT NULL AND pinned > 0
1316 ORDER BY pinned DESC, ${isoSql('COALESCE(published_at, created_at)')} ASC LIMIT 20`
1317 ).all(site.id);
1318 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts, { page: paginaNr(req) }));
1319});
1320
1321// ── Playlist als dereferenceerbare AP-collectie (shaer-ayc) ───────
1322// De eerste stap van het Funkwhale-spoor: een playlist heeft een id, dus een
1323// stabiele URI. Alleen het fedi_open-deel staat erin (de poort is per bestand
1324// en eenrichtings; zie setAudioFediOpen in routes/posts.js) — een collectie
1325// zonder open tracks bestaat wel maar is leeg, want de playlist zelf is niet
1326// geheim, alleen de bestanden erachter.
1327// De lijst van alle playlist-collecties (shaer-ayc, stap 2). De actor wijst
1328// hierheen via AS2 `streams`. Kaal standaard; verrijkte stubs op verzoek
1329// (FEP-9876), dezelfde conventie als followers/following.
1330router.get('/ap/users/:slug/playlists', (req, res) => {
1331 const site = publicSite(req.params.slug);
1332 if (!site) return res.status(404).end();
1333 AP.sendAP(res, AP.listPlaylistsAP(baseUrl(req), site, wantsEnriched(req, res), { page: paginaNr(req) }));
1334});
1335
1336// De tracks van deze site: de kanonieke plek voor onze muziek (shaer-0nh,
1337// stap 3). Een playlist is een keuze hieruit; deze collectie is alles wat de
1338// artiest heeft opengezet, ook wat in geen enkele playlist staat.
1339router.get('/ap/users/:slug/tracks', async (req, res) => {
1340 const site = publicSite(req.params.slug);
1341 if (!site) return res.status(404).end();
1342 AP.sendAP(res, AP.buildTrackCollection(baseUrl(req), site, AP.siteOpenTracks(site.id, { alles: await magAlles(req, site.slug) }), { page: paginaNr(req) }));
1343});
1344
1345// De bibliotheek van deze site (shaer-0nh). Funkwhale's Audio draagt een
1346// `library`, en dat is bij hen het haakje waar een UPLOAD aan komt te hangen --
1347// zonder die bak blijft een binnengehaalde track daar een naam zonder geluid.
1348// Gemeten op 13-8: open.audio had onze vier tracks wel, met onze eigen AP-id's,
1349// maar uploads leeg en is_playable false.
1350//
1351// Openbaar, want alles erin is fedi_open. Er valt dus niets goed te keuren en de
1352// volgerslijst blijft leeg: wie ons volgt volgt de ACTOR, niet de bak.
1353//
1354// `?page=` MOET hier doorgegeven worden. Zonder dat adverteert de wortel een
1355// `first` die op zichzelf uitkomt: de lezer volgt hem, krijgt weer een `Library`
1356// in plaats van een pagina, en klapt eruit -- open.audio gaf op 15-8 een 500 op
1357// precies deze URL. Dezelfde les als bij de outbox (shaer-sk4): een `first`
1358// beloven is een pagina beloven.
1359router.get('/ap/users/:slug/library', (req, res) => {
1360 const site = publicSite(req.params.slug);
1361 if (!site) return res.status(404).end();
1362 AP.sendAP(res, AP.buildLibrary(baseUrl(req), site, AP.siteOpenTracks(site.id), { page: paginaNr(req) }));
1363});
1364
1365// De volgerscollectie die hun docs als vereist noemen. Leeg en eerlijk: er is
1366// geen goedkeuringspad omdat de bibliotheek openbaar is.
1367router.get('/ap/users/:slug/library/followers', (req, res) => {
1368 const site = publicSite(req.params.slug);
1369 if (!site) return res.status(404).end();
1370 AP.sendAP(res, AP.pagedCollection(`${AP.libraryId(baseUrl(req), site)}/followers`, [], { page: paginaNr(req) }));
1371});
1372
1373// Eén track, los op te halen. Een gesloten track is AFWEZIG, niet leeg: 404,
1374// dezelfde regel als in de collectie, zodat het bestaan van een gated nummer
1375// niet uit een ander antwoord af te leiden is.
1376router.get('/ap/users/:slug/tracks/:id', (req, res) => {
1377 const site = publicSite(req.params.slug);
1378 if (!site) return res.status(404).end();
1379 const row = AP.openTrack(site.id, req.params.id);
1380 if (!row) return res.status(404).end();
1381 AP.sendAP(res, AP.buildTrackAudio(baseUrl(req), site, row, { standalone: true }));
1382});
1383
1384// De losse tracks van een post als EEN uitgave (shaer-38y). Ze gingen tot nu
1385// toe los de deur uit -- Audio-objecten die een lezer nergens kon plaatsen. Ze
1386// horen bij elkaar omdat ze in dezelfde post staan, en die post leent zijn
1387// titel, tekst, hoes en tags uit. 404 als de post geen muzikale eenheid IS:
1388// dan is er niets om naar te wijzen, en dat is geen lege collectie maar een
1389// collectie die niet bestaat.
1390router.get('/ap/users/:slug/posts/:id/tracks', (req, res) => {
1391 const site = publicSite(req.params.slug);
1392 if (!site) return res.status(404).end();
1393 const post = db.prepare(
1394 "SELECT id, slug, title, excerpt, content, cover_image_url, tags FROM posts WHERE id = ? AND site_id = ? AND status = 'published'"
1395 ).get(req.params.id, site.id);
1396 if (!post) return res.status(404).end();
1397 const col = AP.buildPostTrackCollection(baseUrl(req), site, post);
1398 if (!col) return res.status(404).end();
1399 AP.sendAP(res, col);
1400});
1401
1402router.get('/ap/users/:slug/playlists/:id', async (req, res) => {
1403 const site = publicSite(req.params.slug);
1404 if (!site) return res.status(404).end();
1405 const pl = db.prepare('SELECT id, title, artist, year, cover_url, kind, release_date, mb_release_id, created_at FROM playlists WHERE id = ? AND site_id = ?')
1406 .get(req.params.id, site.id);
1407 if (!pl) return res.status(404).end();
1408 // De doel-actor van een verhuizing krijgt de VOLLEDIGE plaat, niet alleen de
1409 // nummers die voor de fediverse opengezet zijn (FEP-1580).
1410 const alles = await magAlles(req, site.slug);
1411 AP.sendAP(res, AP.buildPlaylistCollection(baseUrl(req), site, pl, AP.playlistOpenTracks(pl.id, { alles })));
1412});
1413
1414// ── Note ──────────────────────────────────────────────────────────
1415router.get('/ap/notes/:id', async (req, res) => {
1416 // No fan_only filter in the SELECT anymore: a friends-only post is not
1417 // absent, it is GATED. The old route hid it from EVERYONE, also from the
1418 // follower whose friendship earns it — so the signed resolution the reply
1419 // path performs knocked on a door that could never open, and every reply
1420 // to a friends-only post (Shaer's default!) died in
1421 // cannot_resolve_inReplyTo. Strangers still get the exact same 404, so a
1422 // note's existence stays as private as before.
1423 // EEN GEBLOKKEERDE KRIJGT DE DEUR DICHT (Robin, 21-8), net als bij de outbox:
1424 // wie ondertekend aanklopt, klopt met zijn naam erop, en een blokkade is een
1425 // gesloten deur. Dezelfde 404 als een vreemde, zodat het bestaan van een note
1426 // niets extra's verraadt. Onbetekende verzoeken kunnen we niet thuisbrengen
1427 // en houden de publieke weergave -- daarvoor is de Block-bezorging.
1428 if (req.headers['signature']) {
1429 const wie = await AP.verifyRequest(req).catch(() => null);
1430 if (wie && wie.id && AP.isBlockedAny(wie.id)) return res.status(404).end();
1431 }
1432 const post = db.prepare(
1433 "SELECT * FROM posts WHERE id = ? AND status = 'published'"
1434 ).get(req.params.id);
1435 if (post && AP.noteAudience(post) !== 'public') {
1436 // The whole gate in a try: this is the only async route in this file,
1437 // and Express 4 does not catch an async rejection — the request would
1438 // hang forever instead of failing (which is exactly how the missing
1439 // default-export entry manifested while building this). Any error here
1440 // reads as "not authorized", never as silence.
1441 try {
1442 if (AP.noteAudience(post) === 'direct') return res.status(404).end();
1443 const gsite = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
1444 const actor = await AP.verifyRequest(req).catch(() => null);
1445 if (!actor || !AP.mayReadNote(gsite, post, actor.id)) return res.status(404).end();
1446 } catch { return res.status(404).end(); }
1447 }
1448 if (!post) {
1449 // Could be one of OUR outbound replies (ap_outbox), not a post.
1450 const note = AP.getOutboxNote(baseUrl(req), req.params.id);
1451 if (!note) return res.status(404).end();
1452 if (!AP.apWants(req)) {
1453 // A browser hit a reply's AP URL → send them to the source it replies to
1454 // (where the post + its reactions live), falling back to the site home.
1455 const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
1456 ? note.inReplyTo : (baseUrl(req) + '/');
1457 return res.redirect(302, src);
1458 }
1459 return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
1460 }
1461 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
1462 if (!site) return res.status(404).end();
1463 const note = AP.buildNote(baseUrl(req), site, post);
1464 if (!AP.apWants(req)) {
1465 // A browser hit a post's AP note URL → send them to the human post page
1466 // (which shows the post + its "from the fediverse" reactions).
1467 return res.redirect(302, note.url || (baseUrl(req) + '/'));
1468 }
1469 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
1470});
1471
1472// ── Replies collection ── lets remote servers fetch a post's whole thread.
1473// ── De composer-preview (shaer-k3f): een URL wordt alvast een kaart ──
1474//
1475// Bearer-only, net als de thread: dit is de eigen app die tijdens het typen
1476// vraagt wat een link gaat worden. Dezelfde pijplijn als publiceren, dus de
1477// preview kan niet iets beloven dat de post niet waarmaakt. De embed gaat
1478// langs de eigen poort van de lezer -- een ward zonder open embeds-poort
1479// krijgt in de composer geen kaart die zijn feed hem ook niet zou tonen.
1480router.get('/ap/users/:slug/card', async (req, res) => {
1481 const auth = OAuth.verifyBearer(req.headers.authorization);
1482 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
1483 const uit = await AP.previewCard(String(req.query.url || ''));
1484 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
1485 const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
1486 const playback = embedsAllowed && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
1487 AP.sendAP(res, {
1488 '@context': AP.AP_CONTEXT,
1489 quote: AP.quoteObject(uit.quoteJson),
1490 preview: embedsAllowed ? AP.previewObject(uit.embedJson, { playback }) : undefined,
1491 }, 'private, no-store');
1492});
1493
1494// De poorten op één vreemde note, voor de thread en voor de losse note:
1495// beeld, muziek, video en emoji, per verzoek en buiten elke cache om. De
1496// emoji-poort knipt in de byline zelf: FEP-9098 zit in de tag van de
1497// ingesloten actor, niet meer in een eigen emoji-kaart ernaast.
1498function readerGates(site, isWard) {
1499 const gate = (col) => Guardianship.wardGateAllowed(site[col], isWard);
1500 const images = gate('gate_images'), audio = gate('gate_music'), video = gate('gate_video');
1501 const emoji = gate('gate_custom_emoji');
1502 return (n) => ({
1503 ...n,
1504 attachment: AP.gateAttachments(n.attachment, { images, audio, video }),
1505 tag: emoji ? n.tag : AP.stripEmojiTags(n.tag),
1506 attributedTo: (!emoji && n.attributedTo && typeof n.attributedTo === 'object')
1507 ? { ...n.attributedTo, tag: undefined } : n.attributedTo,
1508 });
1509}
1510
1511// ── De thread onder een post (shaer-tqz): ophalen, niet bewaren ────
1512//
1513// Bearer-only: dit is de eigen app van deze account die vraagt, nooit een
1514// vreemde. Klonkt doet de ondertekende GET die de app zelf niet kan (de
1515// sleutel staat hier), loopt één pagina van de replies-collectie af en geeft
1516// genormaliseerde notes terug. Er wordt NIETS opgeslagen; zie getThread.
1517//
1518// Voor een ward geldt de veiligste stand tot shaer-vw4 beslist is: alleen
1519// antwoorden uit de kring die de guardians al kennen, en shaer:hidden telt wat
1520// er buiten viel. De telling staat er zodat de UI eerlijk kan zijn -- OF hij
1521// getoond wordt is onderdeel van datzelfde besluit.
1522router.get('/ap/users/:slug/thread', async (req, res) => {
1523 const auth = OAuth.verifyBearer(req.headers.authorization);
1524 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
1525 const objectUri = String(req.query.object || '');
1526 if (!/^https:\/\//i.test(objectUri)) return res.status(400).json({ error: 'object must be an https URI' });
1527 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
1528 const uit = await AP.getThread(auth.site.slug, objectUri);
1529 if (!uit.found) {
1530 // WIENS schuld is dit? De oude melding zei "jouw server kon het niet
1531 // laden" terwijl onze server het prima deed en de BRON weigerde -- dat
1532 // wees naar de verkeerde partij (Barts melding, 10-8: een post van een
1533 // account dat hij vanochtend nog volgde, en dat nu niet meer).
1534 // 401/403/404/410 is een besluit van die server; al het andere, inclusief
1535 // een status die we niet eens kregen, is een storing.
1536 const geweigerd = [401, 403, 404, 410].includes(uit.sourceStatus);
1537 return res.status(geweigerd ? 404 : 502)
1538 .json({ error: geweigerd ? 'not shared by source' : 'source unreachable', sourceStatus: uit.sourceStatus || undefined });
1539 }
1540 // De poortstand komt uit de kolom (shaer-9y2): expliciete 0/1 van de
1541 // guardians wint, de automatiek is dicht-voor-een-ward. Dicht is de KRING,
1542 // niet niets: antwoorden van al goedgekeurd volk blijven staan, en wat er
1543 // buiten valt wordt geteld. Beeld, muziek en emoji gaan door dezelfde
1544 // poorten als de tijdlijn -- per verzoek, buiten de threadcache om.
1545 const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard);
1546 const kring = threadsOpen ? { notes: uit.notes, hidden: 0 } : AP.filterThreadToCircle(auth.site.slug, uit.notes);
1547 uit.notes = kring.notes.map(readerGates(auth.site, isWard));
1548 uit.hidden = kring.hidden;
1549 // Liked/boosted per antwoord, BUITEN de cache om: de genormaliseerde notes
1550 // mogen twee minuten oud zijn, maar of JIJ iets geliked hebt hoort van nu te
1551 // zijn -- anders springt het hartje terug zodra de reader opnieuw opent.
1552 const reacties = AP.getReactionsFor(auth.site.slug, uit.notes.map((n) => n.id));
1553 // De thread heeft al een ?object= in zijn id, dus geen ?page= erachter: die
1554 // collectie is niet te pagineren zonder de vraag zelf te herhalen. Hij is
1555 // owner-only en wordt door Shaer gelezen, niet door de federatie.
1556 AP.sendAP(res, {
1557 '@context': AP.AP_CONTEXT,
1558 id: `${baseUrl(req)}/ap/users/${encodeURIComponent(auth.site.slug)}/thread?object=${encodeURIComponent(objectUri)}`,
1559 type: 'OrderedCollection',
1560 totalItems: uit.notes.length,
1561 orderedItems: uit.notes.map((n) => ({
1562 ...n,
1563 'shaer:liked': !!(reacties.get(n.id) || {}).liked,
1564 'shaer:boosted': !!(reacties.get(n.id) || {}).boosted,
1565 })),
1566 'shaer:hidden': uit.hidden || undefined,
1567 }, 'private, no-store');
1568});
1569
1570// ── Eén note, voor wie een gesprek naar boven volgt ────────────────
1571//
1572// De thread hierboven geeft wat onder een post hangt; dit geeft de post zelf.
1573// Een lezer die een antwoord kreeg en de ouder niet, kan die ouder bij een
1574// server in secure mode alleen ondertekend halen, en de sleutel staat hier.
1575// Bearer-only en niets bewaren, net als de thread. Dezelfde weigering-of-
1576// storing en dezelfde poorten; voor een ward met een dichte kring is een note
1577// van buiten de kring er niet, en dat wordt gezegd in plaats van verzwegen.
1578router.get('/ap/users/:slug/object', async (req, res) => {
1579 const auth = OAuth.verifyBearer(req.headers.authorization);
1580 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
1581 const objectUri = String(req.query.uri || '');
1582 if (!/^https:\/\//i.test(objectUri)) return res.status(400).json({ error: 'uri must be an https URI' });
1583 const uit = await AP.getObject(auth.site.slug, objectUri);
1584 if (!uit.found) {
1585 const geweigerd = [401, 403, 404, 410].includes(uit.sourceStatus);
1586 return res.status(geweigerd ? 404 : 502)
1587 .json({ error: geweigerd ? 'not shared by source' : 'source unreachable', sourceStatus: uit.sourceStatus || undefined });
1588 }
1589 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
1590 const threadsOpen = Guardianship.wardGateAllowed(auth.site.external_threads, isWard);
1591 if (!threadsOpen && !AP.filterThreadToCircle(auth.site.slug, [uit.note]).notes.length) {
1592 return res.status(404).json({ error: 'outside the circle', 'shaer:hidden': 1 });
1593 }
1594 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...readerGates(auth.site, isWard)(uit.note) }, 'private, no-store');
1595});
1596
1597router.get('/ap/notes/:id/replies', (req, res) => {
1598 const base = baseUrl(req);
1599 const items = AP.getReplyUris(base, req.params.id);
1600 AP.sendAP(res, AP.pagedCollection(`${base}/ap/notes/${req.params.id}/replies`, items));
1601});
1602
1603// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
1604router.get('/.well-known/nodeinfo', (req, res) => {
1605 res.type('application/json');
1606 res.set('Cache-Control', 'public, max-age=3600');
1607 res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
1608});
1609router.get('/nodeinfo/2.1', (req, res) => {
1610 let users = 0; let posts = 0;
1611 // "users" = public AP actors (sites), not the admin/member account rows.
1612 try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
1613 try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
1614 res.type('application/json; charset=utf-8');
1615 res.set('Cache-Control', 'public, max-age=600');
1616 res.send(JSON.stringify({
1617 version: '2.1',
1618 software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
1619 protocols: ['activitypub'],
1620 services: { inbound: [], outbound: [] },
1621 openRegistrations: false,
1622 usage: { users: { total: users }, localPosts: posts },
1623 metadata: { nodeName: 'Klonkt' },
1624 }));
1625});
1626
1627// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
1628const apJson = express.json({
1629 type: ['application/activity+json', 'application/ld+json', 'application/json'],
1630 limit: '1mb',
1631 verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
1632});
1633router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
1634 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
1635 // Met de STACK erbij. Hier stond alleen `e.message`, en op 15-8 leverde dat
1636 // zes keer "[AP inbox] error: slug is not defined" op zonder één aanwijzing
1637 // waar -- een ReferenceError in een handler van duizenden regels, met een
1638 // naam die overal voorkomt. Een fout die je niet kunt plaatsen is niet
1639 // gemeld. Het type en de activiteit erbij, want dat zegt welke tak liep.
1640 catch (e) {
1641 const soort = req.body && req.body.type;
1642 console.warn('[AP inbox] error:', e.message, '| type:', soort, '| slug:', req.params.slug || '(gedeeld)');
1643 console.warn(e.stack);
1644 return res.status(202).end();
1645 }
1646});
1647
1648// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
1649// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
1650// the normal delivery machinery. The token is scoped to one user+site (OAuth
1651// consent), so it must match the slug in the URL. (Declared after apJson, which
1652// this shares with the inbox handler.)
1653router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
1654 const auth = OAuth.verifyBearer(req.headers.authorization);
1655 if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
1656 if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
1657 if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
1658
1659 const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
1660 if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
1661 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
1662 if (out.status === 201 && out.url) res.set('Location', out.url);
1663 // `state` carries a third outcome the app must be able to tell apart from a
1664 // plain success: a ward's follow held for its guardians (§5.3, shaer-p729).
1665 return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url, ...(out.state ? { state: out.state } : {}) });
1666});
1667
1668export default router;
Note: See TracBrowser for help on using the repository browser.