source: Klonkt/src/routes/activitypub.js@ 75ab393

main
Last change on this file since 75ab393 was 75ab393, checked in by Robin Genis <roboburr@…>, 3 months ago

feat(ratelimit): cap fediverse endpoints per IP; key IPv6 limiters by /64

Adds a generous per-IP baseline limiter across /ap/* reads (300/min) and a
tighter cap on the inbox POST (120/min), since each inbox delivery triggers an
outbound actor fetch. clientKey now collapses IPv6 to its /64 prefix so the
login/register/AP limiters can't be sidestepped by rotating addresses within
one allocation. Generous thresholds — real federation never trips them.

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

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