source: Klonkt/src/services/ap-c2s.js@ 1d5ffc0

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

Opsplitsing stap 4 (shaer-drc): de C2S-inname naar ap-c2s.js

ingestOutboxActivity, c2sCreatePost en c2sIdOf verhuizen als een blok --
388 regels, byte-voor-byte -- uit ActivityPubService.js. Anders dan het
transport is dit een coordinator: hij roept achttien werktuigen uit de
dienstlaag aan, en de regel blijft dat een module nooit uit
ActivityPubService importeert. Daarom het patroon dat guardianship al
bewees: de dienstlaag geeft zijn werktuigen bij het laden door via
wireC2S, en de verhuisde functies staan er ongewijzigd -- ze merken niet
dat hun buren injectie werden.

Wat rechtstreeks geimporteerd wordt wijst omlaag: db, ap-core, de
sanitizer en guardianship (c2sVisibility en deliverDirectNote komen daar
toch al vandaan). Het uitvoeroppervlak is voor en na identiek gemeten
(199 named exports, 180 sleutels op het default-object); de c2s-toetsen
lopen door de koppeling heen, dus die is bewezen en niet aangenomen.
Volle suite 1226 groen. ActivityPubService staat nu op 6337 regels.

  • Property mode set to 100644
File size: 26.5 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 // Honour the client's visibility for the reply: 'friends' (followers-
204 // only, the Shaer detail-view Reply) drops Public; anything else stays
205 // quiet-public. 'direct' was already handled above.
206 const r = await deliverReply(site, {
207 postId: parent.localPostId || '', postSlug: null, parent, text: plain,
208 html: object.content || null, attachments: atts,
209 language: object.language || null, visibility: c2sVisibility(object),
210 });
211 if (!r || !r.id) return { status: 502, error: 'reply_failed' };
212 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
213 }
214 return await c2sCreatePost(base, site, user, object);
215 }
216 // ── Gelezen tot hier (shaer-frontend-3tx) ───────────────────
217 //
218 // AS2 kent Read: 'the actor has read the object'. Geen shaer:seen
219 // verzinnen, en geen zetbare stand: dit is een GEBEURTENIS, dus twee
220 // toestellen kunnen elkaar niet terugzetten. Blijft lokaal -- een
221 // leesbevestiging heeft in de fediverse niets te zoeken.
222 case 'Read': {
223 const targetUri = c2sIdOf(object);
224 if (!targetUri) return { status: 400, error: 'missing_object' };
225 const uit = markRead(site.slug, targetUri);
226 // Kennen we die note niet, dan is er niets gelezen om te onthouden.
227 // Geen fout: een client mag best een oud bericht aanwijzen.
228 return { status: uit ? 200 : 202 };
229 }
230 case 'Like':
231 case 'Announce': {
232 const targetUri = c2sIdOf(object);
233 if (!targetUri) return { status: 400, error: 'missing_object' };
234 // A non-public local note cannot be boosted or liked into the open
235 // (shaer-tqc hardening; the Mastodon 422 equivalent).
236 const localPid = postIdFromNoteUrl(targetUri, base);
237 if (localPid) {
238 const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
239 if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
240 return { status: 403, error: 'not_public' };
241 }
242 }
243 const note = await resolveRemoteNote(targetUri, { asSlug: site.slug }).catch(() => null);
244 const objUri = (note && note.object_uri) || targetUri;
245 const authorUri = note && note.actor_uri;
246 const kind = type === 'Announce' ? 'boost' : 'like';
247 await sendInteraction(site, kind, objUri, authorUri);
248 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
249 // De note gaat mee zodat een boost de post je tijdlijn in trekt.
250 try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
251 catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
252 // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
253 // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
254 // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
255 // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
256 // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
257 // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
258 // Anders dan bij een boost geen upsert: een like hoort een post niet
259 // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
260 // terecht een no-op.
261 return { status: 202, url: objUri };
262 }
263 case 'Follow': {
264 const actorUri = c2sIdOf(object);
265 if (!actorUri) return { status: 400, error: 'missing_object' };
266 // FEP-633c §5.3 outbound (shaer-p729): a ward asks its guardians first.
267 // A held request is a THIRD outcome — not sent, not failed — and it
268 // travels to the app as one, so Shaer can show "waiting" instead of a
269 // tile that already looks followed.
270 const held = await gateOutgoingFollow(site, actorUri);
271 if (held) {
272 return {
273 status: 202, url: actorUri, id: held.id,
274 state: held.status === 'denied' ? 'refused_by_guardian' : 'awaiting_guardian',
275 };
276 }
277 // The error REACHES the app (Robins melding, 31-7): swallowing it
278 // made a failed follow look exactly like a successful one.
279 const r = await followActor(site, actorUri);
280 if (r && r.error) return { status: 502, error: 'follow_failed', detail: r.error };
281 return { status: 202, url: actorUri };
282 }
283 // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
284 // ap_blocks, shows in the Block tab, and purges the actor's cached
285 // content. Client-side filtering becomes a cache of this state.
286 case 'Block': {
287 const targetUri = c2sIdOf(object);
288 if (!targetUri) return { status: 400, error: 'missing_object' };
289 const r = await blockTarget(site, targetUri);
290 if (r && r.error) return { status: 400, error: r.error };
291 return { status: 202, url: targetUri };
292 }
293 case 'Undo': {
294 const inner = object && typeof object === 'object' ? object : null;
295 let innerType = inner && inner.type;
296 if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
297 const innerTarget = c2sIdOf(inner && inner.object);
298 if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
299 if (innerType === 'Block') {
300 if (!innerTarget) return { status: 400, error: 'missing_object' };
301 unblock(site, innerTarget).catch(() => {}); // release from Orbit
302 return { status: 202, url: innerTarget };
303 }
304 if (innerType === 'Like' || innerType === 'Announce') {
305 const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
306 const note = await resolveRemoteNote(innerTarget, { asSlug: site.slug }).catch(() => null);
307 const objUri = (note && note.object_uri) || innerTarget;
308 await sendInteraction(site, kind, objUri, note && note.actor_uri);
309 try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
310 catch { /* non-fatal */ }
311 return { status: 202, url: objUri };
312 }
313 return { status: 400, error: 'unsupported_undo' };
314 }
315 // Delete your OWN note (Robins verzoek, 30-7: long-press delete in de
316 // app). Scope stays narrow: this account's posts and outbound replies,
317 // nothing else. The web delete route is the model: Tombstone to the
318 // followers first, then the cascade, so nobody keeps a live copy of a
319 // post the child took back.
320 case 'Delete': {
321 const targetUri = c2sIdOf(object);
322 if (!targetUri) return { status: 400, error: 'missing_object' };
323 const pid = postIdFromNoteUrl(targetUri, base);
324 if (pid) {
325 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(pid);
326 if (post) {
327 if (post.site_id !== site.id) return { status: 403, error: 'not_your_note' };
328 if (post.status === 'published') deliverDelete(site, post).catch(() => { /* best-effort */ });
329 db.transaction(() => {
330 db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
331 try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch { /* FTS optional */ }
332 db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
333 })();
334 return { status: 202, url: targetUri };
335 }
336 // Same /ap/notes/ namespace: one of our outbound replies/messages.
337 // deliverOutboxDelete checks the site itself and tombstones too.
338 if (await deliverOutboxDelete(site, pid)) return { status: 202, url: targetUri };
339 }
340 return { status: 404, error: 'not_your_note' };
341 }
342 // Update of arbitrary objects needs the post-edit pipeline; tracked
343 // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
344 default:
345 return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
346 }
347 } catch (e) {
348 console.warn('[AP] C2S ingest failed:', e && e.message);
349 return { status: 500, error: 'ingest_error' };
350 }
351}
352
353// Create a top-level microblog post from a C2S Note and federate it. Minimal
354// sibling of the /posts/create route: sanitized HTML content, no title/cover.
355async function c2sCreatePost(base, site, user, object) {
356 const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
357 // Media on a top-level post (shaer-j3uh/-oqxk/-df3i): same rules as
358 // deliverReply — only our OWN uploads, image/audio/video, max 4. They used
359 // to be silently dropped here, so a photo post from the app arrived naked.
360 const media = (Array.isArray(object.attachment) ? object.attachment : [])
361 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
362 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
363 .slice(0, 4)
364 .map((a) => {
365 const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) };
366 // The poster the upload leg made, when it did: a video's still frame
367 // (shaer-zowq, .poster.jpg) or an audio's waveform (Robins vraag 30-7,
368 // .poster.png). Rides along so the tag, the federated attachment and
369 // the apps all have something to show instead of a bare box.
370 const posterExt = entry.mediaType.startsWith('video/') ? '.poster.jpg'
371 : entry.mediaType.startsWith('audio/') ? '.poster.png' : null;
372 if (posterExt) {
373 try {
374 const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media');
375 const rel = entry.url.replace(/^\/media\//, '');
376 if (fs.existsSync(path.join(mediaRoot, rel + posterExt))) entry.poster = entry.url + posterExt;
377 } catch { /* no poster is fine */ }
378 }
379 return entry;
380 });
381 if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' };
382 // The web reads the post's content, so the media goes IN it (we build these
383 // tags ourselves from validated paths, after the sanitizer). buildNote
384 // strips <img> back out into AS2 attachments; audio/video tags stay for the
385 // web player and federate via c2s_attachments below.
386 const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
387 const mediaHtml = media.map((a) => {
388 if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
389 // data-poster: <audio> has no poster attribute, but the tile derivation
390 // reads this one to show the waveform (post-tile/post-card).
391 if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata"${a.poster ? ` data-poster="${a.poster}"` : ''} src="${a.url}"></audio></p>`;
392 const poster = a.poster ? ` poster="${a.poster}"` : '';
393 return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
394 }).join('');
395 // De titel (shaer-uply): AS2 zet hem in `name`, en die werd hier nooit
396 // gelezen -- een client kon hem zetten en hij verdween geruisloos, het
397 // slechtste van de drie mogelijke gedragingen. Platte tekst, want dat is wat
398 // `name` per AS2 is en wat de titelkolom overal verwacht; wie er toch HTML
399 // in stopt houdt de tekst over. De grens van 200 is de huisregel voor korte
400 // vrije tekst hier (content warning, sitetitel) -- de posteditor op het web
401 // heeft geen eigen grens, dus strenger dan het web zijn we hiermee niet
402 // op een manier die iemand merkt.
403 // Vanaf de kolom doet de bestaande machinerie de rest: het web toont hem,
404 // en buildNote vouwt hem als vetgedrukte eerste regel in de content
405 // (Mastodon negeert `name` op een Note).
406 const title = HtmlSanitizerService.toPlainText(typeof object.name === 'string' ? object.name : '').trim().slice(0, 200);
407 const postId = crypto.randomUUID();
408 const slug = 'n-' + postId.slice(0, 8);
409 const now = new Date().toISOString();
410 // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
411 // public, Public in `cc` = quiet public (unlisted), followers-only = friends
412 // (rides the existing fan_only pipeline: followers-only AP delivery + web
413 // gating), neither = participants-only (kept local until mention addressing
414 // lands; still followers-gated on the web).
415 const vis = c2sVisibility(object);
416 const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
417 // Deliberately NO cover (Robins besluit, 30-7): the media lives in the
418 // content, and a cover next to it showed the same video twice on the post
419 // page. The tiles derive their picture from the content instead.
420 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)
421 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
422 .run(postId, site.id, slug, user.id, title, html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
423 if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } }
424 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ }
425 bakePostContentWithMentions(html + mediaHtml).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
426 // Ook in de zoekindex, en niet alleen in de kolom (shaer-uply): anders is
427 // een getitelde C2S-post wel te zien maar niet op zijn titel te vinden.
428 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 */ }
429 if (vis !== 'direct') {
430 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 */ });
431 }
432 return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
433}
Note: See TracBrowser for help on using the repository browser.