source: Klonkt/src/routes/activitypub.js@ 8455dac

main
Last change on this file since 8455dac was 8455dac, checked in by Robin <roboburr@…>, 4 weeks ago

De outbox bladert nu echt door (shaer-sk4)

Wat er stond was geen paginering maar een KAP: de route haalde twintig posts uit
SQL en hield daar twintig items van over. Alles daarvoor lag niet op een volgende
pagina maar was helemaal onbereikbaar -- ook voor een lezer die netjes next
volgt.

DE MOEILIJKHEID, en waarom dit niet met een tweede slice kon: de outbox mengt
twee bronnen, posts en open tracks, gevlochten op datum. Met twee losse queries
en een offset per tabel weet je niet hoeveel van elk er in pagina drie horen.
outboxSlice doet daarom een UNION met de datum als sleutel, zet de LIMIT/OFFSET
daaroverheen, en haalt pas dan de rijen zelf op -- twee kleine queries voor de
ids van die ene pagina.

De fans-only voorwaarde zit in de UNION, dus de telling klopt per lezer: een
vreemde ziet 50 en een vriend 51, en niet allebei hetzelfde getal met een andere
lijst eronder.

pagedCollection kent nu alGesneden. Zonder dat zou een volle pagina zichzelf
als de enige zien -- hij telt het aantal pagina's uit de LIJST, en die is bij
SQL-paginering precies een pagina lang. Nu telt hij uit totalItems en biedt hij
een next zolang er meer is.

TWEE DINGEN DIE IK BIJNA VERKEERD DEED. outboxSlice en PAGINA_GROOTTE stonden
niet in de DEFAULT export, en de routes importeren AP zo -- dan is het undefined
bij het VERZOEK, niet bij het importeren. Nagelopen op de server: function,
number. En mijn eigen testdata maakte "2026-01-45" voor de 45e post; dat is geen
datum, en toISOString viel er terecht over om.

Zes tests, waaronder de kern: opeenvolgende pagina's overlappen niet en laten
niets vallen -- drie pagina's dekken alle 50, en niets twee keer.

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

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