source: Klonkt/src/routes/activitypub.js@ 8a474ab

main
Last change on this file since 8a474ab was 8a474ab, checked in by Robin <roboburr@…>, 7 hours ago

Poorten per soort: ook bij het versturen, en film heeft er nu een

Twee gaten die naast elkaar stonden en elkaar versterkten.

gate_images en gate_music golden alleen bij het SERVEREN (shaer-qc9o). Wat een
ward niet te zien kreeg mocht hij wel plaatsen, dus het stond bij iedereen
behalve bij hemzelf. Dat is geen poort meer maar een filter op zijn eigen
scherm, en dat is niet wat een guardian dichtzet. De innamekant weigert nu met
gated_images / gated_music / gated_video, in dezelfde vorm als compose en
messages.

Weigeren en niet wegknippen: stilletjes de bijlage verwijderen publiceert een
bericht dat het kind niet geschreven heeft.

En film had helemaal geen poort (shaer-mxh2), dus de zwaarste soort van de drie
was de enige die altijd door mocht. Nu een gewone rij in de catalogus met een
eigen kolom, aan beide kanten afgedwongen: gateAttachments knipt video weg als
hij dicht staat (ook als hij als AS2-Video zonder mediaType binnenkomt) en de
outbox neemt hem niet aan. shaer:video staat in de caps, zodat de app het
vooraf weet in plaats van het bij de eerste weigering te ontdekken.

De reddingsboei gaat door alle drie heen, zoals door elke andere dichte deur.
Een hulpvraag draagt juist vaak een schermafdruk.

uploadMedia zelf blijft open, en dat is met opzet: de boei gebruikt dezelfde
poot en de server weet daar nog niet waar een upload voor bedoeld is. Een
bijlage die blijft liggen kost een bestand; een boei die vastloopt kost meer.
De weigering valt bij het innemen van de post, en daar is het wel bekend.

Getoetst in test/gates-family.test.js, naast de poorten die er al stonden.
Tegenproef: met de video-regel en de innamelus eruit vallen precies de twee
nieuwe toetsen om.

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