source: Klonkt/src/services/guardianship/delivery.js@ 3e7dde2

main
Last change on this file since 3e7dde2 was 3e7dde2, checked in by Robin <roboburr@…>, 2 weeks ago

Een markering op een hulpvraag is een antwoord, dus draagt hij inReplyTo

Barts vraag (26-8): in Berichten staat "Ik kijk hiernaar." maar een tik erop
geeft niets -- welke context heeft dat bericht?

Geen, bleek. De verwijzing bestond wel (shaer:helpPickup draagt de uri van de
hulpvraag, het guardian-dashboard leeft ervan), maar hij overleefde de reis
naar het berichtenscherm niet: ap_mentions heeft er geen kolom voor, dus de
berichtenlezing serveert hem niet. En de tik-route van de clients volgt
inReplyTo -- die de markering niet droeg.

De reparatie is een regel, op de plek die alle wegen delen: deliverDirectNote
vult inReplyTo met de hulpvraag-uri zodra er een helpMark meereist en de
aanroeper niets anders zegt. Semantisch klopt het gewoon -- de markering gaat
ergens over, en inReplyTo is hoe je dat zegt zonder dialect. Een tik opent nu
de draad met de schermafdruk erbij, en andere fediverse-servers threaden hem
net zo goed. De clients hoeven niets.

markerNote (de canonieke vorm in help.js) zegt het ook, zodat de twee niet
uiteenlopen. Een expliciet meegegeven inReplyTo wint; de markering vult alleen
het gat. Toetsen op het gedeelde punt, over de loopback, zonder netwerk.

Co-Authored-By: Claude Opus 5 <noreply@…>

  • Property mode set to 100644
File size: 10.2 KB
Line 
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 */
13import crypto from 'crypto';
14import db from '../../config/database.js';
15import { carriesGuardians } from './context.js';
16
17const PUBLIC = 'https://www.w3.org/ns/activitystreams#Public';
18
19let deps = null;
20/** Called once by ActivityPubService with the shared AP helpers. */
21export function wireDelivery(d) { deps = d; }
22
23// Addressing → visibility. Arrays or bare strings; unknown shapes read as the
24// safest bucket they match.
25export 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).
43export async function deliverDirectNote(site, { recipients, text, html, language, inReplyTo, attachments, helpRequest, wave, awayUntil, helpMark, gateRequest }) {
44 // EEN MARKERING IS EEN ANTWOORD op de hulpvraag waar hij over gaat (Barts
45 // vraag, 26-8: "welke context heeft 'Ik kijk hiernaar' als ik erop klik?" --
46 // geen). De verwijzing reisde al mee als shaer:-veld, maar dat veld haalt de
47 // berichtenlezing niet, en de tik-route van de clients volgt inReplyTo.
48 // Dus zeggen we het ook in gewoon AS2: dan opent een tik de draad met de
49 // schermafdruk erbij, en threaden andere fediverse-servers hem net zo goed.
50 if (!inReplyTo && helpMark && helpMark.noteUri) inReplyTo = helpMark.noteUri;
51 const { actorId, fetchActor, localActor, deliverTo, deriveHandle, escHtml, linkUrls, linkHashtags,
52 getOutboxRow, buildReplyNote, AP_CONTEXT, getOrCreateKeys, deliver, enqueueDelivery } = deps;
53 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
54 const list = [...new Set((recipients || []).filter((u) => /^https?:\/\//i.test(String(u || ''))))].slice(0, 8);
55 if (!base || !site || !site.slug || !list.length || !String(text || '').trim()) return null;
56 const me = actorId(base, site.slug);
57 // Resolve every recipient for a mention anchor + a delivery inbox.
58 const resolved = [];
59 const teapots = [];
60 for (const uri of list) {
61 // An actor we host is read from our own database, not fetched from our own
62 // hostname: that request has to leave the machine and come back, and when
63 // it does not, the recipient is silently dropped from the note. Everything
64 // that decides anything still runs below, for local and remote alike.
65 // ONDERTEKEND ophalen als het onbetekend niet lukt (asSlug). Een instance
66 // met Mastodons secure mode -- infosec.exchange bijvoorbeeld -- antwoordt
67 // 401 op een anonieme GET van het actor-document. Zonder document geen
68 // inbox, dus viel de ontvanger hier stil weg, en met de laatste ontvanger
69 // gaf deliverDirectNote null terug: "502 direct_failed", zonder te zeggen
70 // wie er niet bereikbaar was.
71 //
72 // Dezelfde les als bij het volgen vanaf een boost (Robins melding, 31-7):
73 // die weg kreeg toen signedGetJson, deze niet. fetchActor probeert nog
74 // steeds ONBETEKEND eerst -- dat blijft de veiligheidskeuze -- en tekent
75 // alleen deze ene URL als dat mislukt.
76 const a = (localActor && localActor(uri))
77 || await fetchActor(uri, { asSlug: site.slug }).catch(() => null);
78 if (!a || !(a.inbox || (a.endpoints && a.endpoints.sharedInbox))) continue;
79 // FEP-633c §4.1: an escalation addressed to a "guardian" that carries
80 // guardians of its own goes nowhere. There is no grand-guardian, so we
81 // MUST NOT recurse to that actor's guardians — and we fail SOFTLY: drop
82 // this one target and keep delivering to the rest, because a malformed
83 // guardian must never cost a child the guardians who are fine.
84 //
85 // Only for a call for help. An ordinary direct note is not an escalation,
86 // and a ward is perfectly entitled to message another ward.
87 if (helpRequest && carriesGuardians(a)) { teapots.push(uri); continue; }
88 resolved.push({ uri, inbox: (a.endpoints && a.endpoints.sharedInbox) || a.inbox, local: !!a.local, handle: deriveHandle(uri), url: a.url || uri });
89 }
90 if (teapots.length) console.warn('[AP] not a teapot: escalation dropped for malformed guardian(s)', teapots.join(', '));
91 if (!resolved.length) {
92 // Every guardian was malformed. §4 does not say what to do here because
93 // §4.1 assumes there are others to continue to — but a ward whose whole
94 // safety net is broken has just called for help into nothing, which is the
95 // one outcome this FEP exists to prevent. Say so loudly; the caller can
96 // tell "nobody was reachable" from "nobody was valid".
97 if (teapots.length) console.error('[AP] EVERY guardian of', site.slug, 'is malformed: the call for help reached no one');
98 return null;
99 }
100 const mention = resolved.map((r) => {
101 const disp = r.handle && r.handle[0] === '@' ? r.handle : '@' + (r.handle || '');
102 return `<a href="${escHtml(r.url)}" class="u-url mention" data-actor="${escHtml(r.uri)}">${escHtml(disp)}</a> `;
103 }).join('');
104 // Rijk antwoord: `html` is de HTML uit de reply-editor, hier gesaneerd; `text`
105 // blijft de platte versie (het `source`-veld en de no-JS-fallback). Levert de
106 // sanitizer niets bruikbaars op, dan valt hij terug op de escaped tekst --
107 // een leeggepoetste editor mag geen leeg bericht versturen.
108 const richClean = html ? deps.sanitizeHtml(String(html)) : '';
109 const rich = richClean && deps.htmlToPlainText(richClean).trim() ? richClean : '';
110 const body = escHtml(String(text).trim()).replace(/\r?\n/g, '<br>');
111 // De mention-anker blijft een eigen alinea vooraan: de ontvanger moet in het
112 // bericht genoemd staan, ook als de rijke inhoud met een kop of lijst begint.
113 const content = rich
114 ? `<p>${mention}</p>${linkUrls(linkHashtags(base, rich))}`
115 : `<p>${mention}${linkUrls(linkHashtags(base, body))}</p>`;
116 const lang = /^[a-z]{2,3}(-[A-Za-z0-9-]+)?$/.test(String(language || '')) ? language : null;
117 // Attachments: same rules as deliverReply (own /media/ uploads only,
118 // image/audio/video, max 4) — the help-buoy capture rides this.
119 const media = (Array.isArray(attachments) ? attachments : [])
120 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
121 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
122 .slice(0, 4)
123 .map((a) => ({ url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) }));
124 const id = crypto.randomUUID();
125 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)
126 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,CURRENT_TIMESTAMP)`)
127 .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);
128 const row = getOutboxRow(id);
129 const note = buildReplyNote(base, site, row);
130 // Markering op een hulpvraag (shaer-lgo): een gewone directe note die er een
131 // shaer:-eigenschap bij draagt, net als de zwaai. Zo reist het over dezelfde
132 // bezorging, ziet de ward het als bericht ("er komt iemand"), en houden de
133 // mede-guardians er staat aan over.
134 if (helpMark && helpMark.noteUri) {
135 note[helpMark.kind === 'handled' ? 'shaer:helpHandled' : 'shaer:helpPickup'] = helpMark.noteUri;
136 }
137 // Een kind dat zelf om een poort vraagt (shaer-8ru). Alleen de naam van de
138 // feature reist mee -- geen vrije tekst, zie gatereq.js.
139 if (gateRequest) note['shaer:gateRequest'] = String(gateRequest);
140 const create = {
141 '@context': AP_CONTEXT,
142 id: note.id + '#create', type: 'Create', actor: me,
143 published: note.published, to: note.to, cc: note.cc, object: note,
144 };
145 const keys = getOrCreateKeys(site.slug);
146 const keyId = `${me}#main-key`;
147 let delivered = 0;
148 // A recipient on this machine takes the loopback (deliverToActor), which
149 // hands the Create to the same inbox handler an HTTP POST would reach: the
150 // note is stored, the mention is stored, and a shaer:away on it is applied,
151 // all by the code that does it for everyone else. A hairpin POST to our own
152 // hostname is not that code path, it is a second one that only appears to be.
153 for (const r of resolved.filter((x) => x.local)) {
154 const res = await deliverTo(site, r.uri, create).catch(() => null);
155 if (res && res.delivered) delivered++;
156 }
157 // Remote: one POST per inbox, so two guardians on the same server share it.
158 for (const inbox of [...new Set(resolved.filter((x) => !x.local).map((r) => r.inbox))]) {
159 let ok = false;
160 try { const st = await deliver(inbox, create, keyId, keys.private_pem); ok = st >= 200 && st < 300; } catch { ok = false; }
161 if (ok) delivered++;
162 else enqueueDelivery(site.slug, inbox, create);
163 }
164 console.log('[AP] direct note', site.slug, '→', resolved.length, 'recipient(s), delivered', delivered);
165 return { id, content, delivered, teapots };
166}
167
168export default { wireDelivery, c2sVisibility, deliverDirectNote };
Note: See TracBrowser for help on using the repository browser.