source: Klonkt/src/services/guardianship/delivery.js@ 407353e

main
Last change on this file since 407353e was 407353e, checked in by Bart <bart@…>, 7 hours ago

Een foto zonder woorden is een bericht (502 direct_failed)

deliverDirectNote begon met !String(text).trim() -> return null, en de inname
maakte van dat null een 502 direct_failed. Sinds de inname een bijlage-only
note doorlaat waren die twee lagen het oneens over wat een bericht is: wie een
foto op een gesprek liet vallen en niets typte kwam precies daar vast te
zitten. Het viel niet op in de tests, want dit was de enige plek waar de regel
stond en hij stond midden in een functie die netwerk doet.

Nu is het een eigen, pure regel -- text, html of bijlage -- met tests eromheen,
zoals de rest van deze laag het doet. Leeg blijft geweigerd: geen tekst, geen
opmaak en geen bijlage is niets.

En String(text) werd String(text || ''): zonder tekst stond er anders
letterlijk "undefined" in de body zodra een aanroeper het veld wegliet.

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

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