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

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

De outbox vertelt de discografie mee (shaer-0nh, stap 4)

Een track verschijnt nu als Create(Audio) in de outbox, door de posts heen op
datum. Daarmee is een Klonkt-actor als kanaal uit te lezen: precies zoals ik
Funkwhales kanaal uitlas -- hun achttien tracks stonden in hun outbox.

WAAROM GEEN BEZORGING NAAR VOLGERS, terwijl ik stap 4 eerder zo beschreef.
Mastodon neemt Audio aan als STATUSTYPE. Publiceer je een post met een album,
dan zouden volgers de Note met spelers krijgen EN N losse Audio-statussen:
dezelfde muziek twee keer in hun tijdlijn. Dat is geen spiegel afmaken maar
andermans tijdlijn vervuilen. Een kanaal-lezer HAALT op; hij hoeft niet
geduwd te worden. De post is het bericht, de outbox is de discografie.

DE DEUR BLIJFT DICHT. buildOutbox krijgt de tracks als ARGUMENT en haalt ze
uitdrukkelijk niet zelf op. De route beslist wie wat ziet, en de blocked-tak
daar levert een outbox zonder posts EN zonder tracks. Een bouwer die stiekem
zijn eigen database bevraagt zou dwars door die dichte deur heen leveren --
dat had ik bijna gebouwd, en de tak in de route ving het pas bij nalezen.

Het id van de activiteit is stabiel (<track>#create), zodat twee keer ophalen
niet twee keer nieuws is.

5 tests.

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

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