source: Klonkt/src/routes/activitypub.js@ 39a9d21

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

Onze tracks zijn eersterangs Audio-objecten (shaer-0nh, stap 3)

De spiegel van wat sinds bdcb3a3 inkomend werkt. Onze muziek zat alleen als
naamloze bijlage in een Note: iets dat bestaat zolang je het omhullende object
vasthoudt. Nu is een track een DING met een eigen id, los op te halen, en in
twee playlists hetzelfde ding.

DE ACTOR-COLLECTIE IS DE KANONIEKE PLEK, niet de playlist (Robins vraag). Een
playlist is een keuze uit wat de artiest heeft uitgebracht; de collectie is dat
geheel. Een track die in geen enkele playlist stond was tot nu toe onzichtbaar
voor de federatie -- die staat er nu wel in.

GET /ap/users/:slug/tracks alles wat opengezet is
GET /ap/users/:slug/tracks/:id een track, met eigen @context
streams tracks eerst, dan playlists

url wordt een Link-array: de mediaType hoort bij de link, niet bij het
object. Zo doet Funkwhale het, en zo leest onze eigen inbox het sinds bdcb3a3 --
een andere Klonkt kan onze tracks dus als track opnemen in plaats van als
bijlage.

GEEN text/html-link erin. Klonkt heeft geen trackpagina: een track wordt getoond
binnen een post, en een post over vijf nummers is niet de pagina van dit ene
nummer. Liever geen link dan een link die iets anders belooft.

De poortregel loopt door: een gesloten track geeft 404, dezelfde "afwezig, niet
leeg" als in de collectie, zodat het bestaan van een gated nummer niet uit een
ander antwoord valt af te leiden.

ONDERWEG OPGERUIMD. De mediaType-afleiding stond twee keer functie-lokaal, met
een commentaar dat ze "dezelfde afleiding" waren. Dat was niet zo: de ene kende
video, de andere alleen beeld. Nu een kaart, op moduleniveau, met de volledige
map -- gedragsgelijk voor buildNote en een superset voor de playlist.

7 tests.

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

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