source: Klonkt/src/services/ap-c2s.js@ 2165b6d

main
Last change on this file since 2165b6d was 0d6d266, checked in by Robin <roboburr@…>, 13 days ago

C2S-antwoord: alle mentions van de client, niet alleen de ouder-auteur

Robins bugreport, 26-8: er gingen er drie de deur uit, er stond er een
gepubliceerd.

Geen filter, maar een schakelaar die nooit werd omgezet. deliverReply
kent de mentions-balk van het web: een ARRAY betekent "precies deze
mensen", undefined betekent "oud gedrag -- noem de auteur van de ouder".
De C2S-inname gaf dat veld helemaal niet mee, dus elk antwoord uit een
app viel terug op dat oude gedrag. Dezelfde schakelaar stuurt drie
dingen tegelijk aan, en ze gingen dus alle drie mee: de ankers vooraan
de inhoud, de Mention-tags (die worden uit die ankers afgeleid) en de
inboxen waar bezorgd wordt.

De tags zijn de bron en niet to/cc: daar staan ook de volgerscollectie
en Public in, en dat zijn geen mensen. Ontdubbeld op actor, want de
ouder-auteur zit meestal ook in de tags. Zonder tags blijft het bij het
oude gedrag -- een lege lijst zou "niemand noemen" betekenen, en dat is
een keuze die een client die geen tags kent nooit gemaakt heeft.

Drie toetsen, met tegenbewijs: de twee over het nieuwe gedrag vallen
tegen de code van hiervoor, en die over het oude gedrag slaagt in
allebei -- dat laatste is het bewijs dat het bestaande pad niet
verlegd is. Volle suite 1234 groen.

  • Property mode set to 100644
File size: 28.0 KB
Line 
1/**
2 * ap-c2s.js — de Client-to-Server-inname (stap 4 van shaer-drc).
3 *
4 * De C2S-tegenhanger van handleInbox: een eigen client (Shaer, R9999) POST een
5 * activity op de outbox en dit blok vertaalt hem naar DEZELFDE machinerie die
6 * het web gebruikt (deliverReply, sendInteraction, followActor, deliverCreate).
7 *
8 * Anders dan ap-transport is dit een COORDINATOR: hij roept de dienstlaag aan,
9 * en de regel van shaer-drc verbiedt een import uit ActivityPubService.js.
10 * Daarom het patroon dat guardianship al bewees: de dienstlaag geeft zijn
11 * werktuigen bij het laden door via wireC2S, en de verhuisde functies staan
12 * hier byte-voor-byte ongewijzigd -- ze merken niet dat hun buren injectie
13 * werden. Wat WEL rechtstreeks geimporteerd wordt, wijst omlaag: db, ap-core,
14 * de sanitizer en guardianship.
15 */
16import crypto from 'crypto';
17import fs from 'fs';
18import path from 'path';
19import db from '../config/database.js';
20import HtmlSanitizerService from './HtmlSanitizerService.js';
21import * as Guardianship from './guardianship/index.js';
22import { PUBLIC, actorId } from './ap-core.js';
23
24// Dezelfde twee als de re-exports in ActivityPubService: het directe-note-been
25// en de zichtbaarheidsregel wonen in guardianship, hier alleen kortgesloten
26// zodat de verhuisde regels ongewijzigd blijven.
27const c2sVisibility = Guardianship.c2sVisibility;
28const deliverDirectNote = Guardianship.deliverDirectNote;
29
30// De werktuigen uit de dienstlaag. ActivityPubService vult ze onderaan zijn
31// eigen evaluatie met wireC2S -- ruim voordat er een verzoek kan binnenkomen.
32// Een aanroep VOOR de koppeling is een programmeerfout en mag hard vallen.
33let proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
34 postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
35 followActor, unfollowActor, blockTarget, unblock, deliverDelete,
36 deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
37 deliverCreate;
38export function wireC2S(deps) {
39 ({ proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
40 postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
41 followActor, unfollowActor, blockTarget, unblock, deliverDelete,
42 deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
43 deliverCreate } = deps);
44}
45
46// ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
47// The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
48// activity here and we translate it onto the SAME delivery machinery the web UI
49// uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
50// { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
51const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
52
53export async function ingestOutboxActivity(site, user, activity) {
54 const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
55 if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
56
57 // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
58 let type = activity.type;
59 let object = activity.object;
60 if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
61 if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
62
63 // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
64 // Relationship) belongs to the guardianship module; anything else falls
65 // through to the switch below.
66 if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
67 const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
68 if (g) return g;
69 }
70 // Een gate-voorstel uit de app (5.6, shaer-8ru): een Offer van een
71 // shaer:GatedSetting, de vorm die 5.6 al beschrijft.
72 //
73 // HIER, EN GEEN `case` IN DE SWITCH. Dat was hij eerst, en die claimde ELKE
74 // Offer: wat geen gate-voorstel was kreeg 400 unsupported_offer -- ook de
75 // adoptie-handshake, en straks elke Offer-vorm die we nog toevoegen. Barts
76 // honderd aanbiedingen liepen er meteen op stuk. Alleen claimen wat je
77 // herkent, en de rest laten doorlopen.
78 if (type === 'Offer') {
79 const gs = Guardianship.gated.parseGatedSetting(activity.object);
80 if (gs) {
81 const uit = proposeGate(site, gs.ward, gs.feature, gs.value);
82 return uit.status === 200 ? { ...uit, status: 201, id: uit.offerId } : uit;
83 }
84 }
85
86 try {
87 switch (type) {
88 case 'Create': {
89 if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
90 // Innamepoorten (shaer-ahy.1, 8-8): wat de ward niet mag versturen
91 // wordt HIER geweigerd, niet in de app verstopt -- een knop die de
92 // client alleen verbergt is geen poort. De reddingsboei gaat ALTIJD
93 // voor: een hulpvraag aan de guardians mag door elke dichte deur heen,
94 // anders sluit een messages-poort precies het kanaal af dat het kind
95 // veilig houdt.
96 {
97 const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
98 const isHelp = object['shaer:helpRequest'] === true || object.helpRequest === true;
99 // Een poortverzoek van het kind zelf (shaer-8ru) gaat langs de
100 // messages-poort. Dat lijkt een gat en is het niet: het verzoek draagt
101 // ALLEEN de naam van de feature, geen vrije tekst, dus er ontstaat geen
102 // kanaal om omheen die poort te praten. Zonder deze uitzondering kan
103 // een kind met berichten dicht nergens meer om vragen -- en dan is de
104 // hele weg dood op precies het moment dat hij nodig is.
105 const isGateReq = !!Guardianship.gatereq.parseRequest(object);
106 const direct = c2sVisibility(object) === 'direct';
107 if (!isHelp && !isGateReq) {
108 if (direct && !Guardianship.wardGateAllowed(site.gate_messages, isWard)) {
109 return { status: 403, error: 'gated_messages' };
110 }
111 if (!direct && !object.inReplyTo && !Guardianship.wardGateAllowed(site.gate_compose, isWard)) {
112 return { status: 403, error: 'gated_compose' };
113 }
114 // Meedoen aan een gesprek is ook iets (Bart, 8-8). Hier stond de
115 // aanname dat een antwoord geen eigen podium is en dus onder compose
116 // door mocht. Dat is teruggedraaid: antwoorden heeft een EIGEN poort,
117 // los van compose in beide richtingen -- je kunt willen dat een kind
118 // meepraat zonder podium, en ook andersom.
119 //
120 // Geldt ook voor een DIRECT antwoord, bovenop de messages-poort: een
121 // privé-antwoord is allebei, en dan mag allebei hem tegenhouden.
122 if (object.inReplyTo && !Guardianship.wardGateAllowed(site.gate_replies, isWard)) {
123 return { status: 403, error: 'gated_replies' };
124 }
125 }
126 }
127 // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
128 // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
129 const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
130 // A picture (or a recording) can be the whole message: media-only
131 // notes pass here; c2sCreatePost validates the attachments themselves.
132 if (!plain.trim() && !object.content && !(Array.isArray(object.attachment) && object.attachment.length)) {
133 return { status: 400, error: 'empty_note' };
134 }
135 // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
136 // outbox machinery to the addressed inboxes only; shows under Messages.
137 if (c2sVisibility(object) === 'direct') {
138 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
139 const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
140 .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
141 if (!recipients.length) return { status: 400, error: 'no_recipients' };
142 // AS2 attachments (e.g. the help-buoy capture, uploaded via
143 // uploadMedia): normalize our own absolute /media/ URLs to relative
144 // so the deliverReply-style validation applies unchanged.
145 const atts = (Array.isArray(object.attachment) ? object.attachment : [])
146 .map((a) => a && typeof a === 'object' ? {
147 url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
148 mediaType: String(a.mediaType || ''),
149 name: String(a.name || '').slice(0, 120),
150 } : null)
151 .filter(Boolean);
152 const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
153 // FEP-633c 3.6.1: a guardian here declaring itself away to its
154 // wards. An away without a (future) end fails loudly, exactly as
155 // the daemon refuses it: stored quietly it would be a nominal
156 // guardian holding a seat.
157 let awayUntil = null;
158 if (Guardianship.availability.isAway(object)) {
159 awayUntil = Guardianship.availability.parseEndTime(object.endTime);
160 if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
161 // No local shortcut here: the note below reaches a ward on this
162 // instance through the loopback, and its inbox handler applies the
163 // absence like it does for a ward anywhere else. One path.
164 }
165 const gateReq = Guardianship.gatereq.parseRequest(object);
166 // Een hulpvraag oppikken of afsluiten vanuit de app (5.2.1, shaer-lgo).
167 // De markering IS al een gewone directe note met een shaer:-eigenschap,
168 // dus hier hoeft niets nieuws bij: de app stuurt precies wat de PWA
169 // stuurt, en het gaat over dezelfde bezorging naar de mede-guardians.
170 //
171 // We boeken hem ook LOKAAL. Zonder dat zou de guardian die de knop
172 // indrukt zijn eigen markering pas zien als hij bij zichzelf
173 // terugkomt -- en die weg bestaat niet.
174 const mark = Guardianship.help.parseMarker(object);
175 if (mark) {
176 const base2 = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
177 // Met de VOLLEDIGE handle. Hier stond `@${site.slug}` -- zonder host,
178 // dus een derde vorm naast de kale URI van de PWA-route en de echte
179 // handle die een binnengekomen markering draagt. Drie spellingen van
180 // dezelfde naam, en "door wie" was de hele vraag van shaer-lgo.
181 const mij = actorId(base2, site.slug);
182 Guardianship.help.record(mark.noteUri, mij, mark.kind, deriveHandle(mij));
183 }
184 const r = await deliverDirectNote(site, { recipients, text: plain, language: object.language || null, inReplyTo: typeof object.inReplyTo === 'string' ? object.inReplyTo : null, attachments: atts, helpRequest: help, awayUntil, gateRequest: gateReq && gateReq.feature, helpMark: mark });
185 if (!r || !r.id) return { status: 502, error: 'direct_failed' };
186 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
187 }
188 if (object.inReplyTo) {
189 const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo), { asSlug: site.slug }).catch(() => null);
190 if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
191 // The attachments ride along (Robins melding, 30-7: "502
192 // reply_failed" op een reply met een foto): deliverReply validates
193 // them itself (own /media only, image|audio|video, max 4) and a
194 // media-only reply is a valid reply there. Dropping them here made
195 // a photo reply arrive naked, and a photo-ONLY reply fail outright.
196 const atts = (Array.isArray(object.attachment) ? object.attachment : [])
197 .map((a) => a && typeof a === 'object' ? {
198 url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
199 mediaType: String(a.mediaType || ''),
200 name: String(a.name || '').slice(0, 120),
201 } : null)
202 .filter(Boolean);
203 // DE MENTIONS VAN DE CLIENT (Robins melding, 26-8). Zonder deze
204 // regel kreeg deliverReply `mentions: undefined`, en dat betekent
205 // daar "oud gedrag: noem alleen de auteur van de ouder". Een client
206 // die er drie stuurde zag er dus een gepubliceerd worden -- niet
207 // door een filter, maar doordat de andere twee hier nooit aankwamen.
208 //
209 // De tags zijn de bron, niet `to`/`cc`: die dragen ook de
210 // volgerscollectie en Public, en dat zijn geen mensen. `href` is de
211 // actor, `name` de handle zoals de client hem spelt.
212 //
213 // Ontdubbeld op actor, want de ouder-auteur zit meestal ook in de
214 // tags en zou anders twee keer vooraan komen te staan.
215 //
216 // GEEN tags meegestuurd blijft undefined en dus het oude gedrag. Een
217 // LEGE lijst kan niet: dat betekent in deliverReply "niemand noemen",
218 // en dat is een keuze die een client die geen tags kent nooit maakte.
219 const gezien = new Set();
220 const mentions = (Array.isArray(object.tag) ? object.tag : (object.tag ? [object.tag] : []))
221 .filter((t) => t && t.type === 'Mention' && typeof t.href === 'string' && /^https?:\/\//i.test(t.href))
222 .filter((t) => !gezien.has(t.href) && gezien.add(t.href))
223 .map((t) => ({ uri: t.href, url: t.href, handle: typeof t.name === 'string' ? t.name : undefined }));
224 // Honour the client's visibility for the reply: 'friends' (followers-
225 // only, the Shaer detail-view Reply) drops Public; anything else stays
226 // quiet-public. 'direct' was already handled above.
227 const r = await deliverReply(site, {
228 postId: parent.localPostId || '', postSlug: null, parent, text: plain,
229 html: object.content || null, attachments: atts,
230 language: object.language || null, visibility: c2sVisibility(object),
231 mentions: mentions.length ? mentions : undefined,
232 });
233 if (!r || !r.id) return { status: 502, error: 'reply_failed' };
234 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
235 }
236 return await c2sCreatePost(base, site, user, object);
237 }
238 // ── Gelezen tot hier (shaer-frontend-3tx) ───────────────────
239 //
240 // AS2 kent Read: 'the actor has read the object'. Geen shaer:seen
241 // verzinnen, en geen zetbare stand: dit is een GEBEURTENIS, dus twee
242 // toestellen kunnen elkaar niet terugzetten. Blijft lokaal -- een
243 // leesbevestiging heeft in de fediverse niets te zoeken.
244 case 'Read': {
245 const targetUri = c2sIdOf(object);
246 if (!targetUri) return { status: 400, error: 'missing_object' };
247 const uit = markRead(site.slug, targetUri);
248 // Kennen we die note niet, dan is er niets gelezen om te onthouden.
249 // Geen fout: een client mag best een oud bericht aanwijzen.
250 return { status: uit ? 200 : 202 };
251 }
252 case 'Like':
253 case 'Announce': {
254 const targetUri = c2sIdOf(object);
255 if (!targetUri) return { status: 400, error: 'missing_object' };
256 // A non-public local note cannot be boosted or liked into the open
257 // (shaer-tqc hardening; the Mastodon 422 equivalent).
258 const localPid = postIdFromNoteUrl(targetUri, base);
259 if (localPid) {
260 const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
261 if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
262 return { status: 403, error: 'not_public' };
263 }
264 }
265 const note = await resolveRemoteNote(targetUri, { asSlug: site.slug }).catch(() => null);
266 const objUri = (note && note.object_uri) || targetUri;
267 const authorUri = note && note.actor_uri;
268 const kind = type === 'Announce' ? 'boost' : 'like';
269 await sendInteraction(site, kind, objUri, authorUri);
270 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
271 // De note gaat mee zodat een boost de post je tijdlijn in trekt.
272 try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
273 catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
274 // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
275 // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
276 // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
277 // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
278 // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
279 // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
280 // Anders dan bij een boost geen upsert: een like hoort een post niet
281 // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
282 // terecht een no-op.
283 return { status: 202, url: objUri };
284 }
285 case 'Follow': {
286 const actorUri = c2sIdOf(object);
287 if (!actorUri) return { status: 400, error: 'missing_object' };
288 // FEP-633c §5.3 outbound (shaer-p729): a ward asks its guardians first.
289 // A held request is a THIRD outcome — not sent, not failed — and it
290 // travels to the app as one, so Shaer can show "waiting" instead of a
291 // tile that already looks followed.
292 const held = await gateOutgoingFollow(site, actorUri);
293 if (held) {
294 return {
295 status: 202, url: actorUri, id: held.id,
296 state: held.status === 'denied' ? 'refused_by_guardian' : 'awaiting_guardian',
297 };
298 }
299 // The error REACHES the app (Robins melding, 31-7): swallowing it
300 // made a failed follow look exactly like a successful one.
301 const r = await followActor(site, actorUri);
302 if (r && r.error) return { status: 502, error: 'follow_failed', detail: r.error };
303 return { status: 202, url: actorUri };
304 }
305 // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
306 // ap_blocks, shows in the Block tab, and purges the actor's cached
307 // content. Client-side filtering becomes a cache of this state.
308 case 'Block': {
309 const targetUri = c2sIdOf(object);
310 if (!targetUri) return { status: 400, error: 'missing_object' };
311 const r = await blockTarget(site, targetUri);
312 if (r && r.error) return { status: 400, error: r.error };
313 return { status: 202, url: targetUri };
314 }
315 case 'Undo': {
316 const inner = object && typeof object === 'object' ? object : null;
317 let innerType = inner && inner.type;
318 if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
319 const innerTarget = c2sIdOf(inner && inner.object);
320 if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
321 if (innerType === 'Block') {
322 if (!innerTarget) return { status: 400, error: 'missing_object' };
323 unblock(site, innerTarget).catch(() => {}); // release from Orbit
324 return { status: 202, url: innerTarget };
325 }
326 if (innerType === 'Like' || innerType === 'Announce') {
327 const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
328 const note = await resolveRemoteNote(innerTarget, { asSlug: site.slug }).catch(() => null);
329 const objUri = (note && note.object_uri) || innerTarget;
330 await sendInteraction(site, kind, objUri, note && note.actor_uri);
331 try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
332 catch { /* non-fatal */ }
333 return { status: 202, url: objUri };
334 }
335 return { status: 400, error: 'unsupported_undo' };
336 }
337 // Delete your OWN note (Robins verzoek, 30-7: long-press delete in de
338 // app). Scope stays narrow: this account's posts and outbound replies,
339 // nothing else. The web delete route is the model: Tombstone to the
340 // followers first, then the cascade, so nobody keeps a live copy of a
341 // post the child took back.
342 case 'Delete': {
343 const targetUri = c2sIdOf(object);
344 if (!targetUri) return { status: 400, error: 'missing_object' };
345 const pid = postIdFromNoteUrl(targetUri, base);
346 if (pid) {
347 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(pid);
348 if (post) {
349 if (post.site_id !== site.id) return { status: 403, error: 'not_your_note' };
350 if (post.status === 'published') deliverDelete(site, post).catch(() => { /* best-effort */ });
351 db.transaction(() => {
352 db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
353 try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch { /* FTS optional */ }
354 db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
355 })();
356 return { status: 202, url: targetUri };
357 }
358 // Same /ap/notes/ namespace: one of our outbound replies/messages.
359 // deliverOutboxDelete checks the site itself and tombstones too.
360 if (await deliverOutboxDelete(site, pid)) return { status: 202, url: targetUri };
361 }
362 return { status: 404, error: 'not_your_note' };
363 }
364 // Update of arbitrary objects needs the post-edit pipeline; tracked
365 // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
366 default:
367 return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
368 }
369 } catch (e) {
370 console.warn('[AP] C2S ingest failed:', e && e.message);
371 return { status: 500, error: 'ingest_error' };
372 }
373}
374
375// Create a top-level microblog post from a C2S Note and federate it. Minimal
376// sibling of the /posts/create route: sanitized HTML content, no title/cover.
377async function c2sCreatePost(base, site, user, object) {
378 const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
379 // Media on a top-level post (shaer-j3uh/-oqxk/-df3i): same rules as
380 // deliverReply — only our OWN uploads, image/audio/video, max 4. They used
381 // to be silently dropped here, so a photo post from the app arrived naked.
382 const media = (Array.isArray(object.attachment) ? object.attachment : [])
383 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
384 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
385 .slice(0, 4)
386 .map((a) => {
387 const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) };
388 // The poster the upload leg made, when it did: a video's still frame
389 // (shaer-zowq, .poster.jpg) or an audio's waveform (Robins vraag 30-7,
390 // .poster.png). Rides along so the tag, the federated attachment and
391 // the apps all have something to show instead of a bare box.
392 const posterExt = entry.mediaType.startsWith('video/') ? '.poster.jpg'
393 : entry.mediaType.startsWith('audio/') ? '.poster.png' : null;
394 if (posterExt) {
395 try {
396 const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media');
397 const rel = entry.url.replace(/^\/media\//, '');
398 if (fs.existsSync(path.join(mediaRoot, rel + posterExt))) entry.poster = entry.url + posterExt;
399 } catch { /* no poster is fine */ }
400 }
401 return entry;
402 });
403 if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' };
404 // The web reads the post's content, so the media goes IN it (we build these
405 // tags ourselves from validated paths, after the sanitizer). buildNote
406 // strips <img> back out into AS2 attachments; audio/video tags stay for the
407 // web player and federate via c2s_attachments below.
408 const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
409 const mediaHtml = media.map((a) => {
410 if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
411 // data-poster: <audio> has no poster attribute, but the tile derivation
412 // reads this one to show the waveform (post-tile/post-card).
413 if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata"${a.poster ? ` data-poster="${a.poster}"` : ''} src="${a.url}"></audio></p>`;
414 const poster = a.poster ? ` poster="${a.poster}"` : '';
415 return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
416 }).join('');
417 // De titel (shaer-uply): AS2 zet hem in `name`, en die werd hier nooit
418 // gelezen -- een client kon hem zetten en hij verdween geruisloos, het
419 // slechtste van de drie mogelijke gedragingen. Platte tekst, want dat is wat
420 // `name` per AS2 is en wat de titelkolom overal verwacht; wie er toch HTML
421 // in stopt houdt de tekst over. De grens van 200 is de huisregel voor korte
422 // vrije tekst hier (content warning, sitetitel) -- de posteditor op het web
423 // heeft geen eigen grens, dus strenger dan het web zijn we hiermee niet
424 // op een manier die iemand merkt.
425 // Vanaf de kolom doet de bestaande machinerie de rest: het web toont hem,
426 // en buildNote vouwt hem als vetgedrukte eerste regel in de content
427 // (Mastodon negeert `name` op een Note).
428 const title = HtmlSanitizerService.toPlainText(typeof object.name === 'string' ? object.name : '').trim().slice(0, 200);
429 const postId = crypto.randomUUID();
430 const slug = 'n-' + postId.slice(0, 8);
431 const now = new Date().toISOString();
432 // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
433 // public, Public in `cc` = quiet public (unlisted), followers-only = friends
434 // (rides the existing fan_only pipeline: followers-only AP delivery + web
435 // gating), neither = participants-only (kept local until mention addressing
436 // lands; still followers-gated on the web).
437 const vis = c2sVisibility(object);
438 const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
439 // Deliberately NO cover (Robins besluit, 30-7): the media lives in the
440 // content, and a cover next to it showed the same video twice on the post
441 // page. The tiles derive their picture from the content instead.
442 db.prepare(`INSERT INTO posts (id, site_id, slug, author_id, title, content, excerpt, status, type, language, fan_only, ap_visibility, created_at, updated_at, published_at)
443 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
444 .run(postId, site.id, slug, user.id, title, html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
445 if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } }
446 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ }
447 bakePostContentWithMentions(html + mediaHtml).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
448 // Ook in de zoekindex, en niet alleen in de kolom (shaer-uply): anders is
449 // een getitelde C2S-post wel te zien maar niet op zijn titel te vinden.
450 try { db.prepare('INSERT INTO posts_fts(content, title, author, post_id) VALUES (?,?,?,?)').run(HtmlSanitizerService.toPlainText(html), title, user.username || '', postId); } catch { /* FTS non-fatal */ }
451 if (vis !== 'direct') {
452 deliverCreate(site, { id: postId, slug, title, content: html + mediaHtml, published_at: now, created_at: now, fan_only: fanOnly, ap_visibility: vis, c2s_attachments: media.length ? JSON.stringify(media) : null }).catch(() => { /* best-effort */ });
453 }
454 return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
455}
Note: See TracBrowser for help on using the repository browser.