source: Klonkt/src/routes/activitypub.js@ 4f322bc

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

Wachten op nieuws, als uitbreiding van de inbox (shaer-n05)

Geen tweede endpoint. Geef since (de shaer:cursor uit je vorige antwoord) en
wait mee aan de bestaande inbox-lezing, en het antwoord blijft hangen tot er
iets is of de tijd om is. Zonder die twee gedraagt de route zich exact zoals
altijd.

Bewust hetzelfde antwoord in plaats van een 'er is nieuws'-seintje: dan hoeft de
client niets nieuws te parsen, is er geen tweede beschrijving van de kaartvorm
die uit de pas kan lopen met de eerste, en scheelt het een tweede ronde.

De merksteen telt ALLE VIER de poten die de inbox samenvoegt: tijdlijn,
berichten, antwoorden op je eigen posts, en wat je zelf verstuurde. Ontbreekt er
een, dan blijft een wachtende client slapen terwijl er wel degelijk nieuws is --
erger dan niet wachten, want het lijkt te werken. rowid en geen tijdstempel: die
loopt strikt op, terwijl twee dingen in dezelfde seconde kunnen aankomen en een
published van een andere server niet te vertrouwen is.

Wachten gebeurt met een interne tik en niet met een gebeurtenis-emitter. Een
emitter moet op ELKE plek worden aangeroepen waar er iets bijkomt, en de plek die
je vergeet is precies de melding die nooit aankomt. Vier MAX(rowid)-queries per
seconde is niets, en dit kan niets missen. Prijs: hooguit een tik vertraging.

Grenzen: hooguit 50 seconden wachten (ruim onder wat een proxy toestaat), vier
gelijktijdige wachters per account, en ophangen breekt het wachten af. Een client
met een kapotte herverbind-lus krijgt gewoon de huidige stand in plaats van een
fout.

9 tests, waaronder de vier poten apart en de vraag of hij wakker wordt in plaats
van de tijd vol te maken. Suite 537/537.

  • Property mode set to 100644
File size: 40.7 KB
Line 
1/**
2 * ActivityPub — public endpoints (Phase 1: discover + fetch).
3 *
4 * GET /.well-known/webfinger?resource=acct:<slug>@<host>
5 * GET /ap/users/:slug actor (content-negotiated: AP-JSON vs redirect to HTML profile)
6 * GET /ap/users/:slug/outbox OrderedCollection of Create(Note)
7 * GET /ap/users/:slug/followers count-only OrderedCollection
8 * GET /ap/users/:slug/featured pinned posts (Mastodon "Featured" tab)
9 * GET /ap/notes/:id a single Note
10 * POST /ap/users/:slug/inbox, /ap/inbox → 202 (Follow/Accept + signature verify: next step)
11 *
12 * Mounted before resolveSite; resolves the site by slug itself.
13 */
14import express from 'express';
15import { readFileSync } from 'fs';
16import db from '../config/database.js';
17import AP from '../services/ActivityPubService.js';
18import { apReadLimiter, apInboxLimiter } from '../middleware/rate-limit.js';
19import { apEnabled } from '../services/SettingsService.js';
20import OAuth from '../services/OAuthService.js';
21import * as Guardianship from '../services/guardianship/index.js';
22import { getPrimarySite } from '../middleware/site.js';
23import multer from 'multer';
24import path from 'path';
25import fs from 'fs';
26import { randomUUID } from 'crypto';
27import { mediaDir } from '../config/paths.js';
28
29const router = express.Router();
30// The whole fediverse layer can be turned off (solo "no federation" mode):
31// then /ap/*, WebFinger and NodeInfo are simply gone — the site is undiscoverable
32// and unfederatable. CRITICAL: this router is mounted at root (app.use(apRoutes)), so a
33// blanket res.status(404) here ran for EVERY request and 404'd the whole site when AP was
34// off. Use next('router') to SKIP this router entirely and let the normal routes handle it
35// (the /ap/* paths then fall through to the app's normal 404, which is correct).
36router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
37// Generous per-IP baseline over all /ap/* (reads). The inbox POST gets an
38// additional, tighter cap inline (it triggers outbound fetches).
39router.use(apReadLimiter);
40let _ver = '1.0.0';
41try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
42
43const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
44const hostOf = (req) => { try { return new URL(baseUrl(req)).host; } catch { return req.get('host'); } };
45const publicSite = (slug) => db.prepare('SELECT * FROM sites WHERE slug = ? AND (is_public IS NULL OR is_public = 1)').get(slug);
46// The primary site, via the one source of truth in middleware/site.js — which
47// falls back to the oldest site when nothing carries the is_primary flag. This
48// route used to keep its own is_primary-only copy, so a fresh instance whose
49// site was never flagged served its HTML at / (that resolver falls back) while
50// WebFinger and the actor route insisted it had no primary at all.
51const primarySlug = () => { const s = getPrimarySite(); return s && s.slug; };
52// A hostname as a human types it and as DNS stores it are the same host:
53// `🩵.is.wildenvrij.nl` IS `xn--zz9h.is.wildenvrij.nl`. WHATWG URL does the IDNA,
54// so compare the ASCII form and never the bytes the client happened to send.
55const asciiHost = (h) => {
56 try { return new URL(`https://${h}`).host.toLowerCase(); } catch { return String(h).trim().toLowerCase(); }
57};
58
59// ── WebFinger ─────────────────────────────────────────────────────
60router.get('/.well-known/webfinger', (req, res) => {
61 const m = String(req.query.resource || '').match(/^acct:([^@]+)@(.+)$/i);
62 if (!m) return res.status(400).type('text/plain').send('bad resource');
63 const user = m[1];
64 let site = publicSite(user);
65 // `acct:<host>@<host>` asks for this server's primary actor — the convention
66 // Shaer's Handle relies on so a Ward is reachable without knowing anyone's
67 // slug. Typing `🩵.is.wildenvrij.nl`, pasting `https://🩵.is.wildenvrij.nl`
68 // (which the client's URL parser silently punycodes) and sending the xn--
69 // form by hand are three spellings of one address; all arrive here with the
70 // host sitting in the user position, and all must find the same actor.
71 if (!site && asciiHost(user) === asciiHost(hostOf(req))) {
72 const slug = primarySlug();
73 if (slug) site = publicSite(slug);
74 }
75 if (!site) return res.status(404).end();
76 res.type('application/jrd+json; charset=utf-8');
77 res.set('Cache-Control', 'public, max-age=300');
78 const actorUri = AP.actorId(baseUrl(req), site.slug);
79 const profileUrl = baseUrl(req) + (site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`);
80 res.send(JSON.stringify({
81 subject: `acct:${site.slug}@${hostOf(req)}`,
82 aliases: [actorUri, profileUrl],
83 links: [
84 { rel: 'self', type: 'application/activity+json', href: actorUri },
85 { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl },
86 ],
87 }));
88});
89
90// ── Actor ─────────────────────────────────────────────────────────
91router.get('/ap/users/:slug', (req, res) => {
92 const site = publicSite(req.params.slug);
93 if (!site) return res.status(404).end();
94 if (!AP.apWants(req)) {
95 // A browser hit the AP actor URL → send them to the human profile.
96 const human = site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`;
97 return res.redirect(302, baseUrl(req) + human);
98 }
99 site.primary_slug = primarySlug();
100 AP.sendAP(res, AP.buildActor(baseUrl(req), site));
101});
102
103// ── Outbox ────────────────────────────────────────────────────────
104router.get('/ap/users/:slug/outbox', async (req, res) => {
105 const site = publicSite(req.params.slug);
106 if (!site) return res.status(404).end();
107 // Authorized fetch (30-7): who is asking decides what they see.
108 // - the owner's own app (bearer) and a verified accepted follower or
109 // guardian get the friends-only history too, so a NEW friend's backfill
110 // brings the past along (Robins besluit: vrienden krijgen de
111 // geschiedenis mee);
112 // - a verified caller this instance BLOCKS gets an EMPTY collection, not
113 // even the public set: a block is a closed door, and a signed fetch is
114 // the caller knocking with their name on it;
115 // - everyone else gets the public collection, exactly as before.
116 const bearer = OAuth.verifyBearer(req.headers.authorization);
117 let verifiedActor = null;
118 if (!bearer && req.headers['signature']) {
119 const verified = await AP.verifyRequest(req).catch(() => null);
120 verifiedActor = verified && verified.id;
121 }
122 const audience = AP.outboxAudience(req.params.slug, {
123 bearerSlug: bearer ? bearer.site.slug : null,
124 verifiedActor,
125 });
126 if (audience === 'blocked') {
127 return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, []), 'private, no-store');
128 }
129 const fanClause = audience === 'friend' ? '' : "AND (fan_only IS NULL OR fan_only = 0)";
130 const posts = db.prepare(
131 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
132 FROM posts WHERE site_id = ? AND status = 'published' ${fanClause}
133 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
134 ).all(site.id);
135 const ob = AP.buildOutbox(baseUrl(req), site, posts);
136 if (audience === 'friend') {
137 // The owner's app builds its feed from this leg, and every note here is
138 // by the site itself: give it the same `shaer:author` byline the timeline
139 // entries carry, so your own cards get a header too (avatar + name).
140 const me = AP.selfAuthor(baseUrl(req), site);
141 for (const it of ob.orderedItems) {
142 if (it && it.object && typeof it.object === 'object') it.object['shaer:author'] = me;
143 }
144 }
145 AP.sendAP(res, ob, audience === 'friend' ? 'private, no-store' : undefined);
146});
147
148// ── Follow-QR (Robins verzoek, 31-7) ──────────────────────────────
149// The QR carries an HTTPS url, not the share: scheme: camera apps (Google
150// Lens voorop) treat unknown schemes as plain text and only offer to OPEN
151// https links (Robins melding, 31-7). The url lands on the interstitial
152// below, whose one big button fires the share: scheme — from a browser the
153// custom scheme DOES work (BROWSABLE intent-filter; Safari prompts).
154// Public on purpose: it encodes only the public handle, and the app's plain
155// image loaders carry no bearer.
156router.get('/ap/users/:slug/follow-qr.png', async (req, res) => {
157 const site = db.prepare('SELECT slug FROM sites WHERE slug = ?').get(req.params.slug);
158 if (!site) return res.status(404).end();
159 try {
160 const { default: QRCode } = await import('qrcode');
161 const png = await QRCode.toBuffer(`${baseUrl(req)}/ap/users/${encodeURIComponent(site.slug)}/follow`, { width: 600, margin: 1 });
162 res.set('Content-Type', 'image/png');
163 res.set('Cache-Control', 'public, max-age=86400');
164 res.send(png);
165 } catch (e) {
166 console.warn('[AP] follow-qr failed:', e && e.message);
167 res.status(500).end();
168 }
169});
170
171// The interstitial the QR opens: one big button into Shaer, and the handle
172// in plain sight for whoever has no Shaer (yet).
173router.get('/ap/users/:slug/follow', (req, res) => {
174 const site = db.prepare('SELECT slug, title FROM sites WHERE slug = ?').get(req.params.slug);
175 if (!site) return res.status(404).end();
176 const host = new URL(baseUrl(req)).host;
177 const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
178 const handle = `@${site.slug}@${host}`;
179 const name = esc(site.title || site.slug);
180 res.set('Cache-Control', 'public, max-age=3600');
181 res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
182<meta name="viewport" content="width=device-width, initial-scale=1">
183<title>Follow ${name}</title>
184<style>
185 body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
186 background: linear-gradient(160deg, #5A32E6, #2a1a5e); color: #fff; text-align: center; }
187 main { padding: 32px; max-width: 420px; }
188 h1 { font-size: 1.5rem; margin: 0 0 .4rem; }
189 .handle { opacity: .85; font-family: ui-monospace, monospace; word-break: break-all; }
190 a.go { display: block; margin: 28px auto 14px; padding: 16px 28px; border-radius: 999px; background: #fff; color: #2a1a5e;
191 font-weight: 700; font-size: 1.15rem; text-decoration: none; }
192 p.small { font-size: .85rem; opacity: .75; line-height: 1.5; }
193</style></head><body><main>
194 <h1>Follow ${name}</h1>
195 <div class="handle">${esc(handle)}</div>
196 <a class="go" href="share:social/follow/AP/${esc(handle)}">Open in Shaer</a>
197 <p class="small">No Shaer? Any fediverse app can follow ${esc(handle)}.</p>
198</main></body></html>`);
199});
200
201// ── Long-poll (owner only, Robins verzoek 31-7) ───────────────────
202// Hold the request until something push-worthy lands for this account, then
203// answer 200 (news: re-read your feed) or 204 after ~25s (nothing: re-arm).
204// The thread in the app stays live without interval polling.
205router.get('/ap/users/:slug/inbox/wait', (req, res) => {
206 const auth = OAuth.verifyBearer(req.headers.authorization);
207 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
208 let settled = false;
209 const done = (code) => {
210 if (settled) return;
211 settled = true;
212 clearTimeout(timer);
213 off();
214 if (!res.headersSent) res.status(code).end();
215 };
216 const off = AP.onNews(auth.site.slug, () => done(200));
217 const timer = setTimeout(() => done(204), 25_000);
218 req.on('close', () => done(204));
219});
220
221// ── Blocked collection (owner only, AP §5.6) ──────────────────────
222// The server blocklist is the source of truth for Shaer's "in Orbit":
223// clients read it here instead of keeping their own state. Actor-kind
224// blocks only (domain blocks are instance policy, not an Orbit member).
225router.get('/ap/users/:slug/blocked', (req, res) => {
226 const auth = OAuth.verifyBearer(req.headers.authorization);
227 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
228 const base = baseUrl(req);
229 const items = AP.listBlocks(auth.site.slug)
230 .filter((b) => b.kind === 'actor')
231 .map((b) => b.target);
232 AP.sendAP(res, {
233 '@context': AP.AP_CONTEXT,
234 id: `${base}/ap/users/${auth.site.slug}/blocked`,
235 type: 'OrderedCollection',
236 totalItems: items.length,
237 orderedItems: items,
238 });
239});
240
241// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
242// The dashboard collections the Shaer clients read: pending adoption offers,
243// gated follows (empty in Klonkt for now) and the guardian's wards. Same
244// contract as the Shaer test daemon.
245function queueRoute(name, build) {
246 router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
247 const auth = OAuth.verifyBearer(req.headers.authorization);
248 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
249 const base = baseUrl(req);
250 const me = `${base}/ap/users/${auth.site.slug}`;
251 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
252 });
253}
254queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
255queueRoute('follows', (id) => Guardianship.followsCollection(id));
256// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
257// waiting on its guardians. Owner-only like the rest — who a child wants to
258// follow is nobody else's business.
259queueRoute('outgoing-follows', (id, slug, me) => Guardianship.outgoingFollowsCollection(id, slug, me));
260queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
261// Availability (FEP-633c 3.6.1) is never public: the ward reads its
262// guardians' real states here and nowhere else.
263queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
264
265// ── Inbox read (owner only, AP C2S) ───────────────────────────────
266// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
267// scoped to this site) reads recent inbound posts (the timeline: accounts
268// they follow) as Create(Note) items, so an app (Shaer) can build a unified
269// feed. Anyone else gets 403; the inbox stays write-only for the public.
270router.get('/ap/users/:slug/inbox', async (req, res) => {
271 const auth = OAuth.verifyBearer(req.headers.authorization);
272 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
273 const base = baseUrl(req);
274 // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
275 // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
276 // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
277 // gedraagt de route zich exact zoals altijd.
278 //
279 // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
280 // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
281 // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
282 // ronde.
283 const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
284 if (req.query.since && wachtS > 0) {
285 const afbreken = new AbortController();
286 res.on('close', () => afbreken.abort()); // client hing op: niet doorgaan met wachten
287 await AP.waitForFeedChange(auth.site.slug, {
288 since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
289 });
290 if (res.writableEnded || afbreken.signal.aborted) return undefined;
291 }
292 // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
293 // world outside the fediverse is the guardians' call. The gate is applied
294 // here, at serialisation: a blocked embed is never sent, because an embed the
295 // client merely hides has still been delivered to the device.
296 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
297 const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
298 // The heavier sibling (5.6): may a third party's PLAYER run inside the app,
299 // and may a link hand the child over to a browser? Both are the guardians'
300 // call, both default to off for a ward, and both need the preview gate open
301 // first: you cannot play, or follow, what you may not see. Served here so
302 // the app knows what it may offer instead of guessing.
303 const playbackAllowed = embedsAllowed
304 && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
305 const rows = AP.getTimeline(auth.site.slug, 60);
306 // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
307 // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
308 // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
309 const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
310 const posts = rows.map((t) => ({
311 id: `${t.id}#create`,
312 type: 'Create',
313 actor: t.author_uri,
314 published: t.published || t.created_at || undefined,
315 object: {
316 id: t.id,
317 type: 'Note',
318 attributedTo: t.author_uri,
319 content: t.content,
320 url: t.url || undefined,
321 published: t.published || t.created_at || undefined,
322 sensitive: !!t.nsfw,
323 summary: t.cw || undefined,
324 // Friends' media travels along (media_json → AS2 attachment), so the
325 // client renders their images/audio like own outbox posts.
326 attachment: AP.timelineAttachments(t.media_json),
327 // The note's preserved tags, so the client can render them: FEP-9098
328 // Emoji tags (:shortcode: → image) and FEP-e232 Link tags (quotes /
329 // inline object references). Combined into one `tag` array; omitted
330 // when the note has neither.
331 tag: (() => {
332 const tags = [...(AP.timelineEmojis(t.emoji_json) || []), ...(AP.timelineObjectLinks(t.link_json) || [])];
333 return tags.length ? tags : undefined;
334 })(),
335 // FEP-044f: the resolved quoted post (author + content), so the client
336 // renders an embedded quote card instead of a bare link. Omitted when the
337 // note has no quote or the quoted post could not be resolved.
338 'shaer:quote': AP.timelineQuote(t.quote_json),
339 // The post author's display info (name / @handle / avatar), so every card
340 // gets a byline header like the quote card. attributedTo stays the bare
341 // actor URI; this is the resolved presentation Klonkt already stored.
342 'shaer:author': (t.author_name || t.author_handle || t.author_icon) ? {
343 name: t.author_name || undefined, handle: t.author_handle || undefined,
344 icon: t.author_icon || undefined, url: t.author_url || undefined,
345 // FEP-9098: emojis in the display name (":shortcode:"), if any.
346 emojis: (() => { try { return t.author_emoji_json ? JSON.parse(t.author_emoji_json) : undefined; } catch { return undefined; } })(),
347 } : undefined,
348 // When a followed account boosted this, who did ("X boosted"). Omitted for
349 // ordinary posts.
350 'shaer:booster': (t.reblog_name || t.reblog_handle || t.reblog_icon) ? {
351 name: t.reblog_name || undefined, handle: t.reblog_handle || undefined,
352 icon: t.reblog_icon || undefined,
353 // FEP-9098: emojis in the booster's display name (":shortcode:"), if any.
354 emojis: (() => { try { return t.reblog_emoji_json ? JSON.parse(t.reblog_emoji_json) : undefined; } catch { return undefined; } })(),
355 } : undefined,
356 // Whether THIS account already liked/boosted the note, so the app's
357 // detail-view buttons show the current state (and can toggle/undo).
358 'shaer:liked': !!(reacties.get(t.id) || {}).liked,
359 'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
360 // An external (non-fediverse) embed, thumbnail-only and never an iframe.
361 // Omitted entirely when the gate is closed (see above).
362 // Carries shaer:playerUrl only when the playback gate is open too.
363 'shaer:embed': embedsAllowed ? AP.timelineEmbed(t.embed_json, { playback: playbackAllowed }) : undefined,
364 },
365 }));
366 // The direct notes addressed to this account: a plain DM, a guardian's wave
367 // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
368 // they are not in the timeline; without them the app's Berichten shows only
369 // what you said yourself. Same shape as a post, so one parser handles both.
370 const me = AP.actorId(base, auth.site.slug);
371 const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
372 const messages = AP.getDirectMessages(auth.site.slug, 60).map((m) => ({
373 id: `${m.object_uri}#create`,
374 type: 'Create',
375 actor: m.actor_uri,
376 published: AP.isoStamp(m.published || m.created_at),
377 object: {
378 id: m.object_uri,
379 type: 'Note',
380 attributedTo: m.actor_uri,
381 content: AP.stripLeadingMentions(m.content),
382 url: m.note_url || undefined,
383 published: AP.isoStamp(m.published || m.created_at),
384 // Addressed to us and to nobody we know of: the other recipients of a
385 // note to several people are not ours to see, so we serve what we know.
386 to: [me],
387 // The Mention is how the client recognises itself as the addressee and
388 // groups the note into a conversation. No FEP-e232 link tags here: a
389 // mention row keeps the resolved quote, not the raw tags.
390 tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
391 attachment: AP.timelineAttachments(m.media_json),
392 // FEP-633c: what kind of message this is. The wave is a gentle nudge from
393 // a guardian; the help request is the buoy. Both render differently.
394 'shaer:wave': m.wave ? true : undefined,
395 'shaer:helpRequest': m.help_request ? true : undefined,
396 'shaer:quote': AP.timelineQuote(m.quote_json),
397 'shaer:author': (m.actor_name || m.actor_handle || m.actor_icon) ? {
398 name: m.actor_name || undefined, handle: m.actor_handle || undefined,
399 icon: m.actor_icon || undefined, url: m.actor_url || undefined,
400 emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
401 } : undefined,
402 'shaer:embed': embedsAllowed ? AP.timelineEmbed(m.embed_json, { playback: playbackAllowed }) : undefined,
403 },
404 }));
405 // Inbound REPLIES on your own posts: stored as interactions (the web's
406 // comment machinery), never as mentions, so this read missed them and a
407 // friend's reply arrived everywhere except in your app (Robins melding,
408 // 30-7). Same shape as the other legs; media/quotes ride the stored JSON.
409 const replies = AP.getReplyMessages(auth.site.slug, 60).map((m) => ({
410 id: `${m.object_uri}#create`,
411 type: 'Create',
412 actor: m.actor_uri,
413 published: AP.isoStamp(m.published || m.created_at),
414 object: {
415 id: m.object_uri,
416 type: 'Note',
417 attributedTo: m.actor_uri,
418 content: AP.stripLeadingMentions(m.content),
419 inReplyTo: m.parent_uri || `${base}/ap/notes/${m.post_id}`,
420 published: AP.isoStamp(m.published || m.created_at),
421 to: [me],
422 tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
423 attachment: AP.timelineAttachments(m.media_json),
424 'shaer:quote': AP.timelineQuote(m.quote_json),
425 'shaer:author': (m.actor_name || m.actor_handle || m.actor_icon) ? {
426 name: m.actor_name || undefined, handle: m.actor_handle || undefined,
427 icon: m.actor_icon || undefined, url: m.actor_url || undefined,
428 emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
429 } : undefined,
430 'shaer:embed': embedsAllowed ? AP.timelineEmbed(m.embed_json, { playback: playbackAllowed }) : undefined,
431 },
432 }));
433 // Your OWN sent notes (replies and direct messages, ap_outbox): without
434 // them a reply existed everywhere except in your own app, Messages showed
435 // half a conversation, and a retry ran into the duplicate guard (Robins
436 // melding, 30-7). Served like the other legs: same shape, one parser.
437 const mine = AP.selfAuthor(base, auth.site);
438 const sent = AP.getSentNotes(base, auth.site, 60).map((n) => ({
439 id: `${n.id}#create`,
440 type: 'Create',
441 actor: me,
442 published: n.published,
443 // The leading mention anchor is addressing, not prose (the DM leg strips
444 // it the same way); the Mention tags built from the full content stay.
445 object: { ...n, content: AP.stripLeadingMentions(n.content), 'shaer:author': mine },
446 }));
447 // Newest first over all legs, so the app can keep treating this as one feed.
448 const items = [...posts, ...messages, ...replies, ...sent].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
449 AP.sendAP(res, {
450 '@context': AP.AP_CONTEXT,
451 id: `${base}/ap/users/${auth.site.slug}/inbox`,
452 type: 'OrderedCollection',
453 // What this account may do with what is in here (FEP-633c 5.6). Owner-only
454 // by construction, and never on the public actor document: it says
455 // something about a child, and only the child and its guardians need it.
456 'shaer:capabilities': {
457 'shaer:externalEmbeds': embedsAllowed,
458 'shaer:externalPlayback': playbackAllowed,
459 // Leaving the app is the same decision as playing inside it: with the
460 // gate shut a link is shown but not followed, so the door is closed too
461 // and not just the picture over it.
462 'shaer:externalLinks': playbackAllowed,
463 },
464 // Het merk van wat hierin zit. Geef hem terug als `since` om op het
465 // volgende te wachten. NA het samenstellen bepaald, zodat hij precies dekt
466 // wat je in handen hebt en niet iets dat er ondertussen bij kwam.
467 'shaer:cursor': AP.feedCursor(auth.site.slug),
468 totalItems: items.length,
469 orderedItems: items,
470 });
471 return undefined;
472});
473
474// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
475// The actor advertises endpoints.uploadMedia; this implements it. A bearer
476// scoped to this site uploads one image/audio/video (multipart field "file",
477// AP convention) into the same store the reply editor uses, and gets back
478// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
479const AP_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
480fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
481const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
482const apMediaUpload = multer({
483 storage: multer.diskStorage({
484 destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
485 filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
486 }),
487 limits: { fileSize: 32 * 1024 * 1024 },
488 fileFilter: (req, file, cb) => {
489 const ext = path.extname(file.originalname || '').toLowerCase();
490 if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
491 cb(null, true);
492 },
493});
494router.post('/ap/users/:slug/uploadMedia', (req, res) => {
495 const auth = OAuth.verifyBearer(req.headers.authorization);
496 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
497 apMediaUpload.single('file')(req, res, (err) => {
498 if (err) return res.status(400).json({ error: err.message });
499 if (!req.file) return res.status(400).json({ error: 'No file' });
500 const mime = String(req.file.mimetype || '');
501 if (!/^(image|audio|video)\//.test(mime)) {
502 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
503 return res.status(400).json({ error: 'Media must be an image, audio or video file' });
504 }
505 // A video gets a poster frame next to it (shaer-zowq), best-effort and
506 // out of band: ffmpeg pulls one frame at 1s into <name>.poster.jpg. On a
507 // machine without ffmpeg nothing happens and nothing breaks; the clients
508 // fall back to extracting a frame natively.
509 if (mime.startsWith('video/')) {
510 // The bundled static build (ffmpeg-static) does the work, exactly like
511 // VideoCoverService and AudioTranscoder already do: Klonkt SHIPS its
512 // ffmpeg (Robins opmerking, 30-7), so nothing needs installing on any
513 // machine. Soft dependency + best-effort: absent stays silent, and
514 // FFMPEG_PATH can still override for an operator who wants a newer one.
515 Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
516 const bin = process.env.FFMPEG_PATH || ff.default;
517 if (!bin) return;
518 const poster = req.file.path + '.poster.jpg';
519 execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-ss', '1', '-i', req.file.path, '-frames:v', '1', '-vf', "scale='min(640,iw)':-2", poster],
520 { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] poster failed:', e.message); });
521 }).catch(() => { /* never blocks the upload */ });
522 }
523 // Audio gets the same courtesy (Robins vraag, 30-7: vrolijk de kale
524 // audio-tegel op): ffmpeg draws the waveform into <name>.poster.png.
525 // White on transparent, so the tile's own gradient stays the backdrop
526 // and every audio post keeps its own hue. The shape is bars, not the
527 // raw hairy wave (Robins tweede vraag): peak and average sampled into
528 // 57 columns (soft tip over bright core), blown up nearest-neighbor to
529 // 14px bars, and drawgrid ERASES 5px gaps (c=black@0 + replace=1 writes
530 // transparent pixels; h=2*ih keeps horizontal grid lines out of frame).
531 if (mime.startsWith('audio/')) {
532 Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
533 const bin = process.env.FFMPEG_PATH || ff.default;
534 if (!bin) return;
535 const poster = req.file.path + '.poster.png';
536 const graph = '[0:a]aformat=channel_layouts=mono,asplit[a][b];'
537 + '[a]showwavespic=s=57x256:colors=white@0.5:filter=peak:scale=sqrt:draw=full[pk];'
538 + '[b]showwavespic=s=57x256:colors=white:filter=average:scale=sqrt:draw=full[av];'
539 + '[pk][av]overlay=format=auto,scale=798:256:flags=neighbor,drawgrid=w=14:h=2*ih:t=5:c=black@0:replace=1';
540 execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-i', req.file.path, '-filter_complex', graph, '-frames:v', '1', poster],
541 { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] waveform failed:', e.message); });
542 }).catch(() => { /* never blocks the upload */ });
543 }
544 res.status(201).json({
545 url: '/media/reply-media/' + req.file.filename,
546 mediaType: mime,
547 name: String(req.file.originalname || '').slice(0, 120),
548 });
549 });
550});
551
552// ── Followers (count-only public, full for the owner) ─────────────
553// A C2S bearer scoped to this site (the account owner) gets the real actor
554// URIs so their own client can build a friends list; everyone else gets the
555// count only (privacy).
556// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
557// Returns true and sets the response headers when the owner asked for it.
558function wantsEnriched(req, res) {
559 res.set('Vary', 'Prefer'); // enriched and bare are two representations
560 if (AP.prefersEnriched(req.get('Prefer'))) {
561 res.set('Preference-Applied', 'return=representation');
562 return true;
563 }
564 return false;
565}
566
567router.get('/ap/users/:slug/followers', (req, res) => {
568 const auth = OAuth.verifyBearer(req.headers.authorization);
569 const owner = auth && auth.site.slug === req.params.slug;
570 const site = owner ? auth.site : publicSite(req.params.slug);
571 if (!site) return res.status(404).end();
572 if (owner) {
573 const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
574 // Default = bare references; enrich only when the client asks (FEP-9876).
575 const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
576 return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items));
577 }
578 const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
579 AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
580});
581
582// ── Following (count-only public, full for the owner) ─────────────
583router.get('/ap/users/:slug/following', (req, res) => {
584 const auth = OAuth.verifyBearer(req.headers.authorization);
585 const owner = auth && auth.site.slug === req.params.slug;
586 const site = owner ? auth.site : publicSite(req.params.slug);
587 if (!site) return res.status(404).end();
588 if (owner) {
589 const enrich = wantsEnriched(req, res); // FEP-9876 opt-in
590 let items = [];
591 try {
592 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);
593 items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
594 } catch { /* table may not exist */ }
595 return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items));
596 }
597 let n = 0;
598 try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
599 AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n));
600});
601
602// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
603router.get('/ap/users/:slug/featured', (req, res) => {
604 const site = publicSite(req.params.slug);
605 if (!site) return res.status(404).end();
606 // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
607 // last-processed-first). So we emit it reversed (lowest pin priority first,
608 // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
609 const posts = db.prepare(
610 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
611 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
612 AND pinned IS NOT NULL AND pinned > 0
613 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
614 ).all(site.id);
615 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts));
616});
617
618// ── Note ──────────────────────────────────────────────────────────
619router.get('/ap/notes/:id', async (req, res) => {
620 // No fan_only filter in the SELECT anymore: a friends-only post is not
621 // absent, it is GATED. The old route hid it from EVERYONE, also from the
622 // follower whose friendship earns it — so the signed resolution the reply
623 // path performs knocked on a door that could never open, and every reply
624 // to a friends-only post (Shaer's default!) died in
625 // cannot_resolve_inReplyTo. Strangers still get the exact same 404, so a
626 // note's existence stays as private as before.
627 const post = db.prepare(
628 "SELECT * FROM posts WHERE id = ? AND status = 'published'"
629 ).get(req.params.id);
630 if (post && AP.noteAudience(post) !== 'public') {
631 // The whole gate in a try: this is the only async route in this file,
632 // and Express 4 does not catch an async rejection — the request would
633 // hang forever instead of failing (which is exactly how the missing
634 // default-export entry manifested while building this). Any error here
635 // reads as "not authorized", never as silence.
636 try {
637 if (AP.noteAudience(post) === 'direct') return res.status(404).end();
638 const gsite = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
639 const actor = await AP.verifyRequest(req).catch(() => null);
640 if (!actor || !AP.mayReadNote(gsite, post, actor.id)) return res.status(404).end();
641 } catch { return res.status(404).end(); }
642 }
643 if (!post) {
644 // Could be one of OUR outbound replies (ap_outbox), not a post.
645 const note = AP.getOutboxNote(baseUrl(req), req.params.id);
646 if (!note) return res.status(404).end();
647 if (!AP.apWants(req)) {
648 // A browser hit a reply's AP URL → send them to the source it replies to
649 // (where the post + its reactions live), falling back to the site home.
650 const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
651 ? note.inReplyTo : (baseUrl(req) + '/');
652 return res.redirect(302, src);
653 }
654 return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
655 }
656 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
657 if (!site) return res.status(404).end();
658 const note = AP.buildNote(baseUrl(req), site, post);
659 if (!AP.apWants(req)) {
660 // A browser hit a post's AP note URL → send them to the human post page
661 // (which shows the post + its "from the fediverse" reactions).
662 return res.redirect(302, note.url || (baseUrl(req) + '/'));
663 }
664 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
665});
666
667// ── Replies collection ── lets remote servers fetch a post's whole thread.
668router.get('/ap/notes/:id/replies', (req, res) => {
669 const base = baseUrl(req);
670 const items = AP.getReplyUris(base, req.params.id);
671 AP.sendAP(res, {
672 '@context': AP.AP_CONTEXT,
673 id: `${base}/ap/notes/${req.params.id}/replies`,
674 type: 'OrderedCollection',
675 totalItems: items.length,
676 orderedItems: items,
677 });
678});
679
680// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
681router.get('/.well-known/nodeinfo', (req, res) => {
682 res.type('application/json');
683 res.set('Cache-Control', 'public, max-age=3600');
684 res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
685});
686router.get('/nodeinfo/2.1', (req, res) => {
687 let users = 0; let posts = 0;
688 // "users" = public AP actors (sites), not the admin/member account rows.
689 try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
690 try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
691 res.type('application/json; charset=utf-8');
692 res.set('Cache-Control', 'public, max-age=600');
693 res.send(JSON.stringify({
694 version: '2.1',
695 software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
696 protocols: ['activitypub'],
697 services: { inbound: [], outbound: [] },
698 openRegistrations: false,
699 usage: { users: { total: users }, localPosts: posts },
700 metadata: { nodeName: 'Klonkt' },
701 }));
702});
703
704// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
705const apJson = express.json({
706 type: ['application/activity+json', 'application/ld+json', 'application/json'],
707 limit: '1mb',
708 verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
709});
710router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
711 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
712 catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
713});
714
715// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
716// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
717// the normal delivery machinery. The token is scoped to one user+site (OAuth
718// consent), so it must match the slug in the URL. (Declared after apJson, which
719// this shares with the inbox handler.)
720router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
721 const auth = OAuth.verifyBearer(req.headers.authorization);
722 if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
723 if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
724 if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
725
726 const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
727 if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
728 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
729 if (out.status === 201 && out.url) res.set('Location', out.url);
730 // `state` carries a third outcome the app must be able to tell apart from a
731 // plain success: a ward's follow held for its guardians (§5.3, shaer-p729).
732 return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url, ...(out.state ? { state: out.state } : {}) });
733});
734
735export default router;
Note: See TracBrowser for help on using the repository browser.