Changeset 5bf63b7 in Klonkt


Ignore:
Timestamp:
06/24/2026 10:35:48 AM (3 months ago)
Author:
Robin Genis <roboburr@…>
Branches:
main
Children:
dd1028a
Parents:
6bd25d1
Message:

feat(activitypub): phase 1b — Follow/Accept, HTTP Signatures, delivery

Inbox handles Follow (store follower + send signed Accept) and Undo Follow;
incoming requests are signature-verified (best-effort). Outgoing POSTs to inboxes
are signed (draft-cavage RSA-SHA256). New published public posts are delivered as
Create(Note) to followers' inboxes. Makes a Klonkt actor truly followable from
Mastodon; live interop test pending with Bart.

Co-Authored-By: Claude <noreply@…>

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    r6bd25d1 r5bf63b7  
    8080});
    8181
    82 // ── Inbox (Phase 1 stub: accept; Follow/Accept + sig verify next step) ──
    83 const apJson = express.json({ type: ['application/activity+json', 'application/ld+json', 'application/json'], limit: '1mb' });
    84 router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apJson, (req, res) => {
    85   try { console.log('[AP inbox]', (req.body && req.body.type) || 'unknown', '→', req.params.slug || 'shared'); } catch { /* ignore */ }
    86   res.status(202).end();
     82// ── Inbox — Follow→Accept, Undo Follow (best-effort signature verify) ──
     83const apJson = express.json({
     84  type: ['application/activity+json', 'application/ld+json', 'application/json'],
     85  limit: '1mb',
     86  verify: (req, _res, buf) => { req.rawBody = buf; }, // raw body for digest verification
     87});
     88router.post(['/ap/users/:slug/inbox', '/ap/inbox'], apJson, async (req, res) => {
     89  try { return res.status(await AP.handleInbox(req, req.params.slug || null) || 202).end(); }
     90  catch (e) { console.warn('[AP inbox] error:', e.message); return res.status(202).end(); }
    8791});
    8892
  • src/routes/posts.js

    r6bd25d1 r5bf63b7  
    1919import { audioUrl } from '../services/AudioStreamService.js';
    2020import { toWebp } from '../services/ImageWebpService.js';
     21import ActivityPubService from '../services/ActivityPubService.js';
    2122
    2223const __dirname = path.dirname(fileURLToPath(import.meta.url));
     
    230231      ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, postId);
    231232    } catch (e) { /* FTS index issues are non-fatal */ }
     233
     234    // ActivityPub: federate a freshly published public post to followers.
     235    if (!fanOnly) {
     236      ActivityPubService.deliverCreate(site, {
     237        id: postId, slug: finalSlug, title: title || finalSlug,
     238        content: cleanContent, published_at: publishedAt, created_at: now,
     239      }).catch(() => { /* best-effort */ });
     240    }
    232241  }
    233242
  • src/services/ActivityPubService.js

    r6bd25d1 r5bf63b7  
    153153}
    154154
     155// ── followers store (lazy stmts) ──────────────────────────────────
     156let _insF, _delF, _listF, _cntF;
     157function fStmts() {
     158  if (!_insF) {
     159    _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)');
     160    _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?');
     161    _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?');
     162    _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?');
     163  }
     164  return { ins: _insF, del: _delF, list: _listF, cnt: _cntF };
     165}
     166export function followerCount(slug) { return fStmts().cnt.get(slug).n; }
     167
     168// ── HTTP Signatures + delivery ────────────────────────────────────
     169const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; };
     170
     171// Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256).
     172export async function deliver(inboxUrl, bodyObj, keyId, privatePem) {
     173  const body = JSON.stringify(bodyObj);
     174  const u = new URL(inboxUrl);
     175  const date = new Date().toUTCString();
     176  const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64');
     177  const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`;
     178  const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64');
     179  const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`;
     180  const r = await fetch(inboxUrl, {
     181    method: 'POST',
     182    headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig },
     183    body,
     184    signal: AbortSignal.timeout(8000),
     185  });
     186  return r.status;
     187}
     188
     189export async function fetchActor(url) {
     190  try {
     191    const r = await fetch(url, { headers: { Accept: 'application/activity+json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) });
     192    if (!r.ok) return null;
     193    return await r.json();
     194  } catch { return null; }
     195}
     196
     197// Best-effort verification of an incoming signed request. Returns the sender's
     198// actor doc if the signature checks out, else null. (Not gating yet — MVP.)
     199export async function verifyRequest(req) {
     200  const sigH = req.headers['signature'];
     201  if (!sigH) return null;
     202  const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]]));
     203  if (!p.keyId || !p.signature) return null;
     204  const actor = await fetchActor(p.keyId.split('#')[0]);
     205  const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem;
     206  if (!pem) return null;
     207  const hs = (p.headers || '(request-target) host date').split(/\s+/);
     208  const line = hs.map((h) => h === '(request-target)'
     209    ? `(request-target): ${req.method.toLowerCase()} ${req.originalUrl}`
     210    : `${h}: ${req.headers[h] || ''}`).join('\n');
     211  let ok = false;
     212  try { ok = crypto.verify('sha256', Buffer.from(line), pem, Buffer.from(p.signature, 'base64')); } catch { ok = false; }
     213  if (ok && hs.includes('digest') && req.rawBody) {
     214    const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64');
     215    if (req.headers['digest'] !== exp) ok = false;
     216  }
     217  return ok ? actor : null;
     218}
     219
     220// Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox.
     221export async function handleInbox(req, slugParam) {
     222  const act = req.body || {};
     223  const type = act.type;
     224  const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
     225  const verified = await verifyRequest(req).catch(() => null); // best-effort; not gating (MVP)
     226
     227  if (type === 'Follow') {
     228    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
     229    const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id));
     230    if (!who || !slug) return 400;
     231    const remote = await fetchActor(who);
     232    if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly
     233    fStmts().ins.run(slug, who, remote.inbox, (remote.endpoints && remote.endpoints.sharedInbox) || null);
     234    const me = actorId(base, slug);
     235    const keys = getOrCreateKeys(slug);
     236    const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}`, type: 'Accept', actor: me, object: act };
     237    deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message));
     238    console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)');
     239    return 202;
     240  }
     241  if (type === 'Undo' && act.object && act.object.type === 'Follow') {
     242    const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id);
     243    const obj = act.object.object;
     244    const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id));
     245    if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); }
     246    return 202;
     247  }
     248  console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)');
     249  return 202;
     250}
     251
     252// Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget).
     253// Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base.
     254export async function deliverCreate(site, post) {
     255  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     256  if (!base || !site || !site.slug) return;
     257  const followers = fStmts().list.all(site.slug);
     258  if (!followers.length) return;
     259  const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))];
     260  const keys = getOrCreateKeys(site.slug);
     261  const keyId = `${actorId(base, site.slug)}#main-key`;
     262  const create = buildCreate(base, site, post);
     263  for (const inbox of inboxes) deliver(inbox, create, keyId, keys.private_pem).catch(() => { /* best-effort */ });
     264}
     265
    155266export default {
    156267  getOrCreateKeys, apWants, sendAP, actorId, noteId,
    157268  buildActor, buildNote, buildCreate, buildOutbox, buildFollowers,
     269  followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate,
    158270};
Note: See TracChangeset for help on using the changeset viewer.