source: Klonkt/src/routes/activitypub.js@ 7a93fcf

main
Last change on this file since 7a93fcf was 08ab8ad, checked in by Robin <roboburr@…>, 6 weeks ago

De app las alleen de tijdlijn, dus een zwaai kwam nooit aan

Robin vroeg waarom zwaai-acties niet als bericht in Shaer verschijnen. De app
bouwt Berichten, gesprekken en de hulpvraag-escalaties uit een bron: de
C2S-inboxlees plus de eigen outbox. Die lees serveerde alleen ap_timeline, en
een directe note staat in ap_mentions. Dus kwam er niets binnen: geen zwaai,
geen DM, en sinds gisteren ook geen hulpvraag meer.

Tot d9ad6c5 lekten directe notes toevallig in de tijdlijn: de insert vroeg
alleen of het een top-level post was van iemand die je volgt. Dat hebben we
dichtgezet om hulpvragen uit de Krant te houden, en daarmee viel de enige weg
weg waarlangs de app ze binnenkreeg. Je eigen antwoord zag je nog wel, want dat
komt uit je outbox, en daardoor leek het half te werken.

Nu serveert de inboxlees allebei: posts en de directe notes die aan jou gericht
zijn. In dezelfde vorm, dus de client heeft er een parser voor. Met to:[jij] en
een Mention-tag, want zonder die adressering groepeert de app het niet tot een
gesprek, en met shaer:wave en shaer:helpRequest zodat een zwaai er als een
zwaai uitziet.

Onderweg gevonden: SQLite schrijft CURRENT_TIMESTAMP in UTC zonder zone, en
Date.parse leest dat als lokale tijd. Twee uur verschil is genoeg om een
gesprek in de verkeerde volgorde te zetten, dus dat gaat nu door isoStamp.

Changed files:
src/services/ActivityPubService.js

  • getDirectMessages(): de mentions die niet ook een tijdlijnrij zijn, want een publieke mention van iemand die je volgt staat in allebei
  • isoStamp(): een opgeslagen stempel als echt moment
  • stripLeadingMentions op de default export, die had de route nodig

src/routes/activitypub.js

  • de inboxlees serveert posts en berichten in een collectie, nieuwste eerst
  • adressering, wave-vlag, byline en de gated shaer:embed op elk bericht

New file:
test/c2s-messages.test.js

  • de twee tabellen blijven gescheiden, de lees brengt ze samen
  • de zwaai zit erin, is gemarkeerd, en is aan jou gericht
  • een publieke mention komt een keer langs, als post

remarks: 333 tests groen. De clientkant zit in de app-repos.

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

  • Property mode set to 100644
File size: 26.1 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 posts = 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 // The direct notes addressed to this account: a plain DM, a guardian's wave
220 // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
221 // they are not in the timeline; without them the app's Berichten shows only
222 // what you said yourself. Same shape as a post, so one parser handles both.
223 const me = AP.actorId(base, auth.site.slug);
224 const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
225 const messages = AP.getDirectMessages(auth.site.slug, 60).map((m) => ({
226 id: `${m.object_uri}#create`,
227 type: 'Create',
228 actor: m.actor_uri,
229 published: AP.isoStamp(m.published || m.created_at),
230 object: {
231 id: m.object_uri,
232 type: 'Note',
233 attributedTo: m.actor_uri,
234 content: AP.stripLeadingMentions(m.content),
235 url: m.note_url || undefined,
236 published: AP.isoStamp(m.published || m.created_at),
237 // Addressed to us and to nobody we know of: the other recipients of a
238 // note to several people are not ours to see, so we serve what we know.
239 to: [me],
240 // The Mention is how the client recognises itself as the addressee and
241 // groups the note into a conversation. No FEP-e232 link tags here: a
242 // mention row keeps the resolved quote, not the raw tags.
243 tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
244 attachment: AP.timelineAttachments(m.media_json),
245 // FEP-633c: what kind of message this is. The wave is a gentle nudge from
246 // a guardian; the help request is the buoy. Both render differently.
247 'shaer:wave': m.wave ? true : undefined,
248 'shaer:helpRequest': m.help_request ? true : undefined,
249 'shaer:quote': AP.timelineQuote(m.quote_json),
250 'shaer:author': (m.actor_name || m.actor_handle || m.actor_icon) ? {
251 name: m.actor_name || undefined, handle: m.actor_handle || undefined,
252 icon: m.actor_icon || undefined, url: m.actor_url || undefined,
253 emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
254 } : undefined,
255 'shaer:embed': embedsAllowed ? AP.timelineEmbed(m.embed_json, { playback: playbackAllowed }) : undefined,
256 },
257 }));
258 // Newest first over both, so the app can keep treating this as one feed.
259 const items = [...posts, ...messages].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
260 AP.sendAP(res, {
261 '@context': AP.AP_CONTEXT,
262 id: `${base}/ap/users/${auth.site.slug}/inbox`,
263 type: 'OrderedCollection',
264 // What this account may do with what is in here (FEP-633c 5.6). Owner-only
265 // by construction, and never on the public actor document: it says
266 // something about a child, and only the child and its guardians need it.
267 'shaer:capabilities': {
268 'shaer:externalEmbeds': embedsAllowed,
269 'shaer:externalPlayback': playbackAllowed,
270 // Leaving the app is the same decision as playing inside it: with the
271 // gate shut a link is shown but not followed, so the door is closed too
272 // and not just the picture over it.
273 'shaer:externalLinks': playbackAllowed,
274 },
275 totalItems: items.length,
276 orderedItems: items,
277 });
278});
279
280// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
281// The actor advertises endpoints.uploadMedia; this implements it. A bearer
282// scoped to this site uploads one image/audio/video (multipart field "file",
283// AP convention) into the same store the reply editor uses, and gets back
284// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
285const AP_MEDIA_DIR = path.resolve(
286 process.env.REPLY_MEDIA_PATH ||
287 path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'storage', 'media', 'reply-media')
288);
289fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
290const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
291const apMediaUpload = multer({
292 storage: multer.diskStorage({
293 destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
294 filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
295 }),
296 limits: { fileSize: 32 * 1024 * 1024 },
297 fileFilter: (req, file, cb) => {
298 const ext = path.extname(file.originalname || '').toLowerCase();
299 if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
300 cb(null, true);
301 },
302});
303router.post('/ap/users/:slug/uploadMedia', (req, res) => {
304 const auth = OAuth.verifyBearer(req.headers.authorization);
305 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
306 apMediaUpload.single('file')(req, res, (err) => {
307 if (err) return res.status(400).json({ error: err.message });
308 if (!req.file) return res.status(400).json({ error: 'No file' });
309 const mime = String(req.file.mimetype || '');
310 if (!/^(image|audio|video)\//.test(mime)) {
311 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
312 return res.status(400).json({ error: 'Media must be an image, audio or video file' });
313 }
314 res.status(201).json({
315 url: '/media/reply-media/' + req.file.filename,
316 mediaType: mime,
317 name: String(req.file.originalname || '').slice(0, 120),
318 });
319 });
320});
321
322// ── Followers (count-only public, full for the owner) ─────────────
323// A C2S bearer scoped to this site (the account owner) gets the real actor
324// URIs so their own client can build a friends list; everyone else gets the
325// count only (privacy).
326// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
327// Returns true and sets the response headers when the owner asked for it.
328function wantsEnriched(req, res) {
329 res.set('Vary', 'Prefer'); // enriched and bare are two representations
330 if (AP.prefersEnriched(req.get('Prefer'))) {
331 res.set('Preference-Applied', 'return=representation');
332 return true;
333 }
334 return false;
335}
336
337router.get('/ap/users/:slug/followers', (req, res) => {
338 const auth = OAuth.verifyBearer(req.headers.authorization);
339 const owner = auth && auth.site.slug === req.params.slug;
340 const site = owner ? auth.site : publicSite(req.params.slug);
341 if (!site) return res.status(404).end();
342 if (owner) {
343 const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
344 // Default = bare references; enrich only when the client asks (FEP-9876).
345 const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
346 return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items));
347 }
348 const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
349 AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
350});
351
352// ── Following (count-only public, full for the owner) ─────────────
353router.get('/ap/users/:slug/following', (req, res) => {
354 const auth = OAuth.verifyBearer(req.headers.authorization);
355 const owner = auth && auth.site.slug === req.params.slug;
356 const site = owner ? auth.site : publicSite(req.params.slug);
357 if (!site) return res.status(404).end();
358 if (owner) {
359 const enrich = wantsEnriched(req, res); // FEP-9876 opt-in
360 let items = [];
361 try {
362 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);
363 items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
364 } catch { /* table may not exist */ }
365 return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items));
366 }
367 let n = 0;
368 try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
369 AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n));
370});
371
372// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
373router.get('/ap/users/:slug/featured', (req, res) => {
374 const site = publicSite(req.params.slug);
375 if (!site) return res.status(404).end();
376 // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
377 // last-processed-first). So we emit it reversed (lowest pin priority first,
378 // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
379 const posts = db.prepare(
380 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
381 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
382 AND pinned IS NOT NULL AND pinned > 0
383 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
384 ).all(site.id);
385 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts));
386});
387
388// ── Note ──────────────────────────────────────────────────────────
389router.get('/ap/notes/:id', (req, res) => {
390 const post = db.prepare(
391 "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
392 ).get(req.params.id);
393 if (!post) {
394 // Could be one of OUR outbound replies (ap_outbox), not a post.
395 const note = AP.getOutboxNote(baseUrl(req), req.params.id);
396 if (!note) return res.status(404).end();
397 if (!AP.apWants(req)) {
398 // A browser hit a reply's AP URL → send them to the source it replies to
399 // (where the post + its reactions live), falling back to the site home.
400 const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
401 ? note.inReplyTo : (baseUrl(req) + '/');
402 return res.redirect(302, src);
403 }
404 return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
405 }
406 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
407 if (!site) return res.status(404).end();
408 const note = AP.buildNote(baseUrl(req), site, post);
409 if (!AP.apWants(req)) {
410 // A browser hit a post's AP note URL → send them to the human post page
411 // (which shows the post + its "from the fediverse" reactions).
412 return res.redirect(302, note.url || (baseUrl(req) + '/'));
413 }
414 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
415});
416
417// ── Replies collection ── lets remote servers fetch a post's whole thread.
418router.get('/ap/notes/:id/replies', (req, res) => {
419 const base = baseUrl(req);
420 const items = AP.getReplyUris(base, req.params.id);
421 AP.sendAP(res, {
422 '@context': AP.AP_CONTEXT,
423 id: `${base}/ap/notes/${req.params.id}/replies`,
424 type: 'OrderedCollection',
425 totalItems: items.length,
426 orderedItems: items,
427 });
428});
429
430// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
431router.get('/.well-known/nodeinfo', (req, res) => {
432 res.type('application/json');
433 res.set('Cache-Control', 'public, max-age=3600');
434 res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
435});
436router.get('/nodeinfo/2.1', (req, res) => {
437 let users = 0; let posts = 0;
438 // "users" = public AP actors (sites), not the admin/member account rows.
439 try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
440 try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
441 res.type('application/json; charset=utf-8');
442 res.set('Cache-Control', 'public, max-age=600');
443 res.send(JSON.stringify({
444 version: '2.1',
445 software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
446 protocols: ['activitypub'],
447 services: { inbound: [], outbound: [] },
448 openRegistrations: false,
449 usage: { users: { total: users }, localPosts: posts },
450 metadata: { nodeName: 'Klonkt' },
451 }));
452});
453
454// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
455const apJson = express.json({
456 type: ['application/activity+json', 'application/ld+json', 'application/json'],
457 limit: '1mb',
458 verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
459});
460router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
461 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
462 catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
463});
464
465// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
466// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
467// the normal delivery machinery. The token is scoped to one user+site (OAuth
468// consent), so it must match the slug in the URL. (Declared after apJson, which
469// this shares with the inbox handler.)
470router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
471 const auth = OAuth.verifyBearer(req.headers.authorization);
472 if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
473 if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
474 if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
475
476 const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
477 if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
478 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
479 if (out.status === 201 && out.url) res.set('Location', out.url);
480 return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url });
481});
482
483export default router;
Note: See TracBrowser for help on using the repository browser.