source: Klonkt/src/routes/activitypub.js@ 7e53594

main
Last change on this file since 7e53594 was 7e53594, checked in by Bart <bart@…>, 4 weeks ago

shaer:log: het logboek staat naast de wachtrijen, niet erin

De apps konden er niet bij. Het logboek zat achter /guardian/api/events, een
PWA-route, dus een ward kon niet lezen waarom haar eigen aanbod geweigerd was --
en dat is precies de helft die §4.2 eist.

Eigen sleutel op de actor, GET /ap/users/:slug/log, dezelfde eigenaar-only
bearer als de wachtrijen. Bewust niet onder shaer:queues: alles daar wacht op
een antwoord en dit is wat er al besloten is. Geschiedenis onderbrengen bij een
woord dat "wachtend" betekent maakt van twee dingen één, en dat is de fout die
deze module vandaag drie keer heeft opgeruimd.

type is 'shaer:Event' en geen AS2-werkwoord. De meeste soorten zijn er geen: een
lapse-stem of een opgepakte hulpvraag is geen Accept, en het zo noemen zou
netter lezen dan het is.

Clients vinden hem via het actor-document, niet via een pad dat ze zelf
verzinnen (shaer-qa0). De AS2-test viel netjes om tot shaer:log was aangemeld --
tweede keer vandaag dat die het vangt.

De daemon volgt (shaer-6d9): zolang die geen shaer:log serveert, liegt een
client die tegen beide praat over een van de twee.

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

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