Changeset 6b5d7da in Klonkt for src/services/ActivityPubService.js
- Timestamp:
- 07/24/2026 04:56:51 PM (7 weeks ago)
- Branches:
- main
- Children:
- e61c289
- Parents:
- 9a00f28
- File:
-
- 1 edited
-
src/services/ActivityPubService.js (modified) (11 diffs)
Legend:
- Unmodified
- Added
- Removed
-
src/services/ActivityPubService.js
r9a00f28 r6b5d7da 25 25 import { getTenancy } from './SettingsService.js'; 26 26 import { t as i18nT } from './i18n.js'; 27 import Blocklist from './BlocklistService.js'; 28 import * as Guardianship from './guardianship/index.js'; 27 29 28 30 const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public'; … … 49 51 // Question stays valid JSON-LD (a strict processor would otherwise drop votersCount). 50 52 votersCount: 'toot:votersCount', 51 // FEP-633c (Guardians): the shaer namespace . helpRequest marks a direct52 // note as a ward's call for help (spec 5.2.1); ignorable by everyone else.53 shaer: 'https://ns.klonkt.com/shaer#',53 // FEP-633c (Guardians): the shaer namespace, owned by the guardianship 54 // module (src/services/guardianship/). 55 ...Guardianship.SHAER_CONTEXT, 54 56 }, 55 57 ]; … … 174 176 // separate state. 175 177 blocked: `${id}/blocked`, 178 // FEP-633c §2: shaer:guardians / shaer:isGuardian / shaer:queues 179 // (guardianship module owns these). 180 ...Guardianship.guardianshipActorProps(id, site.slug), 176 181 // C2S clients (Shaer apps) discover auth + upload here — no hardcoded paths. 177 182 // All four are ActivityPub-spec `endpoints` terms. Dynamic client registration … … 270 275 cc: post.visibility === 'direct' ? [] : [PUBLIC, `${meR}/followers`], 271 276 // FEP-633c 5.2.1: a ward's call for help. Only ever on direct notes. 272 'shaer:helpRequest': (post.visibility === 'direct' && post.help_request) ? true : undefined,277 ...Guardianship.helpRequestProps(post), 273 278 tag: [ 274 279 ...mentionTags(post.content), … … 1303 1308 // Blocked actor/domain → silently drop (202, don't reveal the block). 1304 1309 if (claimedActor && isBlockedAny(claimedActor)) { console.log('[AP] inbox dropped (blocked)', claimedActor, 'from', ip); return 202; } 1305 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag' ];1310 const GATED = ['Create', 'Like', 'Announce', 'Follow', 'Delete', 'Undo', 'Accept', 'Reject', 'Add', 'Remove', 'Update', 'Flag', 'Offer']; 1306 1311 if (GATED.includes(type)) { 1307 1312 if (!verified || !claimedActor || verified.id !== claimedActor) { 1308 1313 console.warn('[AP] inbox REJECTED (signature)', type, claimedActor || '?', 'from', ip, verified ? '(signer mismatch)' : '(unsigned/invalid)'); 1309 1314 return 401; 1315 } 1316 } 1317 1318 // FEP-633c: the adoption handshake. An Offer lands at the local ward; an 1319 // Accept/Reject answers an offer a local guardian sent. Anything the 1320 // guardianship module does not recognize falls through to the old paths. 1321 if (type === 'Offer' || type === 'Accept' || type === 'Reject') { 1322 let gslug = slugParam || null; 1323 if (!gslug && type === 'Offer') { 1324 const rel = Guardianship.parseRelationship(act.object); 1325 if (rel) gslug = slugFromActorUrl(rel.ward); 1326 } 1327 if (!gslug) { 1328 const offerId = typeof act.object === 'string' ? act.object : (act.object && act.object.id); 1329 const rows = offerId ? Guardianship.findByOfferId?.(offerId) || [] : []; 1330 if (rows.length) gslug = rows[0].slug; 1331 if (!gslug) for (const t of (Array.isArray(act.to) ? act.to : (act.to ? [act.to] : []))) { 1332 const s = slugFromActorUrl(t); if (s) { gslug = s; break; } 1333 } 1334 } 1335 if (gslug) { 1336 const gsite = db.prepare('SELECT * FROM sites WHERE slug = ?').get(gslug); 1337 if (gsite && await Guardianship.handleGuardianshipInbox(gsite, act).catch(() => false)) { 1338 console.log('[AP] guardianship', type, 'for', gslug, 'from', claimedActor); 1339 return 202; 1340 } 1310 1341 } 1311 1342 } … … 1463 1494 const ai = actorInfo(await resolveActor(actorUri), actorUri); 1464 1495 const html = HtmlSanitizerService.sanitize(o.content || ''); 1496 // FEP-633c 5.2.1: a ward's call for help rides a direct mention; the 1497 // flag is stored so the Guardian PWA's message centre can list it. 1498 const help = Guardianship.isHelpRequest(o); 1465 1499 for (const slug of slugs) { 1466 1500 try { 1467 const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)')1468 .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null );1501 const r = db.prepare('INSERT OR IGNORE INTO ap_mentions (slug, object_uri, note_url, actor_uri, actor_name, actor_handle, actor_icon, actor_url, content, published, help_request, created_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)') 1502 .run(slug, o.id, safeUrl(o.url) || null, actorUri, ai.name, ai.handle, ai.icon, ai.url, html, o.published || null, help ? 1 : 0); 1469 1503 if (r.changes) { 1470 console.log('[AP] mention', actorUri, '→', slug );1504 console.log('[AP] mention', actorUri, '→', slug, help ? '(help request)' : ''); 1471 1505 const vis = noteVisibility(o); 1472 1506 const priv = vis === 'direct' || vis === 'followers'; … … 1474 1508 const who = ai.name || ai.handle || i18nT(L, 'notif.someone'); 1475 1509 // Same privacy rule as replies: private mentions push without content. 1476 if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` }); 1510 // A help request pushes as its own alert type, aimed at the 1511 // Guardian PWA's message centre. 1512 if (help) pushEvent(slug, { type: 'help', title: i18nT(L, 'push.n_help_t'), body: i18nT(L, 'push.n_help_b', { who }), url: '/guardian' }); 1513 else if (priv) pushEvent(slug, { type: 'dm', title: i18nT(L, 'push.n_dm_t'), body: i18nT(L, 'push.n_dm_b', { who }), url: `${pushPrefix(slug)}/messages` }); 1477 1514 else pushEvent(slug, { type: 'reply', title: i18nT(L, 'push.n_mention_t'), body: `${who}: ${HtmlSanitizerService.toPlainText(html).slice(0, 90)}`, url: `${pushPrefix(slug)}/messages` }); 1478 1515 } … … 1959 1996 if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; } 1960 1997 if (Array.isArray(type)) type = type.find((t) => typeof t === 'string'); 1998 1999 // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship 2000 // Relationship) belongs to the guardianship module; anything else falls 2001 // through to the switch below. 2002 if (type === 'Offer' || type === 'Accept' || type === 'Reject') { 2003 const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null); 2004 if (g) return g; 2005 } 1961 2006 1962 2007 try { … … 2097 2142 } 2098 2143 2099 // Addressing → visibility. Arrays or bare strings; unknown shapes read as the 2100 // safest bucket they match. 2101 export function c2sVisibility(object) { 2102 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string'); 2103 const to = arr(object.to), cc = arr(object.cc); 2104 const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public'; 2105 const isFollowers = (x) => /\/followers\/?$/.test(x); 2106 if (to.some(isPublic)) return 'public'; 2107 if (cc.some(isPublic)) return 'quiet'; 2108 if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends'; 2109 if (!to.length && !cc.length) return 'public'; // no addressing at all: legacy client, keep old behavior 2110 return 'direct'; 2111 } 2112 2113 // A direct note (private mention, shaer-tqc): a NEW conversation (or a direct 2114 // reply) addressed to specific actors only. Stored in ap_outbox with 2115 // visibility 'direct' + the recipient list, delivered to exactly those 2116 // inboxes: no followers fan-out, no Public, so no boosts and no timelines. 2117 // The same S2S leg a Mastodon DM takes, so a guardian on any instance 2118 // receives it as a private mention (the ward call-for-help path). 2119 export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest }) { 2120 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 2121 const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8); 2122 if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null; 2123 const me = actorId(base, site.slug); 2124 // Resolve every recipient for a mention anchor + a delivery inbox. 2125 const resolved = []; 2126 for (const uri of list) { 2127 const a = await fetchActor(uri).catch(() => null); 2128 if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue; 2129 resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, handle: deriveHandle(uri), url: a.url || uri }); 2130 } 2131 if (!resolved.length) return null; 2132 const mention = resolved.map((r) => { 2133 const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || ''); 2134 return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `; 2135 }).join(''); 2136 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>'); 2137 const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`; 2138 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null; 2139 // Attachments: same rules as deliverReply (own /media/ uploads only, 2140 // image/audio/video, max 4) — the help-buoy capture rides this. 2141 const media = (Array.isArray(attachments) ? attachments : []) 2142 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url) 2143 && /^(image|audio|video)\//.test(String(a.mediaType || ''))) 2144 .slice(0, 4) 2145 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) })); 2146 const id = crypto.randomUUID(); 2147 db.prepare(`INSERT INTO ap_outbox (id, site_slug, post_id, post_slug, in_reply_to, to_actor, to_handle, content, language, attachments, visibility, to_actors, help_request, created_at) 2148 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`) 2149 .run(id, site.slug, '', null, inReplyTo || null, resolved[0].uri, resolved[0].handle, content, lang, media.length ? JSON.stringify(media) : null, 'direct', JSON.stringify(resolved.map((r) => r.uri)), helpRequest ? 1 : 0); 2150 const row = iStmts().getO.get(id); 2151 const note = buildReplyNote(base, site, row); 2152 const create = { 2153 '@context': AP_CONTEXT, 2154 id: note.id + '#create', type: 'Create', actor: me, 2155 published: note.published, to: note.to, cc: note.cc, object: note, 2156 }; 2157 const keys = getOrCreateKeys(site.slug); 2158 const keyId = `${me}#main-key`; 2159 let delivered = 0; 2160 for (const inbox of [...new Set(resolved.map((r) => r.inbox))]) { 2161 let ok = false; 2162 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; } 2163 if (ok) delivered++; 2164 else enqueueDelivery(site.slug, inbox, create); 2165 } 2166 console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered); 2167 return { id, content, delivered }; 2168 } 2144 // The direct-note leg (ward call-for-help) lives in the guardianship module 2145 // (src/services/guardianship/delivery.js); wired with our AP helpers at the 2146 // bottom of this file. Re-exported so every existing caller keeps working. 2147 export const c2sVisibility = Guardianship.c2sVisibility; 2148 export const deliverDirectNote = Guardianship.deliverDirectNote; 2169 2149 2170 2150 // Send a reply FROM this site to a remote actor (in reply to their inbound reply). … … 3026 3006 3027 3007 // ── Blocking / defederation ─────────────────────────────────────── 3028 let _insBl, _delBl, _listBl; 3029 function blStmts() { 3030 if (!_insBl) { 3031 _insBl = db.prepare('INSERT OR IGNORE INTO ap_blocks (slug, target, kind, label, created_at) VALUES (?,?,?,?,CURRENT_TIMESTAMP)'); 3032 _delBl = db.prepare('DELETE FROM ap_blocks WHERE slug = ? AND target = ?'); 3033 _listBl = db.prepare('SELECT * FROM ap_blocks WHERE slug = ? ORDER BY created_at DESC'); 3034 } 3035 return { ins: _insBl, del: _delBl, list: _listBl }; 3036 } 3037 export function listBlocks(slug) { return blStmts().list.all(slug); } 3008 // Extracted to BlocklistService (shared: Klonkt's Block tab + Shaer's "in 3009 // Orbit"). Thin delegations keep every existing caller working. 3010 export function listBlocks(slug) { return Blocklist.listBlocks(slug); } 3038 3011 3039 3012 // True if an actor (or its whole domain) is blocked anywhere on this instance. … … 3140 3113 } 3141 3114 3142 export function isBlockedAny(actorUri) { 3143 if (!actorUri) return false; 3144 let domain = ''; try { domain = new URL(actorUri).host; } catch { /* ignore */ } 3145 try { return !!db.prepare("SELECT 1 FROM ap_blocks WHERE (kind='actor' AND target=?) OR (kind='domain' AND target=?) LIMIT 1").get(actorUri, domain); } 3146 catch { return false; } 3147 } 3148 3149 function purgeBlocked(kind, target) { 3115 export function isBlockedAny(actorUri) { return Blocklist.isBlockedAny(actorUri); } 3116 3117 // Block an actor (@handle or actor URL) or a whole domain; purges their content. 3118 // The handle resolver is ours; the storage/purge lives in BlocklistService. 3119 export async function blockTarget(site, input) { return Blocklist.blockTarget(site, input, webfingerResolve); } 3120 3121 export function unblock(site, target) { return Blocklist.unblock(site, target); } 3122 3123 // ── Guardianship module wiring (src/services/guardianship/) ──────── 3124 // The module owns FEP-633c (context, relations, handshake, queues, the 3125 // direct-note leg); we hand it our AP helpers ONCE and delegate. It never 3126 // imports us back. 3127 function selfActorId(slug) { 3128 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''); 3129 return actorId(base, slug); 3130 } 3131 // Deliver one activity to one actor's inbox, signed; queued on failure. 3132 async function deliverToActor(site, actorUri, activity) { 3133 const a = await fetchActor(actorUri).catch(() => null); 3134 const inbox = a && (a.inbox || (a.endpoints && a.endpoints.sharedInbox)); 3135 if (!inbox) return false; 3136 const me = selfActorId(site.slug); 3137 const keys = getOrCreateKeys(site.slug); 3138 const payload = { '@context': AP_CONTEXT, ...activity }; 3150 3139 try { 3151 if (kind === 'domain') { 3152 // Exact host match (a URL LIKE over-/under-matches: it misses bare-domain or :port 3153 // actor URIs and can catch look-alikes). Filter by parsed host, same as isBlockedAny. 3154 const purge = (table, col) => { 3155 let rows = []; 3156 try { rows = db.prepare(`SELECT DISTINCT ${col} AS u FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`).all(); } catch { return; } 3157 const del = db.prepare(`DELETE FROM ${table} WHERE ${col} = ?`); 3158 for (const r of rows) { let h = ''; try { h = new URL(r.u).host; } catch { /* skip */ } if (h === target) { try { del.run(r.u); } catch { /* ignore */ } } } 3159 }; 3160 purge('ap_interactions', 'actor_uri'); 3161 purge('ap_timeline', 'author_uri'); 3162 purge('ap_followers', 'actor_uri'); 3163 } else { 3164 db.prepare('DELETE FROM ap_interactions WHERE actor_uri = ?').run(target); 3165 db.prepare('DELETE FROM ap_timeline WHERE author_uri = ?').run(target); 3166 db.prepare('DELETE FROM ap_followers WHERE actor_uri = ?').run(target); 3167 } 3168 } catch { /* best-effort */ } 3169 } 3170 3171 // Block an actor (@handle or actor URL) or a whole domain; purges their content. 3172 export async function blockTarget(site, input) { 3173 const raw = String(input || '').trim(); 3174 if (!site || !site.slug || !raw) return { error: 'empty' }; 3175 let kind, target, label; 3176 if (/^https?:\/\//i.test(raw)) { kind = 'actor'; target = raw; label = raw; } 3177 else if (raw.includes('@')) { 3178 const actorUrl = await webfingerResolve(raw); 3179 if (!actorUrl) return { error: 'not_found' }; 3180 kind = 'actor'; target = actorUrl; label = raw.startsWith('@') ? raw : ('@' + raw); 3181 } else { kind = 'domain'; target = raw.toLowerCase(); label = raw.toLowerCase(); } 3182 blStmts().ins.run(site.slug, target, kind, label); 3183 purgeBlocked(kind, target); 3184 console.log('[AP] block', site.slug, kind, target); 3185 return { ok: true, label }; 3186 } 3187 3188 export function unblock(site, target) { blStmts().del.run(site.slug, target); return { ok: true }; } 3140 const st = await deliver(inbox, payload, `${me}#main-key`, keys.private_pem); 3141 if (st >= 200 && st < 300) return true; 3142 } catch { /* fall through to the queue */ } 3143 enqueueDelivery(site.slug, inbox, payload); 3144 return true; // queued: it will arrive 3145 } 3146 Guardianship.wireDelivery({ 3147 actorId, fetchActor, deriveHandle, escHtml, linkUrls, linkHashtags, 3148 getOutboxRow: (id) => iStmts().getO.get(id), 3149 buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery, 3150 }); 3151 Guardianship.wireHandshake({ 3152 selfId: selfActorId, 3153 deliverTo: deliverToActor, 3154 deriveHandle, 3155 // Guardian PWA push: an offer or an answer lands as a notification. 3156 onEvent: (slug, ev) => { 3157 const L = pushLang(slug); 3158 const texts = { 3159 offer_received: ['push.n_guard_offer_t', 'push.n_guard_offer_b'], 3160 ward_accepted: ['push.n_guard_ward_t', 'push.n_guard_ward_b'], 3161 }[ev.kind]; 3162 if (!texts) return; 3163 const who = deriveHandle(ev.candidate || ev.ward || ev.guardian || '') || '?'; 3164 pushEvent(slug, { type: 'guardian', title: i18nT(L, texts[0]), body: i18nT(L, texts[1], { who }), url: '/guardian' }); 3165 }, 3166 }); 3189 3167 3190 3168 export default {
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)