| 1 | /**
|
|---|
| 2 | * Guardianship (FEP-633c) — the direct-note delivery leg.
|
|---|
| 3 | *
|
|---|
| 4 | * A direct note (private mention, shaer-tqc) is the ward's call-for-help
|
|---|
| 5 | * carrier: addressed to specific actors only, no Public, no followers
|
|---|
| 6 | * fan-out. Moved here from ActivityPubService (guardianship refactor);
|
|---|
| 7 | * behavior is unchanged.
|
|---|
| 8 | *
|
|---|
| 9 | * This module has NO import back into ActivityPubService: the AP helpers it
|
|---|
| 10 | * needs (actor fetch, key material, delivery, note building) are provided
|
|---|
| 11 | * once via wireDelivery(deps) at ActivityPubService load time.
|
|---|
| 12 | */
|
|---|
| 13 | import crypto from 'crypto';
|
|---|
| 14 | import db from '../../config/database.js';
|
|---|
| 15 | import { carriesGuardians } from './context.js';
|
|---|
| 16 |
|
|---|
| 17 | const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
|
|---|
| 18 |
|
|---|
| 19 | let deps = null;
|
|---|
| 20 | /** Called once by ActivityPubService with the shared AP helpers. */
|
|---|
| 21 | export function wireDelivery(d) { deps = d; }
|
|---|
| 22 |
|
|---|
| 23 | // Addressing → visibility. Arrays or bare strings; unknown shapes read as the
|
|---|
| 24 | // safest bucket they match.
|
|---|
| 25 | export function c2sVisibility(object) {
|
|---|
| 26 | const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
|
|---|
| 27 | const to = arr(object.to), cc = arr(object.cc);
|
|---|
| 28 | const isPublic = (x) => x === PUBLIC || x === 'as:Public' || x === 'Public';
|
|---|
| 29 | const isFollowers = (x) => /\/followers\/?$/.test(x);
|
|---|
| 30 | if (to.some(isPublic)) return 'public';
|
|---|
| 31 | if (cc.some(isPublic)) return 'quiet';
|
|---|
| 32 | if (to.some(isFollowers) || cc.some(isFollowers)) return 'friends';
|
|---|
| 33 | if (!to.length && !cc.length) return 'public'; // no addressing at all: legacy client, keep old behavior
|
|---|
| 34 | return 'direct';
|
|---|
| 35 | }
|
|---|
| 36 |
|
|---|
| 37 | // A direct note: a NEW conversation (or a direct reply) addressed to specific
|
|---|
| 38 | // actors only. Stored in ap_outbox with visibility 'direct' + the recipient
|
|---|
| 39 | // list, delivered to exactly those inboxes: no followers fan-out, no Public,
|
|---|
| 40 | // so no boosts and no timelines. The same S2S leg a Mastodon DM takes, so a
|
|---|
| 41 | // guardian on any instance receives it as a private mention (the ward
|
|---|
| 42 | // call-for-help path).
|
|---|
| 43 | export async function deliverDirectNote(site, { recipients, text, language, inReplyTo, attachments, helpRequest, wave, awayUntil }) {
|
|---|
| 44 | const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
|
|---|
| 45 | getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
|
|---|
| 46 | const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
|
|---|
| 47 | const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
|
|---|
| 48 | if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
|
|---|
| 49 | const me = actorId(base, site.slug);
|
|---|
| 50 | // Resolve every recipient for a mention anchor + a delivery inbox.
|
|---|
| 51 | const resolved = [];
|
|---|
| 52 | const teapots = [];
|
|---|
| 53 | for (const uri of list) {
|
|---|
| 54 | // An actor we host is read from our own database, not fetched from our own
|
|---|
| 55 | // hostname: that request has to leave the machine and come back, and when
|
|---|
| 56 | // it does not, the recipient is silently dropped from the note. Everything
|
|---|
| 57 | // that decides anything still runs below, for local and remote alike.
|
|---|
| 58 | const a = (localActor && localActor(uri)) || await fetchActor(uri).catch(() => null);
|
|---|
| 59 | if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
|
|---|
| 60 | // FEP-633c §4.1: an escalation addressed to a "guardian" that carries
|
|---|
| 61 | // guardians of its own goes nowhere. There is no grand-guardian, so we
|
|---|
| 62 | // MUST NOT recurse to that actor's guardians — and we fail SOFTLY: drop
|
|---|
| 63 | // this one target and keep delivering to the rest, because a malformed
|
|---|
| 64 | // guardian must never cost a child the guardians who are fine.
|
|---|
| 65 | //
|
|---|
| 66 | // Only for a call for help. An ordinary direct note is not an escalation,
|
|---|
| 67 | // and a ward is perfectly entitled to message another ward.
|
|---|
| 68 | if (helpRequest && carriesGuardians(a)) { teapots.push(uri); continue; }
|
|---|
| 69 | resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri });
|
|---|
| 70 | }
|
|---|
| 71 | if (teapots.length) console.warn('[AP] not a teapot: escalation dropped for malformed guardian(s)', teapots.join(', '));
|
|---|
| 72 | if (!resolved.length) {
|
|---|
| 73 | // Every guardian was malformed. §4 does not say what to do here because
|
|---|
| 74 | // §4.1 assumes there are others to continue to — but a ward whose whole
|
|---|
| 75 | // safety net is broken has just called for help into nothing, which is the
|
|---|
| 76 | // one outcome this FEP exists to prevent. Say so loudly; the caller can
|
|---|
| 77 | // tell "nobody was reachable" from "nobody was valid".
|
|---|
| 78 | if (teapots.length) console.error('[AP] EVERY guardian of', site.slug, 'is malformed: the call for help reached no one');
|
|---|
| 79 | return null;
|
|---|
| 80 | }
|
|---|
| 81 | const mention = resolved.map((r) => {
|
|---|
| 82 | const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
|
|---|
| 83 | return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
|
|---|
| 84 | }).join('');
|
|---|
| 85 | const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
|
|---|
| 86 | const content = `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
|
|---|
| 87 | const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
|
|---|
| 88 | // Attachments: same rules as deliverReply (own /media/ uploads only,
|
|---|
| 89 | // image/audio/video, max 4) — the help-buoy capture rides this.
|
|---|
| 90 | const media = (Array.isArray(attachments) ? attachments : [])
|
|---|
| 91 | .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
|
|---|
| 92 | && /^(image|audio|video)\//.test(String(a.mediaType || '')))
|
|---|
| 93 | .slice(0, 4)
|
|---|
| 94 | .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
|
|---|
| 95 | const id = crypto.randomUUID();
|
|---|
| 96 | 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, wave, away_until, created_at)
|
|---|
| 97 | VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
|
|---|
| 98 | .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, wave ? 1 : 0, awayUntil || null);
|
|---|
| 99 | const row = getOutboxRow(id);
|
|---|
| 100 | const note = buildReplyNote(base, site, row);
|
|---|
| 101 | const create = {
|
|---|
| 102 | '@context': AP_CONTEXT,
|
|---|
| 103 | id: note.id + '#create', type: 'Create', actor: me,
|
|---|
| 104 | published: note.published, to: note.to, cc: note.cc, object: note,
|
|---|
| 105 | };
|
|---|
| 106 | const keys = getOrCreateKeys(site.slug);
|
|---|
| 107 | const keyId = `${me}#main-key`;
|
|---|
| 108 | let delivered = 0;
|
|---|
| 109 | // A recipient on this machine takes the loopback (deliverToActor), which
|
|---|
| 110 | // hands the Create to the same inbox handler an HTTP POST would reach: the
|
|---|
| 111 | // note is stored, the mention is stored, and a shaer:away on it is applied,
|
|---|
| 112 | // all by the code that does it for everyone else. A hairpin POST to our own
|
|---|
| 113 | // hostname is not that code path, it is a second one that only appears to be.
|
|---|
| 114 | for (const r of resolved.filter((x) => x.local)) {
|
|---|
| 115 | const res = await deliverTo(site, r.uri, create).catch(() => null);
|
|---|
| 116 | if (res && res.delivered) delivered++;
|
|---|
| 117 | }
|
|---|
| 118 | // Remote: one POST per inbox, so two guardians on the same server share it.
|
|---|
| 119 | for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
|
|---|
| 120 | let ok = false;
|
|---|
| 121 | try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
|
|---|
| 122 | if (ok) delivered++;
|
|---|
| 123 | else enqueueDelivery(site.slug, inbox, create);
|
|---|
| 124 | }
|
|---|
| 125 | console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
|
|---|
| 126 | return { id, content, delivered, teapots };
|
|---|
| 127 | }
|
|---|
| 128 |
|
|---|
| 129 | export default { wireDelivery, c2sVisibility, deliverDirectNote };
|
|---|