source: Klonkt/src/routes/activitypub.js@ 09fc5fb

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

Gesprekken: eerst wie, dan pas wat

shaer-frontend-yso, stap 1 van 3: de serverkant NAAST het bestaande pad, want
de apps in het veld lezen de oude inbox-lezing nog.

De oude lezing geeft de nieuwste 60 berichten over ALLE gesprekken samen. Bij
DM's is dat veel erger dan bij posts -- meer en kortere berichten, dus een druk
gesprek eet de 60 in zijn eentje op en duwt de rest eruit. Viel het laatste
bericht van iemand erbuiten, dan verdween die persoon HELEMAAL uit Messages:
de avatarhemel plaatst mensen op de leeftijd van hun laatste bericht, dus geen
bericht is geen gezicht. test/conversations.test.js legt die bug eerst vast.

Nu twee lezingen:

GET /ap/users/:slug/conversations een rij per tegenpartij, compleet van

vorm -- het aantal rijen is het aantal
mensen, niet het aantal berichten

GET /ap/users/:slug/messages?with= het gesprek zelf, beide kanten onder EEN

limiet, met next zolang er meer is

Beide kanten onder een limiet, want in de oude lezing werden jouw kant
(getSentNotes) en hun kant apart afgekapt en kon een gesprek eenzijdig lijken.

DE CURSOR IS SAMENGESTELD (stempel|ref) en niet alleen de stempel. Twee
berichten in dezelfde seconde is bij DM's een gesprek en geen randgeval; met
'stamp < before' viel alles wat die grensseconde deelde stil weg. De testen
vonden dat, en tellen nu dat de drie paginas samen precies 80 berichten zijn.
De paginagrootte reist mee in next, anders wordt pagina twee stilletjes de
standaard.

Om te voorkomen dat de kaartvorm twee keer beschreven staat: de poorten
(leesPoorten) en de vorm (berichtItem/verzondenItem) zijn uit de inbox-lezing
gehesen en worden nu door allebei gebruikt. Zelfde gedrag, 843/843 groen.

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

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