Changeset 5bf63b7 in Klonkt
- Timestamp:
- 06/24/2026 10:35:48 AM (3 months ago)
- Branches:
- main
- Children:
- dd1028a
- Parents:
- 6bd25d1
- Location:
- src
- Files:
-
- 3 edited
-
routes/activitypub.js (modified) (1 diff)
-
routes/posts.js (modified) (2 diffs)
-
services/ActivityPubService.js (modified) (1 diff)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/activitypub.js
r6bd25d1 r5bf63b7 80 80 }); 81 81 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) ── 83 const 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 }); 88 router.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(); } 87 91 }); 88 92 -
src/routes/posts.js
r6bd25d1 r5bf63b7 19 19 import { audioUrl } from '../services/AudioStreamService.js'; 20 20 import { toWebp } from '../services/ImageWebpService.js'; 21 import ActivityPubService from '../services/ActivityPubService.js'; 21 22 22 23 const __dirname = path.dirname(fileURLToPath(import.meta.url)); … … 230 231 ).run(HtmlSanitizerService.toPlainText(cleanContent), title || '', req.session.user.username, postId); 231 232 } 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 } 232 241 } 233 242 -
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)