source: Klonkt/src/routes/activitypub.js@ 89cc8c4

main
Last change on this file since 89cc8c4 was 89cc8c4, checked in by Robin Genis <roboburr@…>, 2 months ago

fix(fedi): turning AP off no longer 404s the whole site

The AP router is mounted at root, so its 'if (!apEnabled()) return res.status(404)' guard ran
for EVERY request and 404'd the entire site when federation was switched off. Now it uses
next('router') to skip the router so the normal routes handle the request; /ap/* still 404s
naturally when AP is off.

  • Property mode set to 100644
File size: 9.4 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';
20
21const router = express.Router();
22// The whole fediverse layer can be turned off (solo "no federation" mode):
23// then /ap/*, WebFinger and NodeInfo are simply gone — the site is undiscoverable
24// and unfederatable. CRITICAL: this router is mounted at root (app.use(apRoutes)), so a
25// blanket res.status(404) here ran for EVERY request and 404'd the whole site when AP was
26// off. Use next('router') to SKIP this router entirely and let the normal routes handle it
27// (the /ap/* paths then fall through to the app's normal 404, which is correct).
28router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
29// Generous per-IP baseline over all /ap/* (reads). The inbox POST gets an
30// additional, tighter cap inline (it triggers outbound fetches).
31router.use(apReadLimiter);
32let _ver = '1.0.0';
33try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
34
35const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
36const hostOf = (req) => { try { return new URL(baseUrl(req)).host; } catch { return req.get('host'); } };
37const publicSite = (slug) => db.prepare('SELECT * FROM sites WHERE slug = ? AND (is_public IS NULL OR is_public = 1)').get(slug);
38const primarySlug = () => { const r = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get(); return r && r.slug; };
39
40// ── WebFinger ─────────────────────────────────────────────────────
41router.get('/.well-known/webfinger', (req, res) => {
42 const m = String(req.query.resource || '').match(/^acct:([^@]+)@(.+)$/i);
43 if (!m) return res.status(400).type('text/plain').send('bad resource');
44 const site = publicSite(m[1]);
45 if (!site) return res.status(404).end();
46 res.type('application/jrd+json; charset=utf-8');
47 res.set('Cache-Control', 'public, max-age=300');
48 res.send(JSON.stringify({
49 subject: `acct:${site.slug}@${hostOf(req)}`,
50 links: [{ rel: 'self', type: 'application/activity+json', href: AP.actorId(baseUrl(req), site.slug) }],
51 }));
52});
53
54// ── Actor ─────────────────────────────────────────────────────────
55router.get('/ap/users/:slug', (req, res) => {
56 const site = publicSite(req.params.slug);
57 if (!site) return res.status(404).end();
58 if (!AP.apWants(req)) {
59 // A browser hit the AP actor URL → send them to the human profile.
60 const human = site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`;
61 return res.redirect(302, baseUrl(req) + human);
62 }
63 site.primary_slug = primarySlug();
64 AP.sendAP(res, AP.buildActor(baseUrl(req), site));
65});
66
67// ── Outbox ────────────────────────────────────────────────────────
68router.get('/ap/users/:slug/outbox', (req, res) => {
69 const site = publicSite(req.params.slug);
70 if (!site) return res.status(404).end();
71 const posts = db.prepare(
72 `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
73 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
74 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
75 ).all(site.id);
76 AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts));
77});
78
79// ── Followers (count only) ────────────────────────────────────────
80router.get('/ap/users/:slug/followers', (req, res) => {
81 const site = publicSite(req.params.slug);
82 if (!site) return res.status(404).end();
83 const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
84 AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
85});
86
87// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
88router.get('/ap/users/:slug/featured', (req, res) => {
89 const site = publicSite(req.params.slug);
90 if (!site) return res.status(404).end();
91 // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
92 // last-processed-first). So we emit it reversed (lowest pin priority first,
93 // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
94 const posts = db.prepare(
95 `SELECT id, slug, title, content, cover_image_url, nsfw, content_warning, published_at, created_at
96 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
97 AND pinned IS NOT NULL AND pinned > 0
98 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
99 ).all(site.id);
100 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts));
101});
102
103// ── Note ──────────────────────────────────────────────────────────
104router.get('/ap/notes/:id', (req, res) => {
105 const post = db.prepare(
106 "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
107 ).get(req.params.id);
108 if (!post) {
109 // Could be one of OUR outbound replies (ap_outbox), not a post.
110 const note = AP.getOutboxNote(baseUrl(req), req.params.id);
111 if (!note) return res.status(404).end();
112 if (!AP.apWants(req)) {
113 // A browser hit a reply's AP URL → send them to the source it replies to
114 // (where the post + its reactions live), falling back to the site home.
115 const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
116 ? note.inReplyTo : (baseUrl(req) + '/');
117 return res.redirect(302, src);
118 }
119 return AP.sendAP(res, { '@context': 'https://www.w3.org/ns/activitystreams', ...note });
120 }
121 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
122 if (!site) return res.status(404).end();
123 const note = AP.buildNote(baseUrl(req), site, post);
124 if (!AP.apWants(req)) {
125 // A browser hit a post's AP note URL → send them to the human post page
126 // (which shows the post + its "from the fediverse" reactions).
127 return res.redirect(302, note.url || (baseUrl(req) + '/'));
128 }
129 AP.sendAP(res, { '@context': 'https://www.w3.org/ns/activitystreams', ...note });
130});
131
132// ── Replies collection ── lets remote servers fetch a post's whole thread.
133router.get('/ap/notes/:id/replies', (req, res) => {
134 const base = baseUrl(req);
135 const items = AP.getReplyUris(base, req.params.id);
136 AP.sendAP(res, {
137 '@context': 'https://www.w3.org/ns/activitystreams',
138 id: `${base}/ap/notes/${req.params.id}/replies`,
139 type: 'OrderedCollection',
140 totalItems: items.length,
141 orderedItems: items,
142 });
143});
144
145// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
146router.get('/.well-known/nodeinfo', (req, res) => {
147 res.type('application/json');
148 res.set('Cache-Control', 'public, max-age=3600');
149 res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
150});
151router.get('/nodeinfo/2.1', (req, res) => {
152 let users = 0; let posts = 0;
153 try { users = db.prepare('SELECT COUNT(*) c FROM users').get().c; } catch { /* */ }
154 try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
155 res.type('application/json; charset=utf-8');
156 res.set('Cache-Control', 'public, max-age=600');
157 res.send(JSON.stringify({
158 version: '2.1',
159 software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
160 protocols: ['activitypub'],
161 services: { inbound: [], outbound: [] },
162 openRegistrations: false,
163 usage: { users: { total: users }, localPosts: posts },
164 metadata: { nodeName: 'Klonkt' },
165 }));
166});
167
168// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
169const apJson = express.json({
170 type: ['application/activity+json', 'application/ld+json', 'application/json'],
171 limit: '1mb',
172 verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
173});
174router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
175 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
176 catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
177});
178
179export default router;
Note: See TracBrowser for help on using the repository browser.