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

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

De thread-melding wijst nu naar de juiste partij

Bart opende een post van boiert.eu en las "Replies could not be loaded from
your server" terwijl onze server het prima deed: de BRON weigerde. Het was een
friends-only post van een account dat hij 's ochtends nog volgde en nu niet
meer, en Klonkt geeft een niet-publieke note alleen aan followers -- boiert
weigerde dus terecht, met 404, ook op het ondertekende verzoek.

signedGetJson slikte die status ("if (!r.ok) return null"), dus getThread wist
niet WAAROM het misging en de route kon het niet zeggen. Nu geeft hij de status
door aan wie erom vraagt, en getThread draagt hem als sourceStatus.

De route splitst daarop: 401/403/404/410 is een besluit van die server (niet
gedeeld, of weg) en blijft 404 'not shared by source'; al het andere, inclusief
een status die we niet eens kregen, wordt 502 'source unreachable'. Zonder dat
onderscheid vervang je een verkeerde schuldige door een andere gok.

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

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