source: Klonkt/src/routes/activitypub.js@ 0cea12b

main
Last change on this file since 0cea12b was 0cea12b, checked in by Robin <roboburr@…>, 7 weeks ago

Feature: owner inbox read over C2S (GET, bearer-gated)

GET /ap/users/:slug/inbox with a bearer scoped to that site returns the
recent timeline (accounts the owner follows) as an OrderedCollection of
Create(Note) items with content, url, published, sensitive and summary
(CW). Anyone else gets 403: the inbox stays write-only for the public.
This is the missing read half for a connected app's unified feed
(Shaer HomeBase, bead shaer-n4h); same gate pattern as the owner
followers/following read.

Changed files:
src/routes/activitypub.js

  • GET /ap/users/:slug/inbox (owner only) mapping ap_timeline rows

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

  • data-path coverage for the fields the mapping relies on

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

  • Property mode set to 100644
File size: 13.8 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';
[283f618]19import { apEnabled } from '../services/SettingsService.js';
[dd568e7]20import OAuth from '../services/OAuthService.js';
[6bd25d1]21
22const router = express.Router();
[283f618]23// The whole fediverse layer can be turned off (solo "no federation" mode):
24// then /ap/*, WebFinger and NodeInfo are simply gone — the site is undiscoverable
[89cc8c4]25// and unfederatable. CRITICAL: this router is mounted at root (app.use(apRoutes)), so a
26// blanket res.status(404) here ran for EVERY request and 404'd the whole site when AP was
27// off. Use next('router') to SKIP this router entirely and let the normal routes handle it
28// (the /ap/* paths then fall through to the app's normal 404, which is correct).
29router.use((req, res, next) => { if (!apEnabled()) return next('router'); next(); });
[75ab393]30// Generous per-IP baseline over all /ap/* (reads). The inbox POST gets an
31// additional, tighter cap inline (it triggers outbound fetches).
32router.use(apReadLimiter);
[d7526bd]33let _ver = '1.0.0';
34try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
[6bd25d1]35
36const baseUrl = (req) => (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
37const hostOf = (req) => { try { return new URL(baseUrl(req)).host; } catch { return req.get('host'); } };
38const publicSite = (slug) => db.prepare('SELECT * FROM sites WHERE slug = ? AND (is_public IS NULL OR is_public = 1)').get(slug);
39const primarySlug = () => { const r = db.prepare('SELECT slug FROM sites WHERE is_primary = 1').get(); return r && r.slug; };
40
41// ── WebFinger ─────────────────────────────────────────────────────
42router.get('/.well-known/webfinger', (req, res) => {
43 const m = String(req.query.resource || '').match(/^acct:([^@]+)@(.+)$/i);
44 if (!m) return res.status(400).type('text/plain').send('bad resource');
45 const site = publicSite(m[1]);
46 if (!site) return res.status(404).end();
47 res.type('application/jrd+json; charset=utf-8');
48 res.set('Cache-Control', 'public, max-age=300');
[f2796d3]49 const actorUri = AP.actorId(baseUrl(req), site.slug);
50 const profileUrl = baseUrl(req) + (site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`);
[6bd25d1]51 res.send(JSON.stringify({
52 subject: `acct:${site.slug}@${hostOf(req)}`,
[f2796d3]53 aliases: [actorUri, profileUrl],
54 links: [
55 { rel: 'self', type: 'application/activity+json', href: actorUri },
56 { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: profileUrl },
57 ],
[6bd25d1]58 }));
59});
60
61// ── Actor ─────────────────────────────────────────────────────────
62router.get('/ap/users/:slug', (req, res) => {
63 const site = publicSite(req.params.slug);
64 if (!site) return res.status(404).end();
65 if (!AP.apWants(req)) {
66 // A browser hit the AP actor URL → send them to the human profile.
67 const human = site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`;
68 return res.redirect(302, baseUrl(req) + human);
69 }
70 site.primary_slug = primarySlug();
71 AP.sendAP(res, AP.buildActor(baseUrl(req), site));
72});
73
74// ── Outbox ────────────────────────────────────────────────────────
75router.get('/ap/users/:slug/outbox', (req, res) => {
76 const site = publicSite(req.params.slug);
77 if (!site) return res.status(404).end();
78 const posts = db.prepare(
[857a06f]79 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
[6bd25d1]80 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
81 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
82 ).all(site.id);
83 AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, posts));
84});
85
[0cea12b]86// ── Inbox read (owner only, AP C2S) ───────────────────────────────
87// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
88// scoped to this site) reads recent inbound posts (the timeline: accounts
89// they follow) as Create(Note) items, so an app (Shaer) can build a unified
90// feed. Anyone else gets 403; the inbox stays write-only for the public.
91router.get('/ap/users/:slug/inbox', (req, res) => {
92 const auth = OAuth.verifyBearer(req.headers.authorization);
93 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
94 const base = baseUrl(req);
95 const items = AP.getTimeline(auth.site.slug, 60).map((t) => ({
96 id: `${t.id}#create`,
97 type: 'Create',
98 actor: t.author_uri,
99 published: t.published || t.created_at || undefined,
100 object: {
101 id: t.id,
102 type: 'Note',
103 attributedTo: t.author_uri,
104 content: t.content,
105 url: t.url || undefined,
106 published: t.published || t.created_at || undefined,
107 sensitive: !!t.nsfw,
108 summary: t.cw || undefined,
109 },
110 }));
111 AP.sendAP(res, {
112 '@context': AP.AP_CONTEXT,
113 id: `${base}/ap/users/${auth.site.slug}/inbox`,
114 type: 'OrderedCollection',
115 totalItems: items.length,
116 orderedItems: items,
117 });
118});
119
[4407c67]120// ── Followers (count-only public, full for the owner) ─────────────
121// A C2S bearer scoped to this site (the account owner) gets the real actor
122// URIs so their own client can build a friends list; everyone else gets the
123// count only (privacy).
[6bd25d1]124router.get('/ap/users/:slug/followers', (req, res) => {
[4407c67]125 const auth = OAuth.verifyBearer(req.headers.authorization);
126 const owner = auth && auth.site.slug === req.params.slug;
127 const site = owner ? auth.site : publicSite(req.params.slug);
[6bd25d1]128 if (!site) return res.status(404).end();
[4407c67]129 if (owner) {
130 const items = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
131 return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items));
132 }
[6bd25d1]133 const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
134 AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
135});
136
[4407c67]137// ── Following (count-only public, full for the owner) ─────────────
[f2796d3]138router.get('/ap/users/:slug/following', (req, res) => {
[4407c67]139 const auth = OAuth.verifyBearer(req.headers.authorization);
140 const owner = auth && auth.site.slug === req.params.slug;
141 const site = owner ? auth.site : publicSite(req.params.slug);
[f2796d3]142 if (!site) return res.status(404).end();
[4407c67]143 if (owner) {
144 let items = [];
145 try { items = 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); } catch { /* table may not exist */ }
146 return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items));
147 }
[f2796d3]148 let n = 0;
149 try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
150 AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n));
151});
152
[75bda38]153// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
154router.get('/ap/users/:slug/featured', (req, res) => {
155 const site = publicSite(req.params.slug);
156 if (!site) return res.status(404).end();
[2af2e69]157 // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
158 // last-processed-first). So we emit it reversed (lowest pin priority first,
159 // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
[75bda38]160 const posts = db.prepare(
[857a06f]161 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
[75bda38]162 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
163 AND pinned IS NOT NULL AND pinned > 0
[2af2e69]164 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
[75bda38]165 ).all(site.id);
166 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts));
167});
168
[6bd25d1]169// ── Note ──────────────────────────────────────────────────────────
170router.get('/ap/notes/:id', (req, res) => {
171 const post = db.prepare(
172 "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
173 ).get(req.params.id);
[55bc7f9]174 if (!post) {
175 // Could be one of OUR outbound replies (ap_outbox), not a post.
176 const note = AP.getOutboxNote(baseUrl(req), req.params.id);
[49edc72]177 if (!note) return res.status(404).end();
178 if (!AP.apWants(req)) {
179 // A browser hit a reply's AP URL → send them to the source it replies to
180 // (where the post + its reactions live), falling back to the site home.
181 const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
182 ? note.inReplyTo : (baseUrl(req) + '/');
183 return res.redirect(302, src);
184 }
[d3b9f68]185 return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
[55bc7f9]186 }
[6bd25d1]187 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
188 if (!site) return res.status(404).end();
[49edc72]189 const note = AP.buildNote(baseUrl(req), site, post);
190 if (!AP.apWants(req)) {
191 // A browser hit a post's AP note URL → send them to the human post page
192 // (which shows the post + its "from the fediverse" reactions).
193 return res.redirect(302, note.url || (baseUrl(req) + '/'));
194 }
[d3b9f68]195 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
[6bd25d1]196});
197
[d7526bd]198// ── Replies collection ── lets remote servers fetch a post's whole thread.
199router.get('/ap/notes/:id/replies', (req, res) => {
200 const base = baseUrl(req);
201 const items = AP.getReplyUris(base, req.params.id);
202 AP.sendAP(res, {
[d3b9f68]203 '@context': AP.AP_CONTEXT,
[d7526bd]204 id: `${base}/ap/notes/${req.params.id}/replies`,
205 type: 'OrderedCollection',
206 totalItems: items.length,
207 orderedItems: items,
208 });
209});
210
211// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
212router.get('/.well-known/nodeinfo', (req, res) => {
213 res.type('application/json');
214 res.set('Cache-Control', 'public, max-age=3600');
215 res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
216});
217router.get('/nodeinfo/2.1', (req, res) => {
218 let users = 0; let posts = 0;
[f2796d3]219 // "users" = public AP actors (sites), not the admin/member account rows.
220 try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
[d7526bd]221 try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
222 res.type('application/json; charset=utf-8');
223 res.set('Cache-Control', 'public, max-age=600');
224 res.send(JSON.stringify({
225 version: '2.1',
226 software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
227 protocols: ['activitypub'],
228 services: { inbound: [], outbound: [] },
229 openRegistrations: false,
230 usage: { users: { total: users }, localPosts: posts },
231 metadata: { nodeName: 'Klonkt' },
232 }));
233});
234
[5bf63b7]235// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
236const apJson = express.json({
237 type: ['application/activity+json', 'application/ld+json', 'application/json'],
238 limit: '1mb',
239 verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
240});
[75ab393]241router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
[5bf63b7]242 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
243 catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
[6bd25d1]244});
245
[dd568e7]246// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
247// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
248// the normal delivery machinery. The token is scoped to one user+site (OAuth
249// consent), so it must match the slug in the URL. (Declared after apJson, which
250// this shares with the inbox handler.)
251router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
252 const auth = OAuth.verifyBearer(req.headers.authorization);
253 if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
254 if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
255 if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
256
257 const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
258 if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
259 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
260 if (out.status === 201 && out.url) res.set('Location', out.url);
261 return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url });
262});
263
[6bd25d1]264export default router;
Note: See TracBrowser for help on using the repository browser.