source: Klonkt/src/routes/activitypub.js@ 0db3c72

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

Standaardvormen naast het dialect: AS2 en FEP waar die bestaan

shaer-nmw. De client-legs spraken Klonkt-dialect waar de standaard het al
regelt. Nu dragen ze de standaardvorm ERNAAST:

  • attributedTo als ingesloten Person (naam, preferredUsername, icon, url, FEP-9098 emoji in tag) in plaats van alleen shaer:author;
  • een boost is een echte Announce met zijn actor, niet shaer:booster;
  • FEP-044f quote als de geciteerde Note zelf, met eigen attributedTo;
  • AS2 preview (Page met url, name, image) voor de linkkaart.

Weten we niets van de persoon, dan blijft attributedTo de kale URI: een leeg
object zou beweren dat we hem kennen.

De shaer:-velden blijven voorlopig staan. Ze weghalen is de tweede helft van
de bead en kan pas als de apps in het veld zijn bijgewerkt.

De toets is niet "onze app snapt het" maar of een GENERIEKE AP-lezer er iets
aan heeft; test/standard-shapes.test.js kijkt daarom naar geen enkel
shaer:-veld.

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

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