source: Klonkt/src/routes/activitypub.js@ 21ef238

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

Ongelezen per gesprek: een COUNT, en Read als gebeurtenis

shaer-frontend-3tx. De kopjeslezing draagt nu shaer:unread per gesprek, plus
shaer:unreadWave als er een zwaai bij zit -- een zetje van een guardian is geen
gesprek en hoort niet opgeteld te worden.

GEEN BIJGEHOUDEN GETAL (Barts besluit): het aantal is een COUNT over de
berichten na de leesmarkering. Niets om op te hogen bij bezorging, niets om te
verlagen bij lezen, en bij een verwijdering klopt het vanzelf weer -- een
teller zou blijven staan en dan zegt de badge 3 terwijl er niets is. Er staat
een toets op precies dat geval.

READ IS EEN GEBEURTENIS, geen zetbare stand (Barts vraag: een stille client
stuurt toch geen Undo{Read}?). AS2 kent Read; markRead neemt het maximum, dus
een toestel dat een week uit stond kan gelezen berichten niet terugzetten op
ongelezen. Ook daar staat een toets op. Geen shaer:seen verzonnen.

De markering is de samengestelde cursor (stempel|ref), zelfde vorm als de
gesprekspaginering: twee berichten in dezelfde seconde is bij DM's een gesprek
en geen randgeval.

En conversationItems geeft nu PAREN terug in plaats van een losse lijst. Een
kop levert niet altijd een item op (dichte poort, ontbrekende rij), en op index
koppelen zou de telling stil aan het verkeerde gesprek hangen.

862/862 groen.

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

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