source: Klonkt/src/routes/activitypub.js@ 960f015

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

Geen halve namen meer, ook niet als de basis kapot is (Robins vraag, 11-8)

Twee plekken vielen in hun catch terug op @${slug} -- een handle zonder host.
Dat gaat alleen af bij een onparseerbare PUBLIC_BASE_URL, dus bij een kapotte
installatie. Robins punt: dat maakt het niet minder fout. Zonder host zegt @dev
niets op een oppervlak waar iedereen @dev kan heten.

Allebei nu via deriveHandle op de actor-URI. Diens terugval is de KALE URI, en
die is eerlijk: hij ziet er niet uit als een naam die compleet is. En als de
basis onparseerbaar is klopt elke URI die we bouwen toch al niet.

Daarmee is deriveHandle de enige plek waar een handle ontstaat. Vandaag stonden
er vijf spellingen: null in het paneel, @slug zonder host in de app, twee
catch-terugvallen, en de echte vorm op de inkomende weg.

EN EEN VOETANGEL DIE IK ONDERWEG BIJNA INSLIKTE: de routes importeren
ActivityPubService als DEFAULT. deriveHandle en localSlugOf waren wel als named
export beschikbaar maar stonden niet in dat default-object -- AP.deriveHandle
was dus undefined, en dat valt niet om bij het importeren maar pas bij een
VERZOEK. Allebei toegevoegd en op de server nagelopen: function, function, en
@dev@….

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

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