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

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

Je berichten verhuizen mee: FEP-1580 plus een webinterface ervoor

FEP-7628 verhuist je volgers en zegt zelf dat de inhoud een ander probleem is.
Dat probleem stond open: na een Move bleven je berichten op de oude instantie
staan, en elke reactie van een derde wees naar een URI die verdwijnt zodra dat
domein opgezegd wordt. FEP-1580 regelt dat, status DRAFT.

DE AUTORISATIE IS DE MOVE, NIET EEN CODE. De bronkant behandelt een ondertekend
verzoek namens de doel-actor alsof de bron-actor het zelf deed. Dat mag omdat
moveAccount() no_backreference weigert: moved_to staat er alleen als de
doel-actor ons al in alsoKnownAs had. Beide kanten hebben ooit ja gezegd, dus er
is geen tweede vertrouwensmechanisme nodig. Een typefout komt hier niet binnen,
want die haalt de move zelf niet. Dat dit veilig is leunt op de keyId-binding
uit shaer-xd8i: zonder die controle is "wie tekende dit" te zacht om je hele
geschiedenis aan af te geven.

NIEUWE IDS ZIJN GEEN BUG, DE VERTAALTABEL IS HET ANTWOORD. Een verhuisd bericht
krijgt een eigen URI, want het staat op een ander domein. De migration-collectie
mapt oud naar nieuw en derden lezen die om hun eigen verwijzingen bij te werken.
Zonder die collectie is de draad kapot, met die collectie is het een
verhuisbericht. Niet-publieke items staan er alleen in voor wie ze mocht zien:
een lijst met de URIs van je fan-only posts is een lek, ook zonder de inhoud.

Er gaat geen Create de deur uit. Je volgers hebben die berichten jaren geleden
al gezien; driehonderd posts die als nieuw de tijdlijn in klateren is geen
verhuizing maar spam.

Daarnaast /admin/migrate: exporteren, importeren en ophalen via de
webinterface, zodat verhuizen geen SSH-toegang meer vraagt. Importeren gaat
altijd eerst droog, met een verslag en pas daarna een knop die het echt doet.

Getest op twee draaiende instanties, A verhuisd naar B via de echte
moveAccount. Anoniem zag A 3 van de 4 berichten; ondertekend als de doel-actor
kwamen alle 4 mee, inclusief de fan-only. Titel, webadres en publicatiedatum
blijven staan. Media komt echt over: gedownload, in de mediatabel, B serveert
het. Migration-collectie 4 rijen totaal, 3 publiek.

Changed files:
src/services/ActivityPubService.js

  • isMoveTarget: het hele autorisatiepredicaat van de bronkant
  • outboxAudience en mayReadNote: de doel-actor krijgt onze eigen kijkrechten
  • buildActor adverteert migration en moves, ook leeg (de FEP wijst er apart op dat "niets verhuisd" anders niet te onderscheiden is van "kent dit niet")
  • signedGetJson geexporteerd, de ingest heeft hem nodig
  • isMoveTarget en signedGetJson in de default export (movedLock verstopte zich een dag eerder precies zo)

src/services/ap-core.js

  • FEP-1580-termen in de JSON-LD-context

src/services/ArchiveImportService.js

  • een import uit een zip vult dezelfde vertaaltabel; de spec wil dat een geexporteerde collectie identiek behandeld wordt

src/routes/activitypub.js

  • /ap/users/:slug/migration en /moves
  • de blocked-collectie gaat open voor de doel-actor, want zichtbaarheidsvoorkeuren moeten meeverhuizen

src/config/database.js

  • ap_migration en ap_moves, plus sites.migration_complete

src/server.js

  • /admin/migrate aangesloten

src/views/pages/admin.ejs

  • knop naar Migreren

src/services/i18n.js

  • mig.* in nl/en/de

New file:
src/services/MigrationService.js

  • de doelkant: ingest-routine, migration- en moves-collectie, statusvlag

src/routes/admin-migrate.js

  • exporteren, droog importeren, echt importeren, ophalen bij de oude Klonkt

src/views/pages/admin-migrate.ejs

  • de pagina

test/fep1580-migration.test.js

  • 22 tests over beide rollen, plus de regressietest bij 4101c89

remarks: FEP-8b32 ontbreekt volledig (shaer-j1v0), dus er staat geen
handtekening onder de moves-collectie en we zijn niet naleveringsklaar. Bewust
geen leeg proof-veld: een derde die het controleert wordt dan misleid. De DERDE
rol zit er ook niet in, Klonkt leest nog geen migration-collecties van anderen,
dus andermans verhuizing repareert onze verwijzingen nog niet. Alle betrokken
FEPs zijn DRAFT, ook 7628 die we al volgden; 1580 is vers en de auteur schrijft
zelf dat het een audit verdient.

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

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