source: Klonkt/src/routes/activitypub.js@ 6dd26e5

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

De audio-tegel opgevrolijkt: golfvorm als thumbnail en een echte titel

Een audiopost uit Shaer was op Klonkt een kale gradient met (untitled)
(Robins schermafdruk, 30-7). Twee dingen tegelijk gefixt.

De thumbnail: de upload-leg tekent bij audio nu een golfvorm met de
gebundelde ffmpeg (showwavespic, wit op transparant) naar
<naam>.poster.png, precies zoals video zijn stilstaand beeld krijgt. Die
golfvorm rijdt mee als data-poster op de audio-tag (voor de tegels), in
c2s_attachments en als AS2-icon op het gefedereerde Audio-attachment. De
tegel toont hem OVER de eigen hue-gradient, dus elke audiopost houdt
zijn kleur en krijgt zijn vorm.

De titel: C2S-posts hebben geen titel, dus elke Shaer-post toonde
(untitled). Tegels en kaarten leiden de titel nu af: eerst de eigen
woorden van de post (afgekapt op 64), anders een vriendelijk typelabel
(muzieknoot audio, pijl video), en pas als er echt niets is (untitled).
Een tegel die zijn beeld al toont forceert geen label meer.

Changed files:
src/routes/activitypub.js

  • uploadMedia: audio/* -> showwavespic-golfvorm naar .poster.png (best-effort, zelfde patroon als de videoposter)

src/services/ActivityPubService.js

  • c2sCreatePost: posterExt per type (video .jpg, audio .png); de audio-tag krijgt data-poster

src/views/partials/post-tile.ejs

  • audio-detectie + golfvorm over de gradient; titelcascade (titel -> tekst -> typelabel)

src/views/partials/post-card.ejs

  • zelfde spiegel: golfvorm als cover met eigen accent-gradient, zelfde titelcascade

src/assets/css/style.css

  • .grid-tile-wave: contain, padding, transparant op de gradient

test/c2s-compose.test.js

  • audio-golfvormketen: data-poster op de tag, poster in de store, icon op het gefedereerde attachment

remarks: bestaande audioposts kregen hun upload voor deze code en hebben
dus nog geen golfvorm-bestand; die tonen nu wel het muzieknoot-label. Een
verse audiopost laat het hele effect zien. ffmpeg-commando en beide
templates hier lokaal gesmoke-test (RGBA-png, render ok), 345 tests
groen.

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

  • Property mode set to 100644
File size: 29.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';
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 (30-7): who is asking decides what they see.
85 // - the owner's own app (bearer) and a verified accepted follower or
86 // guardian get the friends-only history too, so a NEW friend's backfill
87 // brings the past along (Robins besluit: vrienden krijgen de
88 // geschiedenis mee);
89 // - a verified caller this instance BLOCKS gets an EMPTY collection, not
90 // even the public set: a block is a closed door, and a signed fetch is
91 // the caller knocking with their name on it;
92 // - everyone else gets the public collection, exactly as before.
93 const bearer = OAuth.verifyBearer(req.headers.authorization);
94 let verifiedActor = null;
95 if (!bearer && req.headers['signature']) {
96 const verified = await AP.verifyRequest(req).catch(() => null);
97 verifiedActor = verified && verified.id;
98 }
99 const audience = AP.outboxAudience(req.params.slug, {
100 bearerSlug: bearer ? bearer.site.slug : null,
101 verifiedActor,
102 });
103 if (audience === 'blocked') {
104 return AP.sendAP(res, AP.buildOutbox(baseUrl(req), site, []), 'private, no-store');
105 }
106 const fanClause = audience === 'friend' ? '' : "AND (fan_only IS NULL OR fan_only = 0)";
107 const posts = db.prepare(
108 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
109 FROM posts WHERE site_id = ? AND status = 'published' ${fanClause}
110 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
111 ).all(site.id);
112 const ob = AP.buildOutbox(baseUrl(req), site, posts);
113 if (audience === 'friend') {
114 // The owner's app builds its feed from this leg, and every note here is
115 // by the site itself: give it the same `shaer:author` byline the timeline
116 // entries carry, so your own cards get a header too (avatar + name).
117 const me = AP.selfAuthor(baseUrl(req), site);
118 for (const it of ob.orderedItems) {
119 if (it && it.object && typeof it.object === 'object') it.object['shaer:author'] = me;
120 }
121 }
122 AP.sendAP(res, ob, audience === 'friend' ? 'private, no-store' : undefined);
123});
124
125// ── Blocked collection (owner only, AP §5.6) ──────────────────────
126// The server blocklist is the source of truth for Shaer's "in Orbit":
127// clients read it here instead of keeping their own state. Actor-kind
128// blocks only (domain blocks are instance policy, not an Orbit member).
129router.get('/ap/users/:slug/blocked', (req, res) => {
130 const auth = OAuth.verifyBearer(req.headers.authorization);
131 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
132 const base = baseUrl(req);
133 const items = AP.listBlocks(auth.site.slug)
134 .filter((b) => b.kind === 'actor')
135 .map((b) => b.target);
136 AP.sendAP(res, {
137 '@context': AP.AP_CONTEXT,
138 id: `${base}/ap/users/${auth.site.slug}/blocked`,
139 type: 'OrderedCollection',
140 totalItems: items.length,
141 orderedItems: items,
142 });
143});
144
145// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
146// The dashboard collections the Shaer clients read: pending adoption offers,
147// gated follows (empty in Klonkt for now) and the guardian's wards. Same
148// contract as the Shaer test daemon.
149function queueRoute(name, build) {
150 router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
151 const auth = OAuth.verifyBearer(req.headers.authorization);
152 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
153 const base = baseUrl(req);
154 const me = `${base}/ap/users/${auth.site.slug}`;
155 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
156 });
157}
158queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
159queueRoute('follows', (id) => Guardianship.followsCollection(id));
160queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
161// Availability (FEP-633c 3.6.1) is never public: the ward reads its
162// guardians' real states here and nowhere else.
163queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
164
165// ── Inbox read (owner only, AP C2S) ───────────────────────────────
166// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
167// scoped to this site) reads recent inbound posts (the timeline: accounts
168// they follow) as Create(Note) items, so an app (Shaer) can build a unified
169// feed. Anyone else gets 403; the inbox stays write-only for the public.
170router.get('/ap/users/:slug/inbox', (req, res) => {
171 const auth = OAuth.verifyBearer(req.headers.authorization);
172 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
173 const base = baseUrl(req);
174 // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
175 // world outside the fediverse is the guardians' call. The gate is applied
176 // here, at serialisation: a blocked embed is never sent, because an embed the
177 // client merely hides has still been delivered to the device.
178 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
179 const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
180 // The heavier sibling (5.6): may a third party's PLAYER run inside the app,
181 // and may a link hand the child over to a browser? Both are the guardians'
182 // call, both default to off for a ward, and both need the preview gate open
183 // first: you cannot play, or follow, what you may not see. Served here so
184 // the app knows what it may offer instead of guessing.
185 const playbackAllowed = embedsAllowed
186 && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
187 const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
188 id: `${t.id}#create`,
189 type: 'Create',
190 actor: t.author_uri,
191 published: t.published || t.created_at || undefined,
192 object: {
193 id: t.id,
194 type: 'Note',
195 attributedTo: t.author_uri,
196 content: t.content,
197 url: t.url || undefined,
198 published: t.published || t.created_at || undefined,
199 sensitive: !!t.nsfw,
200 summary: t.cw || undefined,
201 // Friends' media travels along (media_json → AS2 attachment), so the
202 // client renders their images/audio like own outbox posts.
203 attachment: AP.timelineAttachments(t.media_json),
204 // The note's preserved tags, so the client can render them: FEP-9098
205 // Emoji tags (:shortcode: → image) and FEP-e232 Link tags (quotes /
206 // inline object references). Combined into one `tag` array; omitted
207 // when the note has neither.
208 tag: (() => {
209 const tags = [...(AP.timelineEmojis(t.emoji_json) || []), ...(AP.timelineObjectLinks(t.link_json) || [])];
210 return tags.length ? tags : undefined;
211 })(),
212 // FEP-044f: the resolved quoted post (author + content), so the client
213 // renders an embedded quote card instead of a bare link. Omitted when the
214 // note has no quote or the quoted post could not be resolved.
215 'shaer:quote': AP.timelineQuote(t.quote_json),
216 // The post author's display info (name / @handle / avatar), so every card
217 // gets a byline header like the quote card. attributedTo stays the bare
218 // actor URI; this is the resolved presentation Klonkt already stored.
219 'shaer:author': (t.author_name || t.author_handle || t.author_icon) ? {
220 name: t.author_name || undefined, handle: t.author_handle || undefined,
221 icon: t.author_icon || undefined, url: t.author_url || undefined,
222 // FEP-9098: emojis in the display name (":shortcode:"), if any.
223 emojis: (() => { try { return t.author_emoji_json ? JSON.parse(t.author_emoji_json) : undefined; } catch { return undefined; } })(),
224 } : undefined,
225 // When a followed account boosted this, who did ("X boosted"). Omitted for
226 // ordinary posts.
227 'shaer:booster': (t.reblog_name || t.reblog_handle || t.reblog_icon) ? {
228 name: t.reblog_name || undefined, handle: t.reblog_handle || undefined,
229 icon: t.reblog_icon || undefined,
230 // FEP-9098: emojis in the booster's display name (":shortcode:"), if any.
231 emojis: (() => { try { return t.reblog_emoji_json ? JSON.parse(t.reblog_emoji_json) : undefined; } catch { return undefined; } })(),
232 } : undefined,
233 // Whether THIS account already liked/boosted the note, so the app's
234 // detail-view buttons show the current state (and can toggle/undo).
235 'shaer:liked': !!t.liked,
236 'shaer:boosted': !!t.boosted,
237 // An external (non-fediverse) embed, thumbnail-only and never an iframe.
238 // Omitted entirely when the gate is closed (see above).
239 // Carries shaer:playerUrl only when the playback gate is open too.
240 'shaer:embed': embedsAllowed ? AP.timelineEmbed(t.embed_json, { playback: playbackAllowed }) : undefined,
241 },
242 }));
243 // The direct notes addressed to this account: a plain DM, a guardian's wave
244 // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
245 // they are not in the timeline; without them the app's Berichten shows only
246 // what you said yourself. Same shape as a post, so one parser handles both.
247 const me = AP.actorId(base, auth.site.slug);
248 const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
249 const messages = AP.getDirectMessages(auth.site.slug, 60).map((m) => ({
250 id: `${m.object_uri}#create`,
251 type: 'Create',
252 actor: m.actor_uri,
253 published: AP.isoStamp(m.published || m.created_at),
254 object: {
255 id: m.object_uri,
256 type: 'Note',
257 attributedTo: m.actor_uri,
258 content: AP.stripLeadingMentions(m.content),
259 url: m.note_url || undefined,
260 published: AP.isoStamp(m.published || m.created_at),
261 // Addressed to us and to nobody we know of: the other recipients of a
262 // note to several people are not ours to see, so we serve what we know.
263 to: [me],
264 // The Mention is how the client recognises itself as the addressee and
265 // groups the note into a conversation. No FEP-e232 link tags here: a
266 // mention row keeps the resolved quote, not the raw tags.
267 tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
268 attachment: AP.timelineAttachments(m.media_json),
269 // FEP-633c: what kind of message this is. The wave is a gentle nudge from
270 // a guardian; the help request is the buoy. Both render differently.
271 'shaer:wave': m.wave ? true : undefined,
272 'shaer:helpRequest': m.help_request ? true : undefined,
273 'shaer:quote': AP.timelineQuote(m.quote_json),
274 'shaer:author': (m.actor_name || m.actor_handle || m.actor_icon) ? {
275 name: m.actor_name || undefined, handle: m.actor_handle || undefined,
276 icon: m.actor_icon || undefined, url: m.actor_url || undefined,
277 emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
278 } : undefined,
279 'shaer:embed': embedsAllowed ? AP.timelineEmbed(m.embed_json, { playback: playbackAllowed }) : undefined,
280 },
281 }));
282 // Newest first over both, so the app can keep treating this as one feed.
283 const items = [...posts, ...messages].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
284 AP.sendAP(res, {
285 '@context': AP.AP_CONTEXT,
286 id: `${base}/ap/users/${auth.site.slug}/inbox`,
287 type: 'OrderedCollection',
288 // What this account may do with what is in here (FEP-633c 5.6). Owner-only
289 // by construction, and never on the public actor document: it says
290 // something about a child, and only the child and its guardians need it.
291 'shaer:capabilities': {
292 'shaer:externalEmbeds': embedsAllowed,
293 'shaer:externalPlayback': playbackAllowed,
294 // Leaving the app is the same decision as playing inside it: with the
295 // gate shut a link is shown but not followed, so the door is closed too
296 // and not just the picture over it.
297 'shaer:externalLinks': playbackAllowed,
298 },
299 totalItems: items.length,
300 orderedItems: items,
301 });
302});
303
304// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
305// The actor advertises endpoints.uploadMedia; this implements it. A bearer
306// scoped to this site uploads one image/audio/video (multipart field "file",
307// AP convention) into the same store the reply editor uses, and gets back
308// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
309const AP_MEDIA_DIR = path.resolve(
310 process.env.REPLY_MEDIA_PATH ||
311 path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'storage', 'media', 'reply-media')
312);
313fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
314const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
315const apMediaUpload = multer({
316 storage: multer.diskStorage({
317 destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
318 filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
319 }),
320 limits: { fileSize: 32 * 1024 * 1024 },
321 fileFilter: (req, file, cb) => {
322 const ext = path.extname(file.originalname || '').toLowerCase();
323 if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
324 cb(null, true);
325 },
326});
327router.post('/ap/users/:slug/uploadMedia', (req, res) => {
328 const auth = OAuth.verifyBearer(req.headers.authorization);
329 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
330 apMediaUpload.single('file')(req, res, (err) => {
331 if (err) return res.status(400).json({ error: err.message });
332 if (!req.file) return res.status(400).json({ error: 'No file' });
333 const mime = String(req.file.mimetype || '');
334 if (!/^(image|audio|video)\//.test(mime)) {
335 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
336 return res.status(400).json({ error: 'Media must be an image, audio or video file' });
337 }
338 // A video gets a poster frame next to it (shaer-zowq), best-effort and
339 // out of band: ffmpeg pulls one frame at 1s into <name>.poster.jpg. On a
340 // machine without ffmpeg nothing happens and nothing breaks; the clients
341 // fall back to extracting a frame natively.
342 if (mime.startsWith('video/')) {
343 // The bundled static build (ffmpeg-static) does the work, exactly like
344 // VideoCoverService and AudioTranscoder already do: Klonkt SHIPS its
345 // ffmpeg (Robins opmerking, 30-7), so nothing needs installing on any
346 // machine. Soft dependency + best-effort: absent stays silent, and
347 // FFMPEG_PATH can still override for an operator who wants a newer one.
348 Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
349 const bin = process.env.FFMPEG_PATH || ff.default;
350 if (!bin) return;
351 const poster = req.file.path + '.poster.jpg';
352 execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-ss', '1', '-i', req.file.path, '-frames:v', '1', '-vf', "scale='min(640,iw)':-2", poster],
353 { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] poster failed:', e.message); });
354 }).catch(() => { /* never blocks the upload */ });
355 }
356 // Audio gets the same courtesy (Robins vraag, 30-7: vrolijk de kale
357 // audio-tegel op): ffmpeg draws the waveform into <name>.poster.png.
358 // White on transparent, so the tile's own gradient stays the backdrop
359 // and every audio post keeps its own hue.
360 if (mime.startsWith('audio/')) {
361 Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
362 const bin = process.env.FFMPEG_PATH || ff.default;
363 if (!bin) return;
364 const poster = req.file.path + '.poster.png';
365 execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-i', req.file.path, '-filter_complex', 'showwavespic=s=800x256:colors=white', '-frames:v', '1', poster],
366 { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] waveform failed:', e.message); });
367 }).catch(() => { /* never blocks the upload */ });
368 }
369 res.status(201).json({
370 url: '/media/reply-media/' + req.file.filename,
371 mediaType: mime,
372 name: String(req.file.originalname || '').slice(0, 120),
373 });
374 });
375});
376
377// ── Followers (count-only public, full for the owner) ─────────────
378// A C2S bearer scoped to this site (the account owner) gets the real actor
379// URIs so their own client can build a friends list; everyone else gets the
380// count only (privacy).
381// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
382// Returns true and sets the response headers when the owner asked for it.
383function wantsEnriched(req, res) {
384 res.set('Vary', 'Prefer'); // enriched and bare are two representations
385 if (AP.prefersEnriched(req.get('Prefer'))) {
386 res.set('Preference-Applied', 'return=representation');
387 return true;
388 }
389 return false;
390}
391
392router.get('/ap/users/:slug/followers', (req, res) => {
393 const auth = OAuth.verifyBearer(req.headers.authorization);
394 const owner = auth && auth.site.slug === req.params.slug;
395 const site = owner ? auth.site : publicSite(req.params.slug);
396 if (!site) return res.status(404).end();
397 if (owner) {
398 const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
399 // Default = bare references; enrich only when the client asks (FEP-9876).
400 const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
401 return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items));
402 }
403 const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
404 AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
405});
406
407// ── Following (count-only public, full for the owner) ─────────────
408router.get('/ap/users/:slug/following', (req, res) => {
409 const auth = OAuth.verifyBearer(req.headers.authorization);
410 const owner = auth && auth.site.slug === req.params.slug;
411 const site = owner ? auth.site : publicSite(req.params.slug);
412 if (!site) return res.status(404).end();
413 if (owner) {
414 const enrich = wantsEnriched(req, res); // FEP-9876 opt-in
415 let items = [];
416 try {
417 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);
418 items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
419 } catch { /* table may not exist */ }
420 return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items));
421 }
422 let n = 0;
423 try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
424 AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n));
425});
426
427// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
428router.get('/ap/users/:slug/featured', (req, res) => {
429 const site = publicSite(req.params.slug);
430 if (!site) return res.status(404).end();
431 // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
432 // last-processed-first). So we emit it reversed (lowest pin priority first,
433 // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
434 const posts = db.prepare(
435 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
436 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
437 AND pinned IS NOT NULL AND pinned > 0
438 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
439 ).all(site.id);
440 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts));
441});
442
443// ── Note ──────────────────────────────────────────────────────────
444router.get('/ap/notes/:id', (req, res) => {
445 const post = db.prepare(
446 "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
447 ).get(req.params.id);
448 if (!post) {
449 // Could be one of OUR outbound replies (ap_outbox), not a post.
450 const note = AP.getOutboxNote(baseUrl(req), req.params.id);
451 if (!note) return res.status(404).end();
452 if (!AP.apWants(req)) {
453 // A browser hit a reply's AP URL → send them to the source it replies to
454 // (where the post + its reactions live), falling back to the site home.
455 const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
456 ? note.inReplyTo : (baseUrl(req) + '/');
457 return res.redirect(302, src);
458 }
459 return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
460 }
461 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
462 if (!site) return res.status(404).end();
463 const note = AP.buildNote(baseUrl(req), site, post);
464 if (!AP.apWants(req)) {
465 // A browser hit a post's AP note URL → send them to the human post page
466 // (which shows the post + its "from the fediverse" reactions).
467 return res.redirect(302, note.url || (baseUrl(req) + '/'));
468 }
469 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
470});
471
472// ── Replies collection ── lets remote servers fetch a post's whole thread.
473router.get('/ap/notes/:id/replies', (req, res) => {
474 const base = baseUrl(req);
475 const items = AP.getReplyUris(base, req.params.id);
476 AP.sendAP(res, {
477 '@context': AP.AP_CONTEXT,
478 id: `${base}/ap/notes/${req.params.id}/replies`,
479 type: 'OrderedCollection',
480 totalItems: items.length,
481 orderedItems: items,
482 });
483});
484
485// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
486router.get('/.well-known/nodeinfo', (req, res) => {
487 res.type('application/json');
488 res.set('Cache-Control', 'public, max-age=3600');
489 res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
490});
491router.get('/nodeinfo/2.1', (req, res) => {
492 let users = 0; let posts = 0;
493 // "users" = public AP actors (sites), not the admin/member account rows.
494 try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
495 try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
496 res.type('application/json; charset=utf-8');
497 res.set('Cache-Control', 'public, max-age=600');
498 res.send(JSON.stringify({
499 version: '2.1',
500 software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
501 protocols: ['activitypub'],
502 services: { inbound: [], outbound: [] },
503 openRegistrations: false,
504 usage: { users: { total: users }, localPosts: posts },
505 metadata: { nodeName: 'Klonkt' },
506 }));
507});
508
509// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
510const apJson = express.json({
511 type: ['application/activity+json', 'application/ld+json', 'application/json'],
512 limit: '1mb',
513 verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
514});
515router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
516 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
517 catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
518});
519
520// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
521// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
522// the normal delivery machinery. The token is scoped to one user+site (OAuth
523// consent), so it must match the slug in the URL. (Declared after apJson, which
524// this shares with the inbox handler.)
525router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
526 const auth = OAuth.verifyBearer(req.headers.authorization);
527 if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
528 if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
529 if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
530
531 const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
532 if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
533 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
534 if (out.status === 201 && out.url) res.set('Location', out.url);
535 return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url });
536});
537
538export default router;
Note: See TracBrowser for help on using the repository browser.