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

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

Stilte hoort niets te kosten: 304 op de guardian-wachtrijen (Barts punt, 9-8)

Bart wees op wat er al bestond: de inbox stuurt een 304 als er niets veranderd
is (since + wait), en de guardian-wachtrijen deden dat niet. Die stuurden bij
elke verversing de hele lijst terug -- bij honderd wards veertienhonderd
objecten. Over de lijn valt dat mee (2,8 KB gzip), maar het OPBOUWEN en parsen is
wat een telefoon merkt, en dat is precies de oude data die je elke keer weer
terugkrijgt.

En zijn punt over de waarschuwing klopte ook: de app ververst al voor de
guardian, tachtig keer per uur. Iemand waarschuwen voor een gewoonte die de app
zelf heeft is de verkeerde kant op redeneren.

EEN INHOUDS-ETAG, GEEN CURSOR. Een cursor vraagt een tweede beschrijving van
wanneer iets "veranderd" is, en die kan uit de pas lopen met wat er werkelijk in
het antwoord staat; een hash van het antwoord zelf kan dat per definitie niet.
De server bouwt het antwoord nog steeds -- wat we besparen is de overdracht en
het parsen.

NOOIT 304 OP EEN LEEG ANTWOORD, dezelfde les als de '0'-uitzondering bij de
inbox: gaat er bij het opbouwen iets mis en komt er een lege lijst uit, dan is
die hash ook stabiel en kijkt een client voor eeuwig naar niets. Daar staat een
toets op, en de mutatie maakt hem rood.

no-cache betekent niet "niet bewaren" maar "bewaar en vraag na" -- zonder dat
stuurt een browser geen If-None-Match en is de ETag decoratie.

Vijf toetsen, twee mutaties gecontroleerd. Suite 761/761.

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