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

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

Een Library-skelet, want zonder bak blijft een track een naam zonder geluid

GEMETEN, en dat gaf dit zijn richting. open.audio heeft onze vier tracks
binnengehaald langs de AP-weg -- met ONZE track-id's als fid, en met een
artist_credit dat Funkwhale zelf uit onze attributedTo afleidde. Precies het
object waarvan ons gevraagd werd het te sturen; dat hoefde dus niet, hij leidt
het af. Maar uploads is leeg en is_playable false.

Het audiobestand is niet het probleem: /audio/stream geeft 200, audio/mpeg,
2722880 bytes, ook anoniem en met een vreemde User-Agent. Ze hebben het niet
opgehaald. Bij Funkwhale hangt een upload aan een LIBRARY, en die hadden we niet.

DE VORM IS DIE UIT HUN DOCS: type Library, id, name, followers, totalItems,
first, last, plus attributedTo en summary. Onze pagedCollection deed het meeste
al; wat erbij komt is de naam, de volgers en het type.

DE ENIGE TERM DIE WE UIT HUN VOCABULAIRE OVERNEMEN, en het verschil met
track/ArtistCredit is de reden dat dit wel mag: die twee vragen ENTITEITEN waar
wij tekst hebben -- een artiest en een album zijn bij ons kolommen, en er een id
voor verzinnen zou beloven wat we niet kunnen waarmaken. Een library is precies
wat er al staat: onze open tracks, met een echte telling en een echt id. Er valt
niets te verzinnen.

SKELET, letterlijk. Er is GEEN volg-afhandeling. Onze bibliotheek is openbaar --
alles erin heeft fedi_open -- dus er valt niets goed te keuren en de
volgerslijst is leeg en eerlijk. Komt er ooit een besloten variant, dan hoort
daar het Follow/Accept-werk bij en dat is een eigen stuk.

Een test bewaakt dat een GESLOTEN track er niet in komt. De bak is openbaar, en
dat kan alleen als er niets in zit dat het niet is -- anders overrulet de
openbaarheid van de bak de poort van de track.

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

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