Changeset 5bf63b7 in Klonkt for src/services/ActivityPubService.js
- Timestamp:
- 06/24/2026 10:35:48 AM (3 months ago)
- Branches:
- main
- Children:
- dd1028a
- Parents:
- 6bd25d1
- File:
-
- 1 edited
-
src/services/ActivityPubService.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r6bd25d1 r5bf63b7 153 153 } 154 154 155 // ── followers store (lazy stmts) ────────────────────────────────── 156 let _insF, _delF, _listF, _cntF; 157 function 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 } 166 export function followerCount(slug) { return fStmts().cnt.get(slug).n; } 167 168 // ── HTTP Signatures + delivery ──────────────────────────────────── 169 const 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). 172 export 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 189 export 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.) 199 export 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. 221 export 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. 254 export 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 155 266 export default { 156 267 getOrCreateKeys, apWants, sendAP, actorId, noteId, 157 268 buildActor, buildNote, buildCreate, buildOutbox, buildFollowers, 269 followerCount, deliver, fetchActor, verifyRequest, handleInbox, deliverCreate, 158 270 };
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)