source: Klonkt/src/routes/activitypub.js@ af2cc73

main
Last change on this file since af2cc73 was af2cc73, checked in by Bart <bart@…>, 3 weeks ago

Gastlogin via OpenWebAuth: een fan is een volger, geen accounthouder

fan_only betekende altijd al "mijn volgers op de fediverse", maar de poort vroeg
om een KLONKT-ACCOUNT. Dat is de verkeerde vraag, en hij sloot precies de mensen
buiten voor wie de poort openstond. Nu kan een bezoeker bij zijn EIGEN server
bewijzen dat hij @iemand@ergens is (FEP-61cf), en volgt hij deze site, dan is
hij binnen. Geen account hier, geen wachtwoord hier, geen cookie van een derde.

Wij zijn alleen de TARGET instance. Dat is de prettige helft: de home instance
heeft prive-sleutels nodig, wij alleen publieke. Er staat hier dus geen geheim
van iemand anders. De /magic-kant (Klonkt-gebruikers laten inloggen OP andere
sites) is bewust niet gebouwd -- andere functie.

De handtekening-verificatie is NIET opnieuw geschreven: AP.verifyRequest() doet
dit al voor de inbox, inclusief het vastpinnen van de sleutel op de herkomst van
de actor, een replay-venster en een verplichte digest. Een tweede implementatie
van "is deze aanvraag echt van wie hij zegt" is precies wat je niet wilt.

De drie aanvallen die de FEP noemt, hebben elk een toets:

  • IMPERSONATIE: ?zid= bepaalt niets, alleen het ingewisselde ?owt= telt. Mallory kan een link maken met zid=bob, maar komt terug met een token dat Mallory zegt.
  • OPEN REDIRECT: het ontdekte endpoint moet dezelfde host hebben als het adres dat de bezoeker intypte.
  • DoS: tokens vervallen in minuten, gaan na een keer gebruiken weg, en elke uitgifte veegt de oude op.

Onderweg gemeten en vastgelegd: PKCS#1 v1.5 GOOIT GEEN FOUT bij een verkeerde
sleutel. OpenSSL 3 doet aan implicit rejection en geeft afgeleide onzin terug,
juist zodat niemand aan het foutgedrag kan aflezen of zijn gok klopte. 200
vreemde sleutels: 0 fouten, 0 keer het token. De toets test dus "er komt iets
anders uit", niet "het knalt" -- anders schrijft de volgende lezer weer een
assert.throws die per ongeluk slaagt.

Webfinger op de eigen wortel wijst een home instance naar /owa/token. Alleen
origin + '/'; een ACTOR-uri met een pad blijft een 400, want dat legt
webfinger-bare-host.test.js vast en die keuze draai ik niet om als bijvangst.

De fanpoort toont nu het adresveld als hoofdweg en de lokale inlog als tweede,
en fgate.sub zegt niet langer "ingelogde vrienden" maar wat de poort werkelijk
vraagt.

1135 toetsen groen (was 1106). End-to-end nagelopen op een KOPIE van de
database: token inwisselen zet de sessie en haalt het token uit de URL, een
bewezen volger krijgt de tekst, een bewezen niet-volger krijgt de poort en geen
byte van de inhoud, en anoniem idem.

Co-Authored-By: Claude Opus 5 <claude@…>

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