source: Klonkt/src/routes/activitypub.js@ 86e6a45

main
Last change on this file since 86e6a45 was 86e6a45, checked in by roboburr <roboburr@…>, 5 weeks ago

De apps krijgen de hulpstaat, en kunnen afhandelen (shaer-lgo)

Barts melding: afgehandelde hulpverzoeken blijven zichtbaar in de Shaer
GuardianshipView, en kan het afhandelen daar ook?

DIE TWEE ZIJN HETZELFDE PROBLEEM. De apps lazen hulpvragen uit de FEED -- losse
notes met een helpRequest-vlag -- en kregen de staat helemaal niet. Ze konden dus
niet weten of er al iemand op af was, en dan is een afgehandeld verzoek laten
staan nog het eerlijkste dat een app kan doen. Klonkt bewaarde de staat wel; hij
reisde alleen nergens heen.

Nu een queue help op de actor, met de staat PLAT erin: open, wie hem oppakte,
wie hem afsloot en wanneer. Een app hoeft hem niet af te leiden en kan hem dus
ook niet anders afleiden dan het paneel -- helpItemsFor is een plek, net als
wardGates. Twee berekeningen zouden twee guardians een ander beeld geven van
hetzelfde kind, en bij een reddingsboei is dat het gevaarlijkste dat er mis kan
gaan.

AFHANDELEN HOEFDE GEEN NIEUWE VORM. De markering IS al een gewone directe note
met shaer:helpPickup of shaer:helpHandled, precies zoals de zwaai. De outbox
herkent hem nu, dus de app stuurt letterlijk wat de PWA stuurt en het reist over
dezelfde bezorging naar de mede-guardians. Geen tweede weg.

Wel LOKAAL boeken, en daar staat een toets op: zonder dat zag de guardian die de
knop indrukt zijn eigen markering pas als hij bij zichzelf terugkwam, en die weg
bestaat niet.

Oppikken blijft OPEN. De faalstand hier is "iedereen denkt dat het geregeld is",
en die is gevaarlijker dan geen markering.

De AS2-toets ving dat help nog niet gedeclareerd stond op de actor -- precies
waarvoor die toets er is. Suite 718/718; zonder de lokale boeking valt er een om.

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