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

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

Paginering die echt pagineert (shaer-sk4)

Robin zag dat elke ?page= dezelfde inhoud gaf. Dat klopte, en het was geen halve
implementatie maar een omhulsel: pagedCollection veranderde alleen de VORM en
sneed nooit. first en last wezen allebei naar ?page=1, en de routes lazen
!!req.query.page -- of de parameter er STAAT, niet welke. Live gaf ?page=2 en
?page=99 dezelfde acht items, en noemden zichzelf pagina 1.

Nu: echt snijden, met next en prev, en een pagina die zijn eigen nummer
draagt. Een pagina voorbij het einde is LEEG en zegt dat ook -- hem naar de
laatste terugbuigen zou opnieuw een antwoord zijn dat over zichzelf liegt.

DE WORTEL HOUDT ZIJN ITEMS INLINE, en dat is geen slordigheid maar de reden dat
dit veilig is. Shaer leest een document en volgt next niet (KlonktClient.swift
orderedItems). Werd de wortel nu leeg, dan kreeg elke draaiende app nul items en
geen foutmelding -- dezelfde stille val waar ik op 10 augustus bij Funkwhale zelf
in trapte. Eerst de clients leren pagineren, dan pas de wortel afslanken.

WAT WEL EN NIET GEPAGINEERD IS. Volgers en following pagineren nu volledig: die
lijsten zitten al in het geheugen, dus dat kost niets extra. De outbox krijgt de
juiste VORM maar niet meer diepte: de route kapt al op twintig rijen in SQL.
Echt doorbladeren vraagt daar een LIMIT/OFFSET, en dat is lastiger dan bij
volgers omdat posts en tracks op datum door elkaar gevlochten worden en uit twee
tabellen komen -- een UNION met een offset erover, geen tweede slice. Dat staat
als naad in de code en op de bead, niet weggemoffeld.

Zeven tests, waaronder de klacht zelf: pagina 2 geeft ANDERE items dan pagina 1.

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

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