${escTitle}
` : ''; const html = titleHtml + (post.content || ''); return { id, type: 'Note', attributedTo: aId, content: html, url: human, published: new Date(post.published_at || post.created_at || Date.now()).toISOString(), to: [PUBLIC], cc: [`${aId}/followers`], tag: Array.isArray(post.tags) ? post.tags.map((t) => ({ type: 'Hashtag', name: '#' + String(t).replace(/\s+/g, '') })) : [], }; } export function buildCreate(base, site, post) { const note = buildNote(base, site, post); return { '@context': 'https://www.w3.org/ns/activitystreams', id: note.id + '#create', type: 'Create', actor: actorId(base, site.slug), published: note.published, to: note.to, cc: note.cc, object: note, }; } export function buildOutbox(base, site, posts) { const id = `${actorId(base, site.slug)}/outbox`; const items = (posts || []).slice(0, MAX_OUTBOX).map((p) => buildCreate(base, site, p)); return { '@context': 'https://www.w3.org/ns/activitystreams', id, type: 'OrderedCollection', totalItems: items.length, orderedItems: items, }; } export function buildFollowers(base, site, count) { const id = `${actorId(base, site.slug)}/followers`; return { '@context': 'https://www.w3.org/ns/activitystreams', id, type: 'OrderedCollection', totalItems: count || 0, orderedItems: [], // hidden for privacy; count only }; } // ── followers store (lazy stmts) ────────────────────────────────── let _insF, _delF, _listF, _cntF; function fStmts() { if (!_insF) { _insF = db.prepare('INSERT OR IGNORE INTO ap_followers (slug, actor_uri, inbox, shared_inbox, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)'); _delF = db.prepare('DELETE FROM ap_followers WHERE slug = ? AND actor_uri = ?'); _listF = db.prepare('SELECT inbox, shared_inbox FROM ap_followers WHERE slug = ?'); _cntF = db.prepare('SELECT COUNT(*) n FROM ap_followers WHERE slug = ?'); } return { ins: _insF, del: _delF, list: _listF, cnt: _cntF }; } export function followerCount(slug) { return fStmts().cnt.get(slug).n; } // ── HTTP Signatures + delivery ──────────────────────────────────── const slugFromActorUrl = (url) => { const m = String(url || '').match(/\/ap\/users\/([^/?#]+)/); return m ? decodeURIComponent(m[1]) : null; }; // Sign + POST an activity to a remote inbox (draft-cavage HTTP Signatures, RSA-SHA256). export async function deliver(inboxUrl, bodyObj, keyId, privatePem) { const body = JSON.stringify(bodyObj); const u = new URL(inboxUrl); const date = new Date().toUTCString(); const digest = 'SHA-256=' + crypto.createHash('sha256').update(body).digest('base64'); const signingString = `(request-target): post ${u.pathname}\nhost: ${u.host}\ndate: ${date}\ndigest: ${digest}`; const signature = crypto.sign('sha256', Buffer.from(signingString), privatePem).toString('base64'); const sig = `keyId="${keyId}",algorithm="rsa-sha256",headers="(request-target) host date digest",signature="${signature}"`; const r = await fetch(inboxUrl, { method: 'POST', headers: { 'Content-Type': 'application/activity+json', Accept: 'application/activity+json', Date: date, Digest: digest, Signature: sig }, body, signal: AbortSignal.timeout(8000), }); return r.status; } export async function fetchActor(url) { try { const r = await fetch(url, { headers: { Accept: 'application/activity+json' }, redirect: 'follow', signal: AbortSignal.timeout(8000) }); if (!r.ok) return null; return await r.json(); } catch { return null; } } // Best-effort verification of an incoming signed request. Returns the sender's // actor doc if the signature checks out, else null. (Not gating yet — MVP.) export async function verifyRequest(req) { const sigH = req.headers['signature']; if (!sigH) return null; const p = Object.fromEntries([...sigH.matchAll(/([a-zA-Z]+)="([^"]*)"/g)].map((m) => [m[1], m[2]])); if (!p.keyId || !p.signature) return null; const actor = await fetchActor(p.keyId.split('#')[0]); const pem = actor && actor.publicKey && actor.publicKey.publicKeyPem; if (!pem) return null; const hs = (p.headers || '(request-target) host date').split(/\s+/); const line = hs.map((h) => h === '(request-target)' ? `(request-target): ${req.method.toLowerCase()} ${req.originalUrl}` : `${h}: ${req.headers[h] || ''}`).join('\n'); let ok = false; try { ok = crypto.verify('sha256', Buffer.from(line), pem, Buffer.from(p.signature, 'base64')); } catch { ok = false; } if (ok && hs.includes('digest') && req.rawBody) { const exp = 'SHA-256=' + crypto.createHash('sha256').update(req.rawBody).digest('base64'); if (req.headers['digest'] !== exp) ok = false; } return ok ? actor : null; } // Handle an incoming inbox POST. slugParam = null for the shared /ap/inbox. export async function handleInbox(req, slugParam) { const act = req.body || {}; const type = act.type; const base = (process.env.PUBLIC_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, ''); const verified = await verifyRequest(req).catch(() => null); // best-effort; not gating (MVP) if (type === 'Follow') { const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id); const slug = slugParam || slugFromActorUrl(typeof act.object === 'string' ? act.object : (act.object && act.object.id)); if (!who || !slug) return 400; const remote = await fetchActor(who); if (!remote || !remote.inbox) return 202; // can't reach them → drop quietly fStmts().ins.run(slug, who, remote.inbox, (remote.endpoints && remote.endpoints.sharedInbox) || null); const me = actorId(base, slug); const keys = getOrCreateKeys(slug); const accept = { '@context': 'https://www.w3.org/ns/activitystreams', id: `${me}#accept-${Date.now()}`, type: 'Accept', actor: me, object: act }; deliver(remote.inbox, accept, `${me}#main-key`, keys.private_pem).catch((e) => console.warn('[AP] Accept delivery failed:', e.message)); console.log('[AP] Follow', who, '→', slug, verified ? '(sig ok)' : '(sig unverified)'); return 202; } if (type === 'Undo' && act.object && act.object.type === 'Follow') { const who = typeof act.actor === 'string' ? act.actor : (act.actor && act.actor.id); const obj = act.object.object; const slug = slugParam || slugFromActorUrl(typeof obj === 'string' ? obj : (obj && obj.id)); if (who && slug) { fStmts().del.run(slug, who); console.log('[AP] Unfollow', who, '→', slug); } return 202; } console.log('[AP] inbox', type || 'unknown', '→', slugParam || 'shared', '(ignored)'); return 202; } // Deliver a new post as Create(Note) to all followers' inboxes (fire-and-forget). // Needs PUBLIC_BASE_URL (absolute URLs); no-op without followers or base. export async function deliverCreate(site, post) { const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); if (!base || !site || !site.slug) return; const followers = fStmts().list.all(site.slug); if (!followers.length) return; const inboxes = [...new Set(followers.map((f) => f.shared_inbox || f.inbox).filter(Boolean))]; const keys = getOrCreateKeys(site.slug); const keyId = `${actorId(base, site.slug)}#main-key`; const create = buildCreate(base, site, post); for (const inbox of inboxes) deliver(inbox, create, keyId, keys.private_pem).catch(() => { /* best-effort */ }); } export default { getOrCreateKeys, apWants, sendAP, actorId, noteId, buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, };