source: Klonkt/src/routes/activitypub.js@ 742ba7e

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

Externe embeds: thumbnail-only, en gated aan de serverkant

Fase 2 van shaer-277. Een note zonder fediverse-quote maar met een externe link
levert nu een embed-kaart op, via dezelfde resolver (oEmbed of een bekende
provider). Twee besluiten zitten erin verankerd:

THUMBNAIL-ONLY. Nooit de iframe van de aanbieder. Een willekeurig
derde-partij-frame in een kindveilige app is een gat dat je niet meer dicht
krijgt, dus de kaart draagt een afbeelding en een titel en niets uitvoerbaars.

GATED AAN DE SERVERKANT. Externe embeds zijn een gated feature: de wereld van
een ward buiten de fediverse is aan de guardians. Cruciaal is WAAR die gate zit:
bij het serialiseren, niet in de client. Een embed die de client alleen maar
verbergt, is wel degelijk al op het toestel afgeleverd. Staat de gate dicht, dan
gaat shaer:embed simpelweg niet mee.

De regel zelf (externalEmbedsAllowed) is puur en apart getest: null = auto, wat
uit staat voor een ward en aan voor ieder ander; een expliciet guardian-besluit
wint beide kanten op.

Changed files:
src/config/database.js

  • ap_timeline.embed_json en sites.external_embeds (NULL = auto)

src/services/guardianship/notes.js

  • externalEmbedsAllowed(setting, isWard), puur en geexporteerd

src/services/guardianship/index.js

  • doorgeexporteerd

src/services/ActivityPubService.js

  • resolveExternalEmbed + firstExternalUrl + timelineEmbed
  • inbound: geen quote maar wel een externe link -> embed resolven (out of band)

src/routes/activitypub.js

  • inbox-read past de gate toe en serveert shaer:embed alleen als die open staat

New file:
test/external-embeds.test.js

  • 5 tests: de auto-regel, het expliciete besluit, link-selectie (mentions en hashtags overslaan), en dat een kaart zonder url geen kaart is

remarks: 205 tests groen. Nog te doen: de clients laten shaer:embed renderen met
de bestaande QuoteCard, en een UI voor guardians om de gate te bedienen (hoort
bij shaer-3kp).

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

  • Property mode set to 100644
File size: 22.3 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
138// ── Inbox read (owner only, AP C2S) ───────────────────────────────
139// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
140// scoped to this site) reads recent inbound posts (the timeline: accounts
141// they follow) as Create(Note) items, so an app (Shaer) can build a unified
142// feed. Anyone else gets 403; the inbox stays write-only for the public.
143router.get('/ap/users/:slug/inbox', (req, res) => {
144 const auth = OAuth.verifyBearer(req.headers.authorization);
145 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
146 const base = baseUrl(req);
147 // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
148 // world outside the fediverse is the guardians' call. The gate is applied
149 // here, at serialisation: a blocked embed is never sent, because an embed the
150 // client merely hides has still been delivered to the device.
151 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
152 const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
153 const items = AP.getTimeline(auth.site.slug, 60).map((t) => ({
154 id: `${t.id}#create`,
155 type: 'Create',
156 actor: t.author_uri,
157 published: t.published || t.created_at || undefined,
158 object: {
159 id: t.id,
160 type: 'Note',
161 attributedTo: t.author_uri,
162 content: t.content,
163 url: t.url || undefined,
164 published: t.published || t.created_at || undefined,
165 sensitive: !!t.nsfw,
166 summary: t.cw || undefined,
167 // Friends' media travels along (media_json → AS2 attachment), so the
168 // client renders their images/audio like own outbox posts.
169 attachment: AP.timelineAttachments(t.media_json),
170 // The note's preserved tags, so the client can render them: FEP-9098
171 // Emoji tags (:shortcode: → image) and FEP-e232 Link tags (quotes /
172 // inline object references). Combined into one `tag` array; omitted
173 // when the note has neither.
174 tag: (() => {
175 const tags = [...(AP.timelineEmojis(t.emoji_json) || []), ...(AP.timelineObjectLinks(t.link_json) || [])];
176 return tags.length ? tags : undefined;
177 })(),
178 // FEP-044f: the resolved quoted post (author + content), so the client
179 // renders an embedded quote card instead of a bare link. Omitted when the
180 // note has no quote or the quoted post could not be resolved.
181 'shaer:quote': AP.timelineQuote(t.quote_json),
182 // The post author's display info (name / @handle / avatar), so every card
183 // gets a byline header like the quote card. attributedTo stays the bare
184 // actor URI; this is the resolved presentation Klonkt already stored.
185 'shaer:author': (t.author_name || t.author_handle || t.author_icon) ? {
186 name: t.author_name || undefined, handle: t.author_handle || undefined,
187 icon: t.author_icon || undefined, url: t.author_url || undefined,
188 // FEP-9098: emojis in the display name (":shortcode:"), if any.
189 emojis: (() => { try { return t.author_emoji_json ? JSON.parse(t.author_emoji_json) : undefined; } catch { return undefined; } })(),
190 } : undefined,
191 // When a followed account boosted this, who did ("X boosted"). Omitted for
192 // ordinary posts.
193 'shaer:booster': (t.reblog_name || t.reblog_handle || t.reblog_icon) ? {
194 name: t.reblog_name || undefined, handle: t.reblog_handle || undefined,
195 icon: t.reblog_icon || undefined,
196 // FEP-9098: emojis in the booster's display name (":shortcode:"), if any.
197 emojis: (() => { try { return t.reblog_emoji_json ? JSON.parse(t.reblog_emoji_json) : undefined; } catch { return undefined; } })(),
198 } : undefined,
199 // Whether THIS account already liked/boosted the note, so the app's
200 // detail-view buttons show the current state (and can toggle/undo).
201 'shaer:liked': !!t.liked,
202 'shaer:boosted': !!t.boosted,
203 // An external (non-fediverse) embed, thumbnail-only and never an iframe.
204 // Omitted entirely when the gate is closed (see above).
205 'shaer:embed': embedsAllowed ? AP.timelineEmbed(t.embed_json) : undefined,
206 },
207 }));
208 AP.sendAP(res, {
209 '@context': AP.AP_CONTEXT,
210 id: `${base}/ap/users/${auth.site.slug}/inbox`,
211 type: 'OrderedCollection',
212 totalItems: items.length,
213 orderedItems: items,
214 });
215});
216
217// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
218// The actor advertises endpoints.uploadMedia; this implements it. A bearer
219// scoped to this site uploads one image/audio/video (multipart field "file",
220// AP convention) into the same store the reply editor uses, and gets back
221// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
222const AP_MEDIA_DIR = path.resolve(
223 process.env.REPLY_MEDIA_PATH ||
224 path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'storage', 'media', 'reply-media')
225);
226fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
227const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
228const apMediaUpload = multer({
229 storage: multer.diskStorage({
230 destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
231 filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
232 }),
233 limits: { fileSize: 32 * 1024 * 1024 },
234 fileFilter: (req, file, cb) => {
235 const ext = path.extname(file.originalname || '').toLowerCase();
236 if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
237 cb(null, true);
238 },
239});
240router.post('/ap/users/:slug/uploadMedia', (req, res) => {
241 const auth = OAuth.verifyBearer(req.headers.authorization);
242 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
243 apMediaUpload.single('file')(req, res, (err) => {
244 if (err) return res.status(400).json({ error: err.message });
245 if (!req.file) return res.status(400).json({ error: 'No file' });
246 const mime = String(req.file.mimetype || '');
247 if (!/^(image|audio|video)\//.test(mime)) {
248 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
249 return res.status(400).json({ error: 'Media must be an image, audio or video file' });
250 }
251 res.status(201).json({
252 url: '/media/reply-media/' + req.file.filename,
253 mediaType: mime,
254 name: String(req.file.originalname || '').slice(0, 120),
255 });
256 });
257});
258
259// ── Followers (count-only public, full for the owner) ─────────────
260// A C2S bearer scoped to this site (the account owner) gets the real actor
261// URIs so their own client can build a friends list; everyone else gets the
262// count only (privacy).
263// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
264// Returns true and sets the response headers when the owner asked for it.
265function wantsEnriched(req, res) {
266 res.set('Vary', 'Prefer'); // enriched and bare are two representations
267 if (AP.prefersEnriched(req.get('Prefer'))) {
268 res.set('Preference-Applied', 'return=representation');
269 return true;
270 }
271 return false;
272}
273
274router.get('/ap/users/:slug/followers', (req, res) => {
275 const auth = OAuth.verifyBearer(req.headers.authorization);
276 const owner = auth && auth.site.slug === req.params.slug;
277 const site = owner ? auth.site : publicSite(req.params.slug);
278 if (!site) return res.status(404).end();
279 if (owner) {
280 const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
281 // Default = bare references; enrich only when the client asks (FEP-9876).
282 const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
283 return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items));
284 }
285 const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
286 AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
287});
288
289// ── Following (count-only public, full for the owner) ─────────────
290router.get('/ap/users/:slug/following', (req, res) => {
291 const auth = OAuth.verifyBearer(req.headers.authorization);
292 const owner = auth && auth.site.slug === req.params.slug;
293 const site = owner ? auth.site : publicSite(req.params.slug);
294 if (!site) return res.status(404).end();
295 if (owner) {
296 const enrich = wantsEnriched(req, res); // FEP-9876 opt-in
297 let items = [];
298 try {
299 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);
300 items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
301 } catch { /* table may not exist */ }
302 return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items));
303 }
304 let n = 0;
305 try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
306 AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n));
307});
308
309// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
310router.get('/ap/users/:slug/featured', (req, res) => {
311 const site = publicSite(req.params.slug);
312 if (!site) return res.status(404).end();
313 // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
314 // last-processed-first). So we emit it reversed (lowest pin priority first,
315 // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
316 const posts = db.prepare(
317 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, published_at, created_at
318 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
319 AND pinned IS NOT NULL AND pinned > 0
320 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
321 ).all(site.id);
322 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts));
323});
324
325// ── Note ──────────────────────────────────────────────────────────
326router.get('/ap/notes/:id', (req, res) => {
327 const post = db.prepare(
328 "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
329 ).get(req.params.id);
330 if (!post) {
331 // Could be one of OUR outbound replies (ap_outbox), not a post.
332 const note = AP.getOutboxNote(baseUrl(req), req.params.id);
333 if (!note) return res.status(404).end();
334 if (!AP.apWants(req)) {
335 // A browser hit a reply's AP URL → send them to the source it replies to
336 // (where the post + its reactions live), falling back to the site home.
337 const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
338 ? note.inReplyTo : (baseUrl(req) + '/');
339 return res.redirect(302, src);
340 }
341 return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
342 }
343 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
344 if (!site) return res.status(404).end();
345 const note = AP.buildNote(baseUrl(req), site, post);
346 if (!AP.apWants(req)) {
347 // A browser hit a post's AP note URL → send them to the human post page
348 // (which shows the post + its "from the fediverse" reactions).
349 return res.redirect(302, note.url || (baseUrl(req) + '/'));
350 }
351 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
352});
353
354// ── Replies collection ── lets remote servers fetch a post's whole thread.
355router.get('/ap/notes/:id/replies', (req, res) => {
356 const base = baseUrl(req);
357 const items = AP.getReplyUris(base, req.params.id);
358 AP.sendAP(res, {
359 '@context': AP.AP_CONTEXT,
360 id: `${base}/ap/notes/${req.params.id}/replies`,
361 type: 'OrderedCollection',
362 totalItems: items.length,
363 orderedItems: items,
364 });
365});
366
367// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
368router.get('/.well-known/nodeinfo', (req, res) => {
369 res.type('application/json');
370 res.set('Cache-Control', 'public, max-age=3600');
371 res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
372});
373router.get('/nodeinfo/2.1', (req, res) => {
374 let users = 0; let posts = 0;
375 // "users" = public AP actors (sites), not the admin/member account rows.
376 try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
377 try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
378 res.type('application/json; charset=utf-8');
379 res.set('Cache-Control', 'public, max-age=600');
380 res.send(JSON.stringify({
381 version: '2.1',
382 software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
383 protocols: ['activitypub'],
384 services: { inbound: [], outbound: [] },
385 openRegistrations: false,
386 usage: { users: { total: users }, localPosts: posts },
387 metadata: { nodeName: 'Klonkt' },
388 }));
389});
390
391// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
392const apJson = express.json({
393 type: ['application/activity+json', 'application/ld+json', 'application/json'],
394 limit: '1mb',
395 verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
396});
397router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
398 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
399 catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
400});
401
402// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
403// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
404// the normal delivery machinery. The token is scoped to one user+site (OAuth
405// consent), so it must match the slug in the URL. (Declared after apJson, which
406// this shares with the inbox handler.)
407router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
408 const auth = OAuth.verifyBearer(req.headers.authorization);
409 if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
410 if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
411 if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
412
413 const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
414 if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
415 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
416 if (out.status === 201 && out.url) res.set('Location', out.url);
417 return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url });
418});
419
420export default router;
Note: See TracBrowser for help on using the repository browser.