source: Klonkt/src/routes/activitypub.js@ 72ec6a4

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

Media-submappen volgen nu MEDIA_PATH

De submappen voor avatars, post-images, reply-media, hero en audio-covers hadden
elk hun eigen env-variabele met een fallback naar <app>/storage/media/<sub>.
Daardoor negeerden ze MEDIA_PATH: wie zijn data buiten de checkout zette kreeg
alsnog een storage/-map in de work-tree, en uploads landden naast de code. Een
opruimstap bij een volgende deploy kan die vervolgens weggooien.

Nu leiden ze allemaal af van MEDIA_PATH via een gedeelde helper. Een eigen
override per submap wint nog steeds, dus bestaande installaties merken niets.
Dit maakt de scheiding van gebruikersdata en programmadata mogelijk met drie
regels in .env in plaats van acht.

Changed files:
src/routes/account.js

  • AVATAR_DIR via mediaDir(); dode dirname en fileURLToPath-import weg

src/routes/posts.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-media.js

  • POST_IMAGES_DIR en REPLY_MEDIA_DIR via mediaDir(); dode declaraties weg

src/routes/admin-settings.js

  • HERO_DIR via mediaDir(); dode declaraties weg

src/routes/admin-playlists.js

  • COVER_DIR via mediaDir(); dode dirname weg

src/routes/admin-audio.js

  • COVER_DIR via mediaDir(); AUDIO_DIR ongewijzigd (eigen wortel)

src/routes/admin-sites.js

  • PHOTO_DIR via mediaDir(), deelt bewust de avatars-map

src/routes/activitypub.js

  • AP_MEDIA_DIR via mediaDir(); ongebruikte fileURLToPath-import weg

New file:
src/config/paths.js

  • MEDIA_ROOT afgeleid van MEDIA_PATH
  • mediaDir(envVar, sub) voor submappen, met behoud van per-map overrides

DATABASE_PATH en AUDIO_PATH zijn eigen wortels en bewust ongemoeid gelaten.
Geverifieerd: 356 tests groen, en een server met externe MEDIA_PATH maakt al
zijn mappen buiten de checkout aan zonder de work-tree te raken.

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

  • Property mode set to 100644
File size: 36.0 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';
[e61c289]21import * as Guardianship from '../services/guardianship/index.js';
[e6c6e6f]22import multer from 'multer';
23import path from 'path';
24import fs from 'fs';
25import { randomUUID } from 'crypto';
[e2c3d09]26import { mediaDir } from '../config/paths.js';
[6bd25d1]27
28const router = express.Router();
[283f618]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
[89cc8c4]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(); });
[75ab393]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);
[d7526bd]39let _ver = '1.0.0';
40try { _ver = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url))).version || _ver; } catch { /* keep default */ }
[6bd25d1]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');
[f2796d3]55 const actorUri = AP.actorId(baseUrl(req), site.slug);
56 const profileUrl = baseUrl(req) + (site.slug === primarySlug() ? '/' : `/user/${encodeURIComponent(site.slug)}`);
[6bd25d1]57 res.send(JSON.stringify({
58 subject: `acct:${site.slug}@${hostOf(req)}`,
[f2796d3]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 ],
[6bd25d1]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 ────────────────────────────────────────────────────────
[c66cbb4]81router.get('/ap/users/:slug/outbox', async (req, res) => {
[6bd25d1]82 const site = publicSite(req.params.slug);
83 if (!site) return res.status(404).end();
[73a10c2]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']) {
[c66cbb4]96 const verified = await AP.verifyRequest(req).catch(() => null);
[73a10c2]97 verifiedActor = verified && verified.id;
[c66cbb4]98 }
[73a10c2]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)";
[6bd25d1]107 const posts = db.prepare(
[a9da2c0]108 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
[c66cbb4]109 FROM posts WHERE site_id = ? AND status = 'published' ${fanClause}
[6bd25d1]110 ORDER BY COALESCE(published_at, created_at) DESC LIMIT 20`
111 ).all(site.id);
[67f7150]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);
[6bd25d1]123});
124
[1a2f206]125// ── Follow-QR (Robins verzoek, 31-7) ──────────────────────────────
[ef519a3]126// The QR carries an HTTPS url, not the share: scheme: camera apps (Google
127// Lens voorop) treat unknown schemes as plain text and only offer to OPEN
128// https links (Robins melding, 31-7). The url lands on the interstitial
129// below, whose one big button fires the share: scheme — from a browser the
130// custom scheme DOES work (BROWSABLE intent-filter; Safari prompts).
131// Public on purpose: it encodes only the public handle, and the app's plain
132// image loaders carry no bearer.
[1a2f206]133router.get('/ap/users/:slug/follow-qr.png', async (req, res) => {
134 const site = db.prepare('SELECT slug FROM sites WHERE slug = ?').get(req.params.slug);
135 if (!site) return res.status(404).end();
136 try {
137 const { default: QRCode } = await import('qrcode');
[ef519a3]138 const png = await QRCode.toBuffer(`${baseUrl(req)}/ap/users/${encodeURIComponent(site.slug)}/follow`, { width: 600, margin: 1 });
[1a2f206]139 res.set('Content-Type', 'image/png');
140 res.set('Cache-Control', 'public, max-age=86400');
141 res.send(png);
142 } catch (e) {
143 console.warn('[AP] follow-qr failed:', e && e.message);
144 res.status(500).end();
145 }
146});
147
[ef519a3]148// The interstitial the QR opens: one big button into Shaer, and the handle
149// in plain sight for whoever has no Shaer (yet).
150router.get('/ap/users/:slug/follow', (req, res) => {
151 const site = db.prepare('SELECT slug, title FROM sites WHERE slug = ?').get(req.params.slug);
152 if (!site) return res.status(404).end();
153 const host = new URL(baseUrl(req)).host;
154 const esc = (t) => String(t).replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' }[c]));
155 const handle = `@${site.slug}@${host}`;
156 const name = esc(site.title || site.slug);
157 res.set('Cache-Control', 'public, max-age=3600');
158 res.send(`<!doctype html><html lang="en"><head><meta charset="utf-8">
159<meta name="viewport" content="width=device-width, initial-scale=1">
160<title>Follow ${name}</title>
161<style>
162 body { font-family: system-ui, sans-serif; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
163 background: linear-gradient(160deg, #5A32E6, #2a1a5e); color: #fff; text-align: center; }
164 main { padding: 32px; max-width: 420px; }
165 h1 { font-size: 1.5rem; margin: 0 0 .4rem; }
166 .handle { opacity: .85; font-family: ui-monospace, monospace; word-break: break-all; }
167 a.go { display: block; margin: 28px auto 14px; padding: 16px 28px; border-radius: 999px; background: #fff; color: #2a1a5e;
168 font-weight: 700; font-size: 1.15rem; text-decoration: none; }
169 p.small { font-size: .85rem; opacity: .75; line-height: 1.5; }
170</style></head><body><main>
171 <h1>Follow ${name}</h1>
172 <div class="handle">${esc(handle)}</div>
173 <a class="go" href="share:social/follow/AP/${esc(handle)}">Open in Shaer</a>
174 <p class="small">No Shaer? Any fediverse app can follow ${esc(handle)}.</p>
175</main></body></html>`);
176});
177
[2a17f0a]178// ── Long-poll (owner only, Robins verzoek 31-7) ───────────────────
179// Hold the request until something push-worthy lands for this account, then
180// answer 200 (news: re-read your feed) or 204 after ~25s (nothing: re-arm).
181// The thread in the app stays live without interval polling.
182router.get('/ap/users/:slug/inbox/wait', (req, res) => {
183 const auth = OAuth.verifyBearer(req.headers.authorization);
184 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
185 let settled = false;
186 const done = (code) => {
187 if (settled) return;
188 settled = true;
189 clearTimeout(timer);
190 off();
191 if (!res.headersSent) res.status(code).end();
192 };
193 const off = AP.onNews(auth.site.slug, () => done(200));
194 const timer = setTimeout(() => done(204), 25_000);
195 req.on('close', () => done(204));
196});
197
[8b07c12]198// ── Blocked collection (owner only, AP §5.6) ──────────────────────
199// The server blocklist is the source of truth for Shaer's "in Orbit":
200// clients read it here instead of keeping their own state. Actor-kind
201// blocks only (domain blocks are instance policy, not an Orbit member).
202router.get('/ap/users/:slug/blocked', (req, res) => {
203 const auth = OAuth.verifyBearer(req.headers.authorization);
204 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
205 const base = baseUrl(req);
206 const items = AP.listBlocks(auth.site.slug)
207 .filter((b) => b.kind === 'actor')
208 .map((b) => b.target);
209 AP.sendAP(res, {
210 '@context': AP.AP_CONTEXT,
211 id: `${base}/ap/users/${auth.site.slug}/blocked`,
212 type: 'OrderedCollection',
213 totalItems: items.length,
214 orderedItems: items,
215 });
216});
217
[e61c289]218// ── Guardian queues (owner only, FEP-633c, shaer:queues) ──────────
219// The dashboard collections the Shaer clients read: pending adoption offers,
220// gated follows (empty in Klonkt for now) and the guardian's wards. Same
221// contract as the Shaer test daemon.
222function queueRoute(name, build) {
223 router.get(`/ap/users/:slug/queues/${name}`, (req, res) => {
224 const auth = OAuth.verifyBearer(req.headers.authorization);
225 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
226 const base = baseUrl(req);
227 const me = `${base}/ap/users/${auth.site.slug}`;
228 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...build(`${me}/queues/${name}`, auth.site.slug, me) });
229 });
230}
231queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
232queueRoute('follows', (id) => Guardianship.followsCollection(id));
233queueRoute('wards', (id, slug) => Guardianship.wardsCollection(id, slug));
[6eab7e9]234// Availability (FEP-633c 3.6.1) is never public: the ward reads its
235// guardians' real states here and nowhere else.
236queueRoute('guardians', (id, slug) => Guardianship.guardiansCollection(id, slug));
[e61c289]237
[0cea12b]238// ── Inbox read (owner only, AP C2S) ───────────────────────────────
239// GET on the inbox is part of ActivityPub C2S: the account owner (a bearer
240// scoped to this site) reads recent inbound posts (the timeline: accounts
241// they follow) as Create(Note) items, so an app (Shaer) can build a unified
242// feed. Anyone else gets 403; the inbox stays write-only for the public.
243router.get('/ap/users/:slug/inbox', (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 const base = baseUrl(req);
[fc40410]247 // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
248 // world outside the fediverse is the guardians' call. The gate is applied
249 // here, at serialisation: a blocked embed is never sent, because an embed the
250 // client merely hides has still been delivered to the device.
251 const isWard = (() => { try { return Guardianship.listGuardians(auth.site.slug).length > 0; } catch { return false; } })();
252 const embedsAllowed = Guardianship.externalEmbedsAllowed(auth.site.external_embeds, isWard);
[e27b8db]253 // The heavier sibling (5.6): may a third party's PLAYER run inside the app,
254 // and may a link hand the child over to a browser? Both are the guardians'
255 // call, both default to off for a ward, and both need the preview gate open
256 // first: you cannot play, or follow, what you may not see. Served here so
257 // the app knows what it may offer instead of guessing.
258 const playbackAllowed = embedsAllowed
259 && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
[08ab8ad]260 const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
[0cea12b]261 id: `${t.id}#create`,
262 type: 'Create',
263 actor: t.author_uri,
264 published: t.published || t.created_at || undefined,
265 object: {
266 id: t.id,
267 type: 'Note',
268 attributedTo: t.author_uri,
269 content: t.content,
270 url: t.url || undefined,
271 published: t.published || t.created_at || undefined,
272 sensitive: !!t.nsfw,
273 summary: t.cw || undefined,
[fb30b5d]274 // Friends' media travels along (media_json → AS2 attachment), so the
275 // client renders their images/audio like own outbox posts.
276 attachment: AP.timelineAttachments(t.media_json),
[eb36688]277 // The note's preserved tags, so the client can render them: FEP-9098
278 // Emoji tags (:shortcode: → image) and FEP-e232 Link tags (quotes /
279 // inline object references). Combined into one `tag` array; omitted
280 // when the note has neither.
281 tag: (() => {
282 const tags = [...(AP.timelineEmojis(t.emoji_json) || []), ...(AP.timelineObjectLinks(t.link_json) || [])];
283 return tags.length ? tags : undefined;
284 })(),
[6fd0e20]285 // FEP-044f: the resolved quoted post (author + content), so the client
286 // renders an embedded quote card instead of a bare link. Omitted when the
287 // note has no quote or the quoted post could not be resolved.
288 'shaer:quote': AP.timelineQuote(t.quote_json),
[46a30f0]289 // The post author's display info (name / @handle / avatar), so every card
290 // gets a byline header like the quote card. attributedTo stays the bare
291 // actor URI; this is the resolved presentation Klonkt already stored.
292 'shaer:author': (t.author_name || t.author_handle || t.author_icon) ? {
293 name: t.author_name || undefined, handle: t.author_handle || undefined,
294 icon: t.author_icon || undefined, url: t.author_url || undefined,
[a677616]295 // FEP-9098: emojis in the display name (":shortcode:"), if any.
296 emojis: (() => { try { return t.author_emoji_json ? JSON.parse(t.author_emoji_json) : undefined; } catch { return undefined; } })(),
[46a30f0]297 } : undefined,
298 // When a followed account boosted this, who did ("X boosted"). Omitted for
299 // ordinary posts.
300 'shaer:booster': (t.reblog_name || t.reblog_handle || t.reblog_icon) ? {
301 name: t.reblog_name || undefined, handle: t.reblog_handle || undefined,
302 icon: t.reblog_icon || undefined,
[f3caf19]303 // FEP-9098: emojis in the booster's display name (":shortcode:"), if any.
304 emojis: (() => { try { return t.reblog_emoji_json ? JSON.parse(t.reblog_emoji_json) : undefined; } catch { return undefined; } })(),
[46a30f0]305 } : undefined,
[de89079]306 // Whether THIS account already liked/boosted the note, so the app's
307 // detail-view buttons show the current state (and can toggle/undo).
308 'shaer:liked': !!t.liked,
309 'shaer:boosted': !!t.boosted,
[fc40410]310 // An external (non-fediverse) embed, thumbnail-only and never an iframe.
311 // Omitted entirely when the gate is closed (see above).
[e27b8db]312 // Carries shaer:playerUrl only when the playback gate is open too.
313 'shaer:embed': embedsAllowed ? AP.timelineEmbed(t.embed_json, { playback: playbackAllowed }) : undefined,
[0cea12b]314 },
315 }));
[08ab8ad]316 // The direct notes addressed to this account: a plain DM, a guardian's wave
317 // (§5), a ward's 🛟 help request (§5.2.1). Those are messages, not posts, so
318 // they are not in the timeline; without them the app's Berichten shows only
319 // what you said yourself. Same shape as a post, so one parser handles both.
320 const me = AP.actorId(base, auth.site.slug);
321 const myHandle = (() => { try { return `@${auth.site.slug}@${new URL(base).host}`; } catch { return `@${auth.site.slug}`; } })();
322 const messages = AP.getDirectMessages(auth.site.slug, 60).map((m) => ({
323 id: `${m.object_uri}#create`,
324 type: 'Create',
325 actor: m.actor_uri,
326 published: AP.isoStamp(m.published || m.created_at),
327 object: {
328 id: m.object_uri,
329 type: 'Note',
330 attributedTo: m.actor_uri,
331 content: AP.stripLeadingMentions(m.content),
332 url: m.note_url || undefined,
333 published: AP.isoStamp(m.published || m.created_at),
334 // Addressed to us and to nobody we know of: the other recipients of a
335 // note to several people are not ours to see, so we serve what we know.
336 to: [me],
337 // The Mention is how the client recognises itself as the addressee and
338 // groups the note into a conversation. No FEP-e232 link tags here: a
339 // mention row keeps the resolved quote, not the raw tags.
340 tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
341 attachment: AP.timelineAttachments(m.media_json),
342 // FEP-633c: what kind of message this is. The wave is a gentle nudge from
343 // a guardian; the help request is the buoy. Both render differently.
344 'shaer:wave': m.wave ? true : undefined,
345 'shaer:helpRequest': m.help_request ? true : undefined,
346 'shaer:quote': AP.timelineQuote(m.quote_json),
347 'shaer:author': (m.actor_name || m.actor_handle || m.actor_icon) ? {
348 name: m.actor_name || undefined, handle: m.actor_handle || undefined,
349 icon: m.actor_icon || undefined, url: m.actor_url || undefined,
350 emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
351 } : undefined,
352 'shaer:embed': embedsAllowed ? AP.timelineEmbed(m.embed_json, { playback: playbackAllowed }) : undefined,
353 },
354 }));
[55eca8b]355 // Inbound REPLIES on your own posts: stored as interactions (the web's
356 // comment machinery), never as mentions, so this read missed them and a
357 // friend's reply arrived everywhere except in your app (Robins melding,
358 // 30-7). Same shape as the other legs; media/quotes ride the stored JSON.
359 const replies = AP.getReplyMessages(auth.site.slug, 60).map((m) => ({
360 id: `${m.object_uri}#create`,
361 type: 'Create',
362 actor: m.actor_uri,
363 published: AP.isoStamp(m.published || m.created_at),
364 object: {
365 id: m.object_uri,
366 type: 'Note',
367 attributedTo: m.actor_uri,
368 content: AP.stripLeadingMentions(m.content),
369 inReplyTo: m.parent_uri || `${base}/ap/notes/${m.post_id}`,
370 published: AP.isoStamp(m.published || m.created_at),
371 to: [me],
372 tag: [{ type: 'Mention', href: me, name: myHandle }, ...(AP.timelineEmojis(m.emoji_json) || [])],
373 attachment: AP.timelineAttachments(m.media_json),
374 'shaer:quote': AP.timelineQuote(m.quote_json),
375 'shaer:author': (m.actor_name || m.actor_handle || m.actor_icon) ? {
376 name: m.actor_name || undefined, handle: m.actor_handle || undefined,
377 icon: m.actor_icon || undefined, url: m.actor_url || undefined,
378 emojis: (() => { try { return m.actor_emoji_json ? JSON.parse(m.actor_emoji_json) : undefined; } catch { return undefined; } })(),
379 } : undefined,
380 'shaer:embed': embedsAllowed ? AP.timelineEmbed(m.embed_json, { playback: playbackAllowed }) : undefined,
381 },
382 }));
[6a99668]383 // Your OWN sent notes (replies and direct messages, ap_outbox): without
384 // them a reply existed everywhere except in your own app, Messages showed
385 // half a conversation, and a retry ran into the duplicate guard (Robins
386 // melding, 30-7). Served like the other legs: same shape, one parser.
387 const mine = AP.selfAuthor(base, auth.site);
388 const sent = AP.getSentNotes(base, auth.site, 60).map((n) => ({
389 id: `${n.id}#create`,
390 type: 'Create',
391 actor: me,
392 published: n.published,
393 // The leading mention anchor is addressing, not prose (the DM leg strips
394 // it the same way); the Mention tags built from the full content stay.
395 object: { ...n, content: AP.stripLeadingMentions(n.content), 'shaer:author': mine },
396 }));
397 // Newest first over all legs, so the app can keep treating this as one feed.
[55eca8b]398 const items = [...posts, ...messages, ...replies, ...sent].sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
[0cea12b]399 AP.sendAP(res, {
400 '@context': AP.AP_CONTEXT,
401 id: `${base}/ap/users/${auth.site.slug}/inbox`,
402 type: 'OrderedCollection',
[e27b8db]403 // What this account may do with what is in here (FEP-633c 5.6). Owner-only
404 // by construction, and never on the public actor document: it says
405 // something about a child, and only the child and its guardians need it.
406 'shaer:capabilities': {
407 'shaer:externalEmbeds': embedsAllowed,
408 'shaer:externalPlayback': playbackAllowed,
409 // Leaving the app is the same decision as playing inside it: with the
410 // gate shut a link is shown but not followed, so the door is closed too
411 // and not just the picture over it.
412 'shaer:externalLinks': playbackAllowed,
413 },
[0cea12b]414 totalItems: items.length,
415 orderedItems: items,
416 });
417});
418
[e6c6e6f]419// ── uploadMedia (owner only, AP C2S) ──────────────────────────────
420// The actor advertises endpoints.uploadMedia; this implements it. A bearer
421// scoped to this site uploads one image/audio/video (multipart field "file",
422// AP convention) into the same store the reply editor uses, and gets back
423// { url, mediaType, name } to attach on a note (e.g. the help-buoy capture).
[e2c3d09]424const AP_MEDIA_DIR = mediaDir('REPLY_MEDIA_PATH', 'reply-media');
[e6c6e6f]425fs.mkdirSync(AP_MEDIA_DIR, { recursive: true });
426const AP_MEDIA_EXT = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.mp3', '.m4a', '.ogg', '.opus', '.flac', '.wav', '.mp4', '.webm', '.mov']);
427const apMediaUpload = multer({
428 storage: multer.diskStorage({
429 destination: (req, file, cb) => cb(null, AP_MEDIA_DIR),
430 filename: (req, file, cb) => cb(null, `${randomUUID()}${path.extname(file.originalname || '').toLowerCase()}`),
431 }),
432 limits: { fileSize: 32 * 1024 * 1024 },
433 fileFilter: (req, file, cb) => {
434 const ext = path.extname(file.originalname || '').toLowerCase();
435 if (!AP_MEDIA_EXT.has(ext)) return cb(new Error('Media must be an image, audio or video file'));
436 cb(null, true);
437 },
438});
439router.post('/ap/users/:slug/uploadMedia', (req, res) => {
440 const auth = OAuth.verifyBearer(req.headers.authorization);
441 if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
442 apMediaUpload.single('file')(req, res, (err) => {
443 if (err) return res.status(400).json({ error: err.message });
444 if (!req.file) return res.status(400).json({ error: 'No file' });
445 const mime = String(req.file.mimetype || '');
446 if (!/^(image|audio|video)\//.test(mime)) {
447 try { fs.unlinkSync(req.file.path); } catch { /* best effort */ }
448 return res.status(400).json({ error: 'Media must be an image, audio or video file' });
449 }
[7d01696]450 // A video gets a poster frame next to it (shaer-zowq), best-effort and
451 // out of band: ffmpeg pulls one frame at 1s into <name>.poster.jpg. On a
452 // machine without ffmpeg nothing happens and nothing breaks; the clients
453 // fall back to extracting a frame natively.
454 if (mime.startsWith('video/')) {
[79f00c5]455 // The bundled static build (ffmpeg-static) does the work, exactly like
456 // VideoCoverService and AudioTranscoder already do: Klonkt SHIPS its
457 // ffmpeg (Robins opmerking, 30-7), so nothing needs installing on any
458 // machine. Soft dependency + best-effort: absent stays silent, and
459 // FFMPEG_PATH can still override for an operator who wants a newer one.
460 Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
461 const bin = process.env.FFMPEG_PATH || ff.default;
462 if (!bin) return;
[7d01696]463 const poster = req.file.path + '.poster.jpg';
[79f00c5]464 execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-ss', '1', '-i', req.file.path, '-frames:v', '1', '-vf', "scale='min(640,iw)':-2", poster],
[7d01696]465 { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] poster failed:', e.message); });
466 }).catch(() => { /* never blocks the upload */ });
467 }
[6dd26e5]468 // Audio gets the same courtesy (Robins vraag, 30-7: vrolijk de kale
469 // audio-tegel op): ffmpeg draws the waveform into <name>.poster.png.
470 // White on transparent, so the tile's own gradient stays the backdrop
[1f640ff]471 // and every audio post keeps its own hue. The shape is bars, not the
472 // raw hairy wave (Robins tweede vraag): peak and average sampled into
473 // 57 columns (soft tip over bright core), blown up nearest-neighbor to
474 // 14px bars, and drawgrid ERASES 5px gaps (c=black@0 + replace=1 writes
475 // transparent pixels; h=2*ih keeps horizontal grid lines out of frame).
[6dd26e5]476 if (mime.startsWith('audio/')) {
477 Promise.all([import('child_process'), import('ffmpeg-static')]).then(([{ execFile }, ff]) => {
478 const bin = process.env.FFMPEG_PATH || ff.default;
479 if (!bin) return;
480 const poster = req.file.path + '.poster.png';
[1f640ff]481 const graph = '[0:a]aformat=channel_layouts=mono,asplit[a][b];'
482 + '[a]showwavespic=s=57x256:colors=white@0.5:filter=peak:scale=sqrt:draw=full[pk];'
483 + '[b]showwavespic=s=57x256:colors=white:filter=average:scale=sqrt:draw=full[av];'
484 + '[pk][av]overlay=format=auto,scale=798:256:flags=neighbor,drawgrid=w=14:h=2*ih:t=5:c=black@0:replace=1';
485 execFile(bin, ['-hide_banner', '-loglevel', 'error', '-y', '-i', req.file.path, '-filter_complex', graph, '-frames:v', '1', poster],
[6dd26e5]486 { timeout: 30000 }, (e) => { if (e && e.code !== 'ENOENT') console.warn('[media] waveform failed:', e.message); });
487 }).catch(() => { /* never blocks the upload */ });
488 }
[e6c6e6f]489 res.status(201).json({
490 url: '/media/reply-media/' + req.file.filename,
491 mediaType: mime,
492 name: String(req.file.originalname || '').slice(0, 120),
493 });
494 });
495});
496
[4407c67]497// ── Followers (count-only public, full for the owner) ─────────────
498// A C2S bearer scoped to this site (the account owner) gets the real actor
499// URIs so their own client can build a friends list; everyone else gets the
500// count only (privacy).
[30871c1]501// FEP-9876: enrichment is opt-in via `Prefer: return=representation` (RFC 7240).
502// Returns true and sets the response headers when the owner asked for it.
503function wantsEnriched(req, res) {
504 res.set('Vary', 'Prefer'); // enriched and bare are two representations
505 if (AP.prefersEnriched(req.get('Prefer'))) {
506 res.set('Preference-Applied', 'return=representation');
507 return true;
508 }
509 return false;
510}
511
[6bd25d1]512router.get('/ap/users/:slug/followers', (req, res) => {
[4407c67]513 const auth = OAuth.verifyBearer(req.headers.authorization);
514 const owner = auth && auth.site.slug === req.params.slug;
515 const site = owner ? auth.site : publicSite(req.params.slug);
[6bd25d1]516 if (!site) return res.status(404).end();
[4407c67]517 if (owner) {
[7922694]518 const uris = db.prepare('SELECT actor_uri FROM ap_followers WHERE slug = ? ORDER BY created_at').all(site.slug).map((r) => r.actor_uri);
[30871c1]519 // Default = bare references; enrich only when the client asks (FEP-9876).
520 const items = wantsEnriched(req, res) ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
[4407c67]521 return AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, items.length, items));
522 }
[6bd25d1]523 const n = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?').get(site.slug).n;
524 AP.sendAP(res, AP.buildFollowers(baseUrl(req), site, n));
525});
526
[4407c67]527// ── Following (count-only public, full for the owner) ─────────────
[f2796d3]528router.get('/ap/users/:slug/following', (req, res) => {
[4407c67]529 const auth = OAuth.verifyBearer(req.headers.authorization);
530 const owner = auth && auth.site.slug === req.params.slug;
531 const site = owner ? auth.site : publicSite(req.params.slug);
[f2796d3]532 if (!site) return res.status(404).end();
[4407c67]533 if (owner) {
[30871c1]534 const enrich = wantsEnriched(req, res); // FEP-9876 opt-in
[4407c67]535 let items = [];
[30871c1]536 try {
537 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);
538 items = enrich ? uris.map((u) => AP.buildActorRef(site.slug, u)) : uris;
539 } catch { /* table may not exist */ }
[4407c67]540 return AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, items.length, items));
541 }
[f2796d3]542 let n = 0;
543 try { n = db.prepare("SELECT COUNT(*) n FROM ap_following WHERE slug = ? AND status = 'accepted'").get(site.slug).n; } catch { /* table may not exist */ }
544 AP.sendAP(res, AP.buildFollowing(baseUrl(req), site, n));
545});
546
[75bda38]547// ── Featured (pinned posts → Mastodon "Featured" tab) ─────────────
548router.get('/ap/users/:slug/featured', (req, res) => {
549 const site = publicSite(req.params.slug);
550 if (!site) return res.status(404).end();
[2af2e69]551 // NB: Mastodon DISPLAYS the featured collection in REVERSE (pins shown
552 // last-processed-first). So we emit it reversed (lowest pin priority first,
553 // rank 1 last) → Mastodon flips it back to pin-rank ascending on the profile.
[75bda38]554 const posts = db.prepare(
[a9da2c0]555 `SELECT id, slug, title, content, cover_image_url, cover_video_url, nsfw, content_warning, c2s_attachments, published_at, created_at
[75bda38]556 FROM posts WHERE site_id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)
557 AND pinned IS NOT NULL AND pinned > 0
[2af2e69]558 ORDER BY pinned DESC, COALESCE(published_at, created_at) ASC LIMIT 20`
[75bda38]559 ).all(site.id);
560 AP.sendAP(res, AP.buildFeatured(baseUrl(req), site, posts));
561});
562
[6bd25d1]563// ── Note ──────────────────────────────────────────────────────────
564router.get('/ap/notes/:id', (req, res) => {
565 const post = db.prepare(
566 "SELECT * FROM posts WHERE id = ? AND status = 'published' AND (fan_only IS NULL OR fan_only = 0)"
567 ).get(req.params.id);
[55bc7f9]568 if (!post) {
569 // Could be one of OUR outbound replies (ap_outbox), not a post.
570 const note = AP.getOutboxNote(baseUrl(req), req.params.id);
[49edc72]571 if (!note) return res.status(404).end();
572 if (!AP.apWants(req)) {
573 // A browser hit a reply's AP URL → send them to the source it replies to
574 // (where the post + its reactions live), falling back to the site home.
575 const src = (typeof note.inReplyTo === 'string' && /^https?:\/\//i.test(note.inReplyTo))
576 ? note.inReplyTo : (baseUrl(req) + '/');
577 return res.redirect(302, src);
578 }
[d3b9f68]579 return AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
[55bc7f9]580 }
[6bd25d1]581 const site = db.prepare('SELECT * FROM sites WHERE id = ?').get(post.site_id);
582 if (!site) return res.status(404).end();
[49edc72]583 const note = AP.buildNote(baseUrl(req), site, post);
584 if (!AP.apWants(req)) {
585 // A browser hit a post's AP note URL → send them to the human post page
586 // (which shows the post + its "from the fediverse" reactions).
587 return res.redirect(302, note.url || (baseUrl(req) + '/'));
588 }
[d3b9f68]589 AP.sendAP(res, { '@context': AP.AP_CONTEXT, ...note });
[6bd25d1]590});
591
[d7526bd]592// ── Replies collection ── lets remote servers fetch a post's whole thread.
593router.get('/ap/notes/:id/replies', (req, res) => {
594 const base = baseUrl(req);
595 const items = AP.getReplyUris(base, req.params.id);
596 AP.sendAP(res, {
[d3b9f68]597 '@context': AP.AP_CONTEXT,
[d7526bd]598 id: `${base}/ap/notes/${req.params.id}/replies`,
599 type: 'OrderedCollection',
600 totalItems: items.length,
601 orderedItems: items,
602 });
603});
604
605// ── NodeInfo ── standard instance metadata so fediverse tools recognise Klonkt.
606router.get('/.well-known/nodeinfo', (req, res) => {
607 res.type('application/json');
608 res.set('Cache-Control', 'public, max-age=3600');
609 res.send(JSON.stringify({ links: [{ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1', href: `${baseUrl(req)}/nodeinfo/2.1` }] }));
610});
611router.get('/nodeinfo/2.1', (req, res) => {
612 let users = 0; let posts = 0;
[f2796d3]613 // "users" = public AP actors (sites), not the admin/member account rows.
614 try { users = db.prepare('SELECT COUNT(*) c FROM sites WHERE (is_public IS NULL OR is_public = 1)').get().c; } catch { /* */ }
[d7526bd]615 try { posts = db.prepare("SELECT COUNT(*) c FROM posts WHERE status = 'published'").get().c; } catch { /* */ }
616 res.type('application/json; charset=utf-8');
617 res.set('Cache-Control', 'public, max-age=600');
618 res.send(JSON.stringify({
619 version: '2.1',
620 software: { name: 'klonkt', version: _ver, repository: 'https://github.com/roboburr/klonkt' },
621 protocols: ['activitypub'],
622 services: { inbound: [], outbound: [] },
623 openRegistrations: false,
624 usage: { users: { total: users }, localPosts: posts },
625 metadata: { nodeName: 'Klonkt' },
626 }));
627});
628
[5bf63b7]629// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
630const apJson = express.json({
631 type: ['application/activity+json', 'application/ld+json', 'application/json'],
632 limit: '1mb',
633 verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
634});
[75ab393]635router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apInboxLimiter, apJson, async (req, res) => {
[5bf63b7]636 try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
637 catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
[6bd25d1]638});
639
[dd568e7]640// ── Outbox POST: ActivityPub Client-to-Server ─────────────────────
641// A bearer-authenticated client (Shaer) POSTs an activity; we translate it onto
642// the normal delivery machinery. The token is scoped to one user+site (OAuth
643// consent), so it must match the slug in the URL. (Declared after apJson, which
644// this shares with the inbox handler.)
645router.post('/ap/users/:slug/outbox', apInboxLimiter, apJson, async (req, res) => {
646 const auth = OAuth.verifyBearer(req.headers.authorization);
647 if (!auth) { res.set('WWW-Authenticate', 'Bearer'); return res.status(401).json({ error: 'invalid_token' }); }
648 if (auth.site.slug !== req.params.slug) return res.status(403).json({ error: 'wrong_site', detail: 'token is scoped to a different site' });
649 if (auth.user.readonly) return res.status(403).json({ error: 'read_only_account' });
650
651 const out = await AP.ingestOutboxActivity(auth.site, auth.user, req.body);
652 if (out.error) return res.status(out.status || 400).json({ error: out.error, detail: out.detail });
653 // 201 Created → Location header (AP spec); 202 Accepted for side-effect verbs.
654 if (out.status === 201 && out.url) res.set('Location', out.url);
655 return res.status(out.status || 202).json({ ok: true, id: out.id, url: out.url });
656});
657
[6bd25d1]658export default router;
Note: See TracBrowser for help on using the repository browser.