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

main
Last change on this file since c8e03c6 was e27b8db, checked in by Robin Genis <roboburr@…>, 6 weeks ago

Afspelen in de app als tweede gated feature, en het gat in de gate

Bij het uitzoeken van de YouTube-vraag bleek de gate lek. De web-Krant bouwt de
speler uit de inhoud van de post via timelineEmbedHtml, en dat pad raakte
gateEmbeds nooit. Een ward wiens guardians niets hadden toegestaan kreeg dus de
volledige YouTube-speler op het web, terwijl de app niets liet zien: het zware
ding open, het lichte dicht. Precies omgekeerd.

Nu zijn het twee besluiten, want het zijn twee dingen. Zien dat er een filmpje
is, is niet hetzelfde als het scherm afstaan aan de motor van een derde partij,
compleet met eindscherm en volgende-video. shaer:externalEmbeds houdt de kaart,
shaer:externalPlayback de speler, allebei standaard uit voor een ward, en
afspelen vereist de kaart: je kunt niet spelen wat je niet mag zien.

En het antwoord op Robins vraag over de links: die vallen er ook onder. De gate
verborg tot nu toe alleen het plaatje terwijl de kale link eronder gewoon
aantikbaar bleef, dus de deur stond open met een doek eroverheen. Staat de gate
dicht, dan toont de kaart zich nog wel maar is hij geen deur meer.

De server bepaalt wat gespeeld mag worden, niet de client: hij levert
shaer:playerUrl mee, alleen bij een open gate en alleen in de privacy-variant
(youtube-nocookie met rel=0, of de eigen speler van de PeerTube-instance). De
app houdt zo geen lijst van hosts bij; hij speelt wat hij krijgt aangereikt.

Changed files:
src/config/database.js

  • kolom sites.external_playback

src/services/guardianship/notes.js

  • externalPlaybackAllowed naast externalEmbedsAllowed

src/services/guardianship/gated.js

  • shaer:externalPlayback in de feature-tabel

src/services/ActivityPubService.js

  • timelineEmbed voegt shaer:playerUrl toe als afspelen mag; playerUrlFor kent alleen privacy-varianten en weigert de rest

src/routes/activitypub.js

  • shaer:capabilities op de owner-only inbox-read: wat mag dit account
  • de embed draagt de speler-URL alleen bij een open playback-gate

src/routes/posts.js

  • het gat gedicht: de speler-iframe op de web-Krant valt nu onder de gate

src/routes/guardian.js

  • de voorstel-route is feature-bewust; het lokale pad stuurt nu ook door

src/assets/js/guardian.js

  • tweede knop in het paneel, alleen zichtbaar als de kaart al aan staat

src/services/i18n.js

  • de labels in nl, en, de

test/gated-settings.test.js

  • drie tests: de speler-URL rijdt alleen mee bij een open gate, een pagina die we niet framen blijft een thumbnail, en afspelen vereist de kaart

remarks: 280 tests groen. Niets geforceerd: beide gates staan standaard uit
voor een ward en twee van de drie guardians moeten nog steeds akkoord gaan.

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

  • Property mode set to 100644
File size: 23.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 multer from 'multer';
23import path from 'path';
24import fs from 'fs';
25import { fileURLToPath } from 'url';
26import { randomUUID } from 'crypto';
27
28const router = express.Router();
29// The whole fediverse layer can be turned off (solo "no federation" mode):
30// then /ap/*, WebFinger and NodeInfo are simply gone — the site is undiscoverable
31// and unfederatable. CRITICAL: this router is mounted at root (app.use(apRoutes)), so a
32// blanket res.status(404) here ran for EVERY request and 404'd the whole site when AP was
33// off. Use next('router') to SKIP this router entirely and let the normal routes handle it
34// (the /ap/* paths then fall through to the app's normal 404, which is correct).
35router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
36// Generous per-IP baseline over all /ap/* (reads). The inbox POST gets an
37// additional, tighter cap inline (it triggers outbound fetches).
38router.use(apReadLimiter);
39let _ver = '1.0.0';
40try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
41
42const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
43const hostOf = (req) => { try { return new URL(baseUrl(req)).host; } catch { return req.get('host'); } };
44const publicSite = (slug) => db.prepare('SELECT * FROM sites WHERE slug = ? AND (is_public IS NULL OR is_public = 1)').get(slug);
45const primarySlug = () => { const r = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get(); return r && r.slug; };
46
47// ── WebFinger ─────────────────────────────────────────────────────
48router.get('/.well-known/webfinger', (req, res) => {
49 const m = String(req.query.resource || '').match(/^acct:([^@]+)@(.+)$/i);
50 if (!m) return res.status(400).type('text/plain').send('bad resource');
51 const site = publicSite(m[1]);
52 if (!site) return res.status(404).end();
53 res.type('application/jrd+json; charset=utf-8');
54 res.set('Cache-Control', 'public, max-age=300');
55 const actorUri = AP.actorId(baseUrl(req), site.slug);
56 const profileUrl = baseUrl(req) + (site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`);
57 res.send(JSON.stringify({
58 subject: `acct:${site.slug}@${hostOf(req)}`,
59 aliases: [actorUri, profileUrl],
60 links: [
61 { rel: 'self', type: 'application/activity+json', href: actorUri },
62 { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl },
63 ],
64 }));
65});
66
67// ── Actor ─────────────────────────────────────────────────────────
68router.get('/ap/users/:slug', (req, res) => {
69 const site = publicSite(req.params.slug);
70 if (!site) return res.status(404).end();
71 if (!AP.apWants(req)) {
72 // A browser hit the AP actor URL → send them to the human profile.
73 const human = site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`;
74 return res.redirect(302, baseUrl(req) + human);
75 }
76 site.primary_slug = primarySlug();
77 AP.sendAP(res, AP.buildActor(baseUrl(req), site));
78});
79
80// ── Outbox ────────────────────────────────────────────────────────
81router.get('/ap/users/:slug/outbox', async (req, res) => {
82 const site = publicSite(req.params.slug);
83 if (!site) return res.status(404).end();
84 // Authorized fetch (FEP-633c §5.3 note): a committed guardian doing a SIGNED
85 // GET may read the ward's fan-only history too, without appearing as a
86 // follower. Unsigned / non-guardian callers get the public collection only.
87 let asGuardian = false;
88 if (req.headers['signature']) {
89 const verified = await AP.verifyRequest(req).catch(() => null);
90 asGuardian = !!(verified && AP.isWardGuardian(req.params.slug, verified.id));
91 }
92 const fanClause = asGuardian ? '' : "AND (fan_only IS NULL OR fan_only = 0)";
93 const posts = db.prepare(
94 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
95 FROM posts WHERE site_id = ? AND status = 'published' ${fanClause}
96 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
97 ).all(site.id);
98 AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts), asGuardian ? 'private, no-store' : undefined);
99});
100
101// ── Blocked collection (owner only, AP §5.6) ──────────────────────
102// The server blocklist is the source of truth for Shaer's "in Orbit":
103// clients read it here instead of keeping their own state. Actor-kind
104// blocks only (domain blocks are instance policy, not an Orbit member).
105router.get('/ap/users/:slug/blocked', (req, res) => {
106 const auth = OAuth.verifyBearer(req.headers.authorization);
107 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
108 const base = baseUrl(req);
109 const items = AP.listBlocks(auth.site.slug)
110 .filter((b) => b.kind === 'actor')
111 .map((b) => b.target);
112 AP.sendAP(res, {
113 '@context': AP.AP_CONTEXT,
114 id: `${base}/ap/users/${auth.site.slug}/blocked`,
115 type: 'OrderedCollection',
116 totalItems: items.length,
117 orderedItems: items,
118 });
119});
120
121// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
122// The dashboard collections the Shaer clients read: pending adoption offers,
123// gated follows (empty in Klonkt for now) and the guardian's wards. Same
124// contract as the Shaer test daemon.
125function queueRoute(name, build) {
126 router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
127 const auth = OAuth.verifyBearer(req.headers.authorization);
128 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
129 const base = baseUrl(req);
130 const me = `${base}/ap/users/${auth.site.slug}`;
131 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
132 });
133}
134queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
135queueRoute('follows', (id) => Guardianship.followsCollection(id));
136queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
137// Availability (FEP-633c 3.6.1) is never public: the ward reads its
138// guardians' real states here and nowhere else.
139queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
140
141// ── Inbox read (owner only, AP C2S) ───────────────────────────────
142// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
143// scoped to this site) reads recent inbound posts (the timeline: accounts
144// they follow) as Create(Note) items, so an app (Shaer) can build a unified
145// feed. Anyone else gets 403; the inbox stays write-only for the public.
146router.get('/ap/users/:slug/inbox', (req, res) => {
147 const auth = OAuth.verifyBearer(req.headers.authorization);
148 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
149 const base = baseUrl(req);
150 // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
151 // world outside the fediverse is the guardians' call. The gate is applied
152 // here, at serialisation: a blocked embed is never sent, because an embed the
153 // client merely hides has still been delivered to the device.
154 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
155 const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
156 // The heavier sibling (5.6): may a third party's PLAYER run inside the app,
157 // and may a link hand the child over to a browser? Both are the guardians'
158 // call, both default to off for a ward, and both need the preview gate open
159 // first: you cannot play, or follow, what you may not see. Served here so
160 // the app knows what it may offer instead of guessing.
161 const playbackAllowed = embedsAllowed
162 && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
163 const items = AP.getTimeline(auth.site.slug, 60).map((t) => ({
164 id: `${t.id}#create`,
165 type: 'Create',
166 actor: t.author_uri,
167 published: t.published || t.created_at || undefined,
168 object: {
169 id: t.id,
170 type: 'Note',
171 attributedTo: t.author_uri,
172 content: t.content,
173 url: t.url || undefined,
174 published: t.published || t.created_at || undefined,
175 sensitive: !!t.nsfw,
176 summary: t.cw || undefined,
177 // Friends' media travels along (media_json → AS2 attachment), so the
178 // client renders their images/audio like own outbox posts.
179 attachment: AP.timelineAttachments(t.media_json),
180 // The note's preserved tags, so the client can render them: FEP-9098
181 // Emoji tags (:shortcode: → image) and FEP-e232 Link tags (quotes /
182 // inline object references). Combined into one `tag` array; omitted
183 // when the note has neither.
184 tag: (() => {
185 const tags = [...(AP.timelineEmojis(t.emoji_json) || []), ...(AP.timelineObjectLinks(t.link_json) || [])];
186 return tags.length ? tags : undefined;
187 })(),
188 // FEP-044f: the resolved quoted post (author + content), so the client
189 // renders an embedded quote card instead of a bare link. Omitted when the
190 // note has no quote or the quoted post could not be resolved.
191 'shaer:quote': AP.timelineQuote(t.quote_json),
192 // The post author's display info (name / @handle / avatar), so every card
193 // gets a byline header like the quote card. attributedTo stays the bare
194 // actor URI; this is the resolved presentation Klonkt already stored.
195 'shaer:author': (t.author_name || t.author_handle || t.author_icon) ? {
196 name: t.author_name || undefined, handle: t.author_handle || undefined,
197 icon: t.author_icon || undefined, url: t.author_url || undefined,
198 // FEP-9098: emojis in the display name (":shortcode:"), if any.
199 emojis: (() => { try { return t.author_emoji_json ? JSON.parse(t.author_emoji_json) : undefined; } catch { return undefined; } })(),
200 } : undefined,
201 // When a followed account boosted this, who did ("X boosted"). Omitted for
202 // ordinary posts.
203 'shaer:booster': (t.reblog_name || t.reblog_handle || t.reblog_icon) ? {
204 name: t.reblog_name || undefined, handle: t.reblog_handle || undefined,
205 icon: t.reblog_icon || undefined,
206 // FEP-9098: emojis in the booster's display name (":shortcode:"), if any.
207 emojis: (() => { try { return t.reblog_emoji_json ? JSON.parse(t.reblog_emoji_json) : undefined; } catch { return undefined; } })(),
208 } : undefined,
209 // Whether THIS account already liked/boosted the note, so the app's
210 // detail-view buttons show the current state (and can toggle/undo).
211 'shaer:liked': !!t.liked,
212 'shaer:boosted': !!t.boosted,
213 // An external (non-fediverse) embed, thumbnail-only and never an iframe.
214 // Omitted entirely when the gate is closed (see above).
215 // Carries shaer:playerUrl only when the playback gate is open too.
216 'shaer:embed': embedsAllowed ? AP.timelineEmbed(t.embed_json, { playback: playbackAllowed }) : undefined,
217 },
218 }));
219 AP.sendAP(res, {
220 '@context': AP.AP_CONTEXT,
221 id: `${base}/ap/users/${auth.site.slug}/inbox`,
222 type: 'OrderedCollection',
223 // What this account may do with what is in here (FEP-633c 5.6). Owner-only
224 // by construction, and never on the public actor document: it says
225 // something about a child, and only the child and its guardians need it.
226 'shaer:capabilities': {
227 'shaer:externalEmbeds': embedsAllowed,
228 'shaer:externalPlayback': playbackAllowed,
229 // Leaving the app is the same decision as playing inside it: with the
230 // gate shut a link is shown but not followed, so the door is closed too
231 // and not just the picture over it.
232 'shaer:externalLinks': playbackAllowed,
233 },
234 totalItems: items.length,
235 orderedItems: items,
236 });
237});
238
239// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
240// The actor advertises endpoints.uploadMedia; this implements it. A bearer
241// scoped to this site uploads one image/audio/video (multipart field "file",
242// AP convention) into the same store the reply editor uses, and gets back
243// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
244const AP_MEDIA_DIR = path.resolve(
245 process.env.REPLY_MEDIA_PATH ||
246 path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'storage', 'media', 'reply-media')
247);
248fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
249const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
250const apMediaUpload = multer({
251 storage: multer.diskStorage({
252 destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
253 filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
254 }),
255 limits: { fileSize: 32 * 1024 * 1024 },
256 fileFilter: (req, file, cb) => {
257 const ext = path.extname(file.originalname || '').toLowerCase();
258 if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
259 cb(null, true);
260 },
261});
262router.post('/ap/users/:slug/uploadMedia', (req, res) => {
263 const auth = OAuth.verifyBearer(req.headers.authorization);
264 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
265 apMediaUpload.single('file')(req, res, (err) => {
266 if (err) return res.status(400).json({ error: err.message });
267 if (!req.file) return res.status(400).json({ error: 'No file' });
268 const mime = String(req.file.mimetype || '');
269 if (!/^(image|audio|video)\//.test(mime)) {
270 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
271 return res.status(400).json({ error: 'Media must be an image, audio or video file' });
272 }
273 res.status(201).json({
274 url: '/media/reply-media/' + req.file.filename,
275 mediaType: mime,
276 name: String(req.file.originalname || '').slice(0, 120),
277 });
278 });
279});
280
281// ── Followers (count-only public, full for the owner) ─────────────
282// A C2S bearer scoped to this site (the account owner) gets the real actor
283// URIs so their own client can build a friends list; everyone else gets the
284// count only (privacy).
285// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
286// Returns true and sets the response headers when the owner asked for it.
287function wantsEnriched(req, res) {
288 res.set('Vary', 'Prefer'); // enriched and bare are two representations
289 if (AP.prefersEnriched(req.get('Prefer'))) {
290 res.set('Preference-Applied', 'return=representation');
291 return true;
292 }
293 return false;
294}
295
296router.get('/ap/users/:slug/followers', (req, res) => {
297 const auth = OAuth.verifyBearer(req.headers.authorization);
298 const owner = auth && auth.site.slug === req.params.slug;
299 const site = owner ? auth.site : publicSite(req.params.slug);
300 if (!site) return res.status(404).end();
301 if (owner) {
302 const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
303 // Default = bare references; enrich only when the client asks (FEP-9876).
304 const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
305 return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items));
306 }
307 const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
308 AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
309});
310
311// ── Following (count-only public, full for the owner) ─────────────
312router.get('/ap/users/:slug/following', (req, res) => {
313 const auth = OAuth.verifyBearer(req.headers.authorization);
314 const owner = auth && auth.site.slug === req.params.slug;
315 const site = owner ? auth.site : publicSite(req.params.slug);
316 if (!site) return res.status(404).end();
317 if (owner) {
318 const enrich = wantsEnriched(req, res); // FEP-9876 opt-in
319 let items = [];
320 try {
321 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);
322 items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
323 } catch { /* table may not exist */ }
324 return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items));
325 }
326 let n = 0;
327 try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
328 AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n));
329});
330
331// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
332router.get('/ap/users/:slug/featured', (req, res) => {
333 const site = publicSite(req.params.slug);
334 if (!site) return res.status(404).end();
335 // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
336 // last-processed-first). So we emit it reversed (lowest pin priority first,
337 // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
338 const posts = db.prepare(
339 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
340 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
341 AND pinned IS NOT NULL AND pinned > 0
342 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
343 ).all(site.id);
344 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts));
345});
346
347// ── Note ──────────────────────────────────────────────────────────
348router.get('/ap/notes/:id', (req, res) => {
349 const post = db.prepare(
350 "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
351 ).get(req.params.id);
352 if (!post) {
353 // Could be one of OUR outbound replies (ap_outbox), not a post.
354 const note = AP.getOutboxNote(baseUrl(req), req.params.id);
355 if (!note) return res.status(404).end();
356 if (!AP.apWants(req)) {
357 // A browser hit a reply's AP URL → send them to the source it replies to
358 // (where the post + its reactions live), falling back to the site home.
359 const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
360 ? note.inReplyTo : (baseUrl(req) + '/');
361 return res.redirect(302, src);
362 }
363 return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
364 }
365 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
366 if (!site) return res.status(404).end();
367 const note = AP.buildNote(baseUrl(req), site, post);
368 if (!AP.apWants(req)) {
369 // A browser hit a post's AP note URL → send them to the human post page
370 // (which shows the post + its "from the fediverse" reactions).
371 return res.redirect(302, note.url || (baseUrl(req) + '/'));
372 }
373 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
374});
375
376// ── Replies collection ── lets remote servers fetch a post's whole thread.
377router.get('/ap/notes/:id/replies', (req, res) => {
378 const base = baseUrl(req);
379 const items = AP.getReplyUris(base, req.params.id);
380 AP.sendAP(res, {
381 '@context': AP.AP_CONTEXT,
382 id: `${base}/ap/notes/${req.params.id}/replies`,
383 type: 'OrderedCollection',
384 totalItems: items.length,
385 orderedItems: items,
386 });
387});
388
389// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
390router.get('/.well-known/nodeinfo', (req, res) => {
391 res.type('application/json');
392 res.set('Cache-Control', 'public, max-age=3600');
393 res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
394});
395router.get('/nodeinfo/2.1', (req, res) => {
396 let users = 0; let posts = 0;
397 // "users" = public AP actors (sites), not the admin/member account rows.
398 try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
399 try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
400 res.type('application/json; charset=utf-8');
401 res.set('Cache-Control', 'public, max-age=600');
402 res.send(JSON.stringify({
403 version: '2.1',
404 software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
405 protocols: ['activitypub'],
406 services: { inbound: [], outbound: [] },
407 openRegistrations: false,
408 usage: { users: { total: users }, localPosts: posts },
409 metadata: { nodeName: 'Klonkt' },
410 }));
411});
412
413// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
414const apJson = express.json({
415 type: ['application/activity+json', 'application/ld+json', 'application/json'],
416 limit: '1mb',
417 verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
418});
419router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
420 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
421 catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
422});
423
424// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
425// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
426// the normal delivery machinery. The token is scoped to one user+site (OAuth
427// consent), so it must match the slug in the URL. (Declared after apJson, which
428// this shares with the inbox handler.)
429router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
430 const auth = OAuth.verifyBearer(req.headers.authorization);
431 if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
432 if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
433 if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
434
435 const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
436 if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
437 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
438 if (out.status === 201 && out.url) res.set('Location', out.url);
439 return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url });
440});
441
442export default router;
Note: See TracBrowser for help on using the repository browser.