source: Klonkt/src/routes/activitypub.js@ 6e9bc70

main
Last change on this file since 6e9bc70 was 6e9bc70, checked in by Bart <bart@…>, 2 days ago

Zachte groepen: een gesprek is de set deelnemers, niet één tegenpartij

Een direct bericht aan Anna en Ben bestond al als groep -- to_actors droeg de
hele set -- maar elke lezing sleutelde op één other. Een uitgaand bericht
aan twee mensen viel in twee gesprekken uiteen, en een binnenkomend bericht
van Anna aan jou en Ben kwam alleen bij Anna terecht: Ben viel eruit, want de
adressering van een binnenkomer werd nergens bewaard.

Nu is er een sleutel, één keer berekend bij het schrijven
(conversation-key.js): voor één ander zijn kale URI, precies zoals het altijd
was, dus bestaande gesprekken, leesmarkeringen en ?with=-URL's blijven geldig
zonder migratie; voor een groep de gesorteerde set, gescheiden door een
spatie. Koppen, geschiedenis, leesmarkeringen en ongelezen lezen hem allemaal
via dezelfde unie, en werken daarmee per gesprek zonder dat er in die queries
iets hoefde te veranderen. Oude rijen hebben geen sleutel en lezen zoals ze
altijd lazen.

Alleen directe berichten groeperen: een openbare post aan jou en Ben is geen
besloten gesprek met z'n drieën.

/messages neemt meer dan één with, en elk item draagt shaer:conversation en
shaer:participants. Uitgaande directe notes krijgen een FEP-7888 context --
die van de ouder als het een antwoord is, anders een nieuwe -- die bewaard en
uitgestuurd wordt, maar geen bubbels samenvoegt: een bubbel die Carla toont
terwijl je antwoord haar niet bereikt, zou liegen over wie meeleest.

shaer-mbji
Co-Authored-By: Claude Opus 5.5 <claude@…>

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