source: Klonkt/src/routes/activitypub.js@ 0202104

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

Beschikbaarheid van guardians (FEP-633c 3.6): away, dormant, lapse

De Klonkt-kant van het beschikbaarheidsvoorstel, nagemaakt zoals eerst in de
daemon gevalideerd (shaer-8z7): dezelfde toestanden, dezelfde regels, dezelfde
weigeringen. De spiegel-tests dragen dezelfde namen als de daemon-tests, zodat
drift tussen de twee backends opvalt als een falende test met dezelfde woorden.

De kern is guardianship/availability.js: drie toestanden per (ward, guardian),
met als regel boven alles dat een antwoord alles herstelt, tot en met een
lopende lapse. Elke geverifieerde inbox-activiteit en elke C2S-handeling van
een guardian herstelt hem en annuleert een lapse tegen hem, nog voor er naar de
activiteit gekeken wordt. Bewust achter de handtekening-poort: een ongeverifieerde
bewering oma te zijn mag oma niet wakker maken.

Afwezig komt binnen over beide wegen: S2S als directe note met shaer:away en
endTime van een guardian elders (het gewone geval), en C2S als een guardian
hier zich afmeldt; die note draagt de marker mee naar wards elders en wordt
voor wards op deze instance direct toegepast, want een lokale inbox ontvangt
zijn eigen bezorging niet. Zonder (toekomstig) einde faalt het luid met 400,
precies zoals de daemon weigert.

Slapend volgt alleen uit onbeantwoorde direct geadresseerde verzoeken; de
follow-gating registreert die nu als bewijs. De markering notificeert verplicht
via protocol en de 6-handle, eenmalig op de overgang, centraal bedraad zodat
elke plek waar een promotie kan gebeuren hetzelfde notificeert.

De drempel van 3.5 rekent voortaan over de beschikbare set: de follow-quorums
en de gated settings allebei. De test die het waarom draagt: vijf guardians van
wie twee weg zijn gaven een drempel van drie die de twee levenden nooit haalden;
over de beschikbare set beslissen zij weer.

De lapse loopt over dezelfde draden als de gated settings: een Offer van
shaer:Lapse opent op de server van het kind, Accept/Reject stemt, het venster
loopt altijd vol, en de voltooiing verwijdert de relatie met de
nooit-leeg-grens uit 3.4 als tweede slot eronder. De offers-queue draagt de
lopende lapses en de nieuwe owner-only guardians-queue de beschikbaarheid, in
precies de vorm die de daemon serveert, dus de Shaer-apps van gisteren werken
zonder wijziging.

Changed files:
src/config/database.js

  • tabellen ap_guardian_attention, ap_attention_requests, ap_lapses
  • kolom ap_outbox.away_until

src/services/guardianship/handshake.js

  • Offer van shaer:Lapse (S2S en C2S), lapse-stemmen op Accept/Reject, one-answer op elke C2S-handeling

src/services/guardianship/gated.js

  • tally en voortgang over de beschikbare set; een stem is een antwoord

src/services/guardianship/notes.js

  • awayProps: shaer:away plus endTime op de uitgaande directe note

src/services/guardianship/delivery.js

  • away_until door het directe pad heen

src/services/guardianship/queues.js

  • guardiansCollection; offersCollection draagt de lapses

src/services/guardianship/index.js

  • exports

src/services/ActivityPubService.js

  • one-answer achter de handtekening-poort
  • away-ingest op het mention-pad en het C2S-directe pad
  • dormancy-bewijs op de follow-gating; quorum over de beschikbare set
  • de notificatieplicht van 3.6.2, een keer bedraad
  • buildReplyNote draagt awayProps

src/routes/activitypub.js

  • owner-only route /queues/guardians

src/routes/guardian.js

  • dashboard-besluit is een antwoord; quorum over de beschikbare set

src/services/guardianship/relations.js

  • guardians-queue aangekondigd in shaer:queues

test/activitypub-as2.test.js

  • guardians toegevoegd aan de queue-sleutels

New file:
src/services/guardianship/availability.js

  • de toestandsmachine, de lapse en de endTime-parser

test/availability.test.js

  • veertien spiegel-tests van de daemon, tot en met de volle lapse-flow over de S2S-draad en het vijf-guardians-rekenvoorbeeld

remarks: de PWA toont de beschikbaarheid nog niet (chips in het paneel per
kind en een lapse-kaart komen apart); de echte kruis-implementatie-testbank
blijft open op shaer-6d9. Klonkt heeft geen pinbare klok zoals de daemon; de
tests dateren bewijs terug in plaats van de tijd vooruit te zetten, en dat
staat er als kanttekening bij. Niet uitgerold.

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

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