source: Klonkt/src/services/ap-c2s.js@ 407353e

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

Lege-bericht-controle deed niets, want een lege paragraaf is waar

De voorwaarde luidde !plain.trim() && !object.content && geen bijlagen. Shaer
stuurde bij lege tekst <p></p>, en die string is waar, dus de hele controle
kon voor die client nooit kloppen: een bericht zonder inhoud kwam er gewoon
langs en werd een lege alinea in de tijdlijn.

plain is al uit content afgeleid, dus die tweede term voegde niets toe behalve
dat gat. Weg. Een bijlage mag nog steeds het hele bericht zijn -- dat is precies
wat een foto-post is.

De app stuurt sinds vandaag geen lege body meer mee, maar de controle hoort ook
te werken tegen een client die dat wel doet.

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

  • Property mode set to 100644
File size: 30.1 KB
RevLine 
[1d5ffc0]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 }
[8a474ab]125 // DE SOORT-POORTEN TELLEN OOK BIJ HET VERSTUREN (shaer-qc9o).
126 //
127 // gate_images en gate_music werden alleen bij het SERVEREN
128 // afgedwongen. Wat een ward niet te zien kreeg mocht hij dus wel
129 // plaatsen: het stond bij iedereen behalve bij hemzelf. Dan is het
130 // geen poort meer maar een filter op zijn eigen scherm, en dat is
131 // niet wat een guardian dichtzet.
132 //
133 // Weigeren en niet wegknippen: stilletjes de bijlage verwijderen
134 // publiceert een bericht dat het kind niet geschreven heeft. Dat de
135 // reddingsboei hierboven al langs is, is het hele punt -- een
136 // hulpvraag met een schermafdruk gaat door elke dichte deur heen.
137 const soortVan = (a) => {
138 const mt = String((a && a.mediaType) || '');
139 const t = a && a.type;
140 if (mt.startsWith('image/') || t === 'Image') return 'images';
141 if (mt.startsWith('audio/') || t === 'Audio') return 'music';
142 if (mt.startsWith('video/') || t === 'Video') return 'video';
143 return '';
144 };
145 const KOLOM = { images: 'gate_images', music: 'gate_music', video: 'gate_video' };
146 const meegestuurd = new Set((Array.isArray(object.attachment) ? object.attachment : [object.attachment])
147 .filter((a) => a && typeof a === 'object')
148 .map(soortVan)
149 .filter(Boolean));
150 for (const soort of meegestuurd) {
151 if (!Guardianship.wardGateAllowed(site[KOLOM[soort]], isWard)) {
152 return { status: 403, error: `gated_${soort}` };
153 }
154 }
[1d5ffc0]155 }
156 }
157 // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
158 // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
159 const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
160 // A picture (or a recording) can be the whole message: media-only
161 // notes pass here; c2sCreatePost validates the attachments themselves.
[0ad37a0]162 // `!object.content` stond hier en zette de controle in de praktijk uit:
163 // Shaer stuurde bij lege tekst `<p></p>`, en die string is waar, dus
164 // een leeg bericht kwam er gewoon langs en werd een lege alinea in de
165 // tijdlijn. `plain` is al uit content afgeleid, dus die tweede term
166 // voegde niets toe behalve dat gat. Een bijlage mag nog steeds het hele
167 // bericht zijn -- dat is wat een foto-post is.
168 if (!plain.trim() && !(Array.isArray(object.attachment) && object.attachment.length)) {
[1d5ffc0]169 return { status: 400, error: 'empty_note' };
170 }
171 // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
172 // outbox machinery to the addressed inboxes only; shows under Messages.
173 if (c2sVisibility(object) === 'direct') {
174 const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
175 const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
176 .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
177 if (!recipients.length) return { status: 400, error: 'no_recipients' };
178 // AS2 attachments (e.g. the help-buoy capture, uploaded via
179 // uploadMedia): normalize our own absolute /media/ URLs to relative
180 // so the deliverReply-style validation applies unchanged.
181 const atts = (Array.isArray(object.attachment) ? object.attachment : [])
182 .map((a) => a && typeof a === 'object' ? {
183 url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
184 mediaType: String(a.mediaType || ''),
185 name: String(a.name || '').slice(0, 120),
186 } : null)
187 .filter(Boolean);
188 const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
189 // FEP-633c 3.6.1: a guardian here declaring itself away to its
190 // wards. An away without a (future) end fails loudly, exactly as
191 // the daemon refuses it: stored quietly it would be a nominal
192 // guardian holding a seat.
193 let awayUntil = null;
194 if (Guardianship.availability.isAway(object)) {
195 awayUntil = Guardianship.availability.parseEndTime(object.endTime);
196 if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
197 // No local shortcut here: the note below reaches a ward on this
198 // instance through the loopback, and its inbox handler applies the
199 // absence like it does for a ward anywhere else. One path.
200 }
201 const gateReq = Guardianship.gatereq.parseRequest(object);
202 // Een hulpvraag oppikken of afsluiten vanuit de app (5.2.1, shaer-lgo).
203 // De markering IS al een gewone directe note met een shaer:-eigenschap,
204 // dus hier hoeft niets nieuws bij: de app stuurt precies wat de PWA
205 // stuurt, en het gaat over dezelfde bezorging naar de mede-guardians.
206 //
207 // We boeken hem ook LOKAAL. Zonder dat zou de guardian die de knop
208 // indrukt zijn eigen markering pas zien als hij bij zichzelf
209 // terugkomt -- en die weg bestaat niet.
210 const mark = Guardianship.help.parseMarker(object);
211 if (mark) {
212 const base2 = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
213 // Met de VOLLEDIGE handle. Hier stond `@${site.slug}` -- zonder host,
214 // dus een derde vorm naast de kale URI van de PWA-route en de echte
215 // handle die een binnengekomen markering draagt. Drie spellingen van
216 // dezelfde naam, en "door wie" was de hele vraag van shaer-lgo.
217 const mij = actorId(base2, site.slug);
218 Guardianship.help.record(mark.noteUri, mij, mark.kind, deriveHandle(mij));
219 }
220 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 });
221 if (!r || !r.id) return { status: 502, error: 'direct_failed' };
222 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
223 }
224 if (object.inReplyTo) {
225 const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo), { asSlug: site.slug }).catch(() => null);
226 if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
227 // The attachments ride along (Robins melding, 30-7: "502
228 // reply_failed" op een reply met een foto): deliverReply validates
229 // them itself (own /media only, image|audio|video, max 4) and a
230 // media-only reply is a valid reply there. Dropping them here made
231 // a photo reply arrive naked, and a photo-ONLY reply fail outright.
232 const atts = (Array.isArray(object.attachment) ? object.attachment : [])
233 .map((a) => a && typeof a === 'object' ? {
234 url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
235 mediaType: String(a.mediaType || ''),
236 name: String(a.name || '').slice(0, 120),
237 } : null)
238 .filter(Boolean);
[0d6d266]239 // DE MENTIONS VAN DE CLIENT (Robins melding, 26-8). Zonder deze
240 // regel kreeg deliverReply `mentions: undefined`, en dat betekent
241 // daar "oud gedrag: noem alleen de auteur van de ouder". Een client
242 // die er drie stuurde zag er dus een gepubliceerd worden -- niet
243 // door een filter, maar doordat de andere twee hier nooit aankwamen.
244 //
245 // De tags zijn de bron, niet `to`/`cc`: die dragen ook de
246 // volgerscollectie en Public, en dat zijn geen mensen. `href` is de
247 // actor, `name` de handle zoals de client hem spelt.
248 //
249 // Ontdubbeld op actor, want de ouder-auteur zit meestal ook in de
250 // tags en zou anders twee keer vooraan komen te staan.
251 //
252 // GEEN tags meegestuurd blijft undefined en dus het oude gedrag. Een
253 // LEGE lijst kan niet: dat betekent in deliverReply "niemand noemen",
254 // en dat is een keuze die een client die geen tags kent nooit maakte.
255 const gezien = new Set();
256 const mentions = (Array.isArray(object.tag) ? object.tag : (object.tag ? [object.tag] : []))
257 .filter((t) => t && t.type === 'Mention' && typeof t.href === 'string' && /^https?:\/\//i.test(t.href))
258 .filter((t) => !gezien.has(t.href) && gezien.add(t.href))
259 .map((t) => ({ uri: t.href, url: t.href, handle: typeof t.name === 'string' ? t.name : undefined }));
[1d5ffc0]260 // Honour the client's visibility for the reply: 'friends' (followers-
261 // only, the Shaer detail-view Reply) drops Public; anything else stays
262 // quiet-public. 'direct' was already handled above.
263 const r = await deliverReply(site, {
264 postId: parent.localPostId || '', postSlug: null, parent, text: plain,
265 html: object.content || null, attachments: atts,
266 language: object.language || null, visibility: c2sVisibility(object),
[0d6d266]267 mentions: mentions.length ? mentions : undefined,
[1d5ffc0]268 });
269 if (!r || !r.id) return { status: 502, error: 'reply_failed' };
270 return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
271 }
272 return await c2sCreatePost(base, site, user, object);
273 }
274 // ── Gelezen tot hier (shaer-frontend-3tx) ───────────────────
275 //
276 // AS2 kent Read: 'the actor has read the object'. Geen shaer:seen
277 // verzinnen, en geen zetbare stand: dit is een GEBEURTENIS, dus twee
278 // toestellen kunnen elkaar niet terugzetten. Blijft lokaal -- een
279 // leesbevestiging heeft in de fediverse niets te zoeken.
280 case 'Read': {
281 const targetUri = c2sIdOf(object);
282 if (!targetUri) return { status: 400, error: 'missing_object' };
283 const uit = markRead(site.slug, targetUri);
284 // Kennen we die note niet, dan is er niets gelezen om te onthouden.
285 // Geen fout: een client mag best een oud bericht aanwijzen.
286 return { status: uit ? 200 : 202 };
287 }
288 case 'Like':
289 case 'Announce': {
290 const targetUri = c2sIdOf(object);
291 if (!targetUri) return { status: 400, error: 'missing_object' };
292 // A non-public local note cannot be boosted or liked into the open
293 // (shaer-tqc hardening; the Mastodon 422 equivalent).
294 const localPid = postIdFromNoteUrl(targetUri, base);
295 if (localPid) {
296 const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
297 if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
298 return { status: 403, error: 'not_public' };
299 }
300 }
301 const note = await resolveRemoteNote(targetUri, { asSlug: site.slug }).catch(() => null);
302 const objUri = (note && note.object_uri) || targetUri;
303 const authorUri = note && note.actor_uri;
304 const kind = type === 'Announce' ? 'boost' : 'like';
305 await sendInteraction(site, kind, objUri, authorUri);
306 // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
307 // De note gaat mee zodat een boost de post je tijdlijn in trekt.
308 try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
309 catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
310 // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
311 // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
312 // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
313 // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
314 // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
315 // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
316 // Anders dan bij een boost geen upsert: een like hoort een post niet
317 // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
318 // terecht een no-op.
319 return { status: 202, url: objUri };
320 }
321 case 'Follow': {
322 const actorUri = c2sIdOf(object);
323 if (!actorUri) return { status: 400, error: 'missing_object' };
324 // FEP-633c §5.3 outbound (shaer-p729): a ward asks its guardians first.
325 // A held request is a THIRD outcome — not sent, not failed — and it
326 // travels to the app as one, so Shaer can show "waiting" instead of a
327 // tile that already looks followed.
328 const held = await gateOutgoingFollow(site, actorUri);
329 if (held) {
330 return {
331 status: 202, url: actorUri, id: held.id,
332 state: held.status === 'denied' ? 'refused_by_guardian' : 'awaiting_guardian',
333 };
334 }
335 // The error REACHES the app (Robins melding, 31-7): swallowing it
336 // made a failed follow look exactly like a successful one.
337 const r = await followActor(site, actorUri);
338 if (r && r.error) return { status: 502, error: 'follow_failed', detail: r.error };
339 return { status: 202, url: actorUri };
340 }
341 // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
342 // ap_blocks, shows in the Block tab, and purges the actor's cached
343 // content. Client-side filtering becomes a cache of this state.
344 case 'Block': {
345 const targetUri = c2sIdOf(object);
346 if (!targetUri) return { status: 400, error: 'missing_object' };
347 const r = await blockTarget(site, targetUri);
348 if (r && r.error) return { status: 400, error: r.error };
349 return { status: 202, url: targetUri };
350 }
351 case 'Undo': {
352 const inner = object && typeof object === 'object' ? object : null;
353 let innerType = inner && inner.type;
354 if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
355 const innerTarget = c2sIdOf(inner && inner.object);
356 if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
357 if (innerType === 'Block') {
358 if (!innerTarget) return { status: 400, error: 'missing_object' };
359 unblock(site, innerTarget).catch(() => {}); // release from Orbit
360 return { status: 202, url: innerTarget };
361 }
362 if (innerType === 'Like' || innerType === 'Announce') {
363 const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
364 const note = await resolveRemoteNote(innerTarget, { asSlug: site.slug }).catch(() => null);
365 const objUri = (note && note.object_uri) || innerTarget;
366 await sendInteraction(site, kind, objUri, note && note.actor_uri);
367 try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
368 catch { /* non-fatal */ }
369 return { status: 202, url: objUri };
370 }
371 return { status: 400, error: 'unsupported_undo' };
372 }
373 // Delete your OWN note (Robins verzoek, 30-7: long-press delete in de
374 // app). Scope stays narrow: this account's posts and outbound replies,
375 // nothing else. The web delete route is the model: Tombstone to the
376 // followers first, then the cascade, so nobody keeps a live copy of a
377 // post the child took back.
378 case 'Delete': {
379 const targetUri = c2sIdOf(object);
380 if (!targetUri) return { status: 400, error: 'missing_object' };
381 const pid = postIdFromNoteUrl(targetUri, base);
382 if (pid) {
383 const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(pid);
384 if (post) {
385 if (post.site_id !== site.id) return { status: 403, error: 'not_your_note' };
386 if (post.status === 'published') deliverDelete(site, post).catch(() => { /* best-effort */ });
387 db.transaction(() => {
388 db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
389 try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch { /* FTS optional */ }
390 db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
391 })();
392 return { status: 202, url: targetUri };
393 }
394 // Same /ap/notes/ namespace: one of our outbound replies/messages.
395 // deliverOutboxDelete checks the site itself and tombstones too.
396 if (await deliverOutboxDelete(site, pid)) return { status: 202, url: targetUri };
397 }
398 return { status: 404, error: 'not_your_note' };
399 }
400 // Update of arbitrary objects needs the post-edit pipeline; tracked
401 // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
402 default:
403 return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
404 }
405 } catch (e) {
406 console.warn('[AP] C2S ingest failed:', e && e.message);
407 return { status: 500, error: 'ingest_error' };
408 }
409}
410
411// Create a top-level microblog post from a C2S Note and federate it. Minimal
412// sibling of the /posts/create route: sanitized HTML content, no title/cover.
413async function c2sCreatePost(base, site, user, object) {
414 const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
415 // Media on a top-level post (shaer-j3uh/-oqxk/-df3i): same rules as
416 // deliverReply — only our OWN uploads, image/audio/video, max 4. They used
417 // to be silently dropped here, so a photo post from the app arrived naked.
418 const media = (Array.isArray(object.attachment) ? object.attachment : [])
419 .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
420 && /^(image|audio|video)\//.test(String(a.mediaType || '')))
421 .slice(0, 4)
422 .map((a) => {
423 const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) };
424 // The poster the upload leg made, when it did: a video's still frame
425 // (shaer-zowq, .poster.jpg) or an audio's waveform (Robins vraag 30-7,
426 // .poster.png). Rides along so the tag, the federated attachment and
427 // the apps all have something to show instead of a bare box.
428 const posterExt = entry.mediaType.startsWith('video/') ? '.poster.jpg'
429 : entry.mediaType.startsWith('audio/') ? '.poster.png' : null;
430 if (posterExt) {
431 try {
432 const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media');
433 const rel = entry.url.replace(/^\/media\//, '');
434 if (fs.existsSync(path.join(mediaRoot, rel + posterExt))) entry.poster = entry.url + posterExt;
435 } catch { /* no poster is fine */ }
436 }
437 return entry;
438 });
439 if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' };
440 // The web reads the post's content, so the media goes IN it (we build these
441 // tags ourselves from validated paths, after the sanitizer). buildNote
442 // strips <img> back out into AS2 attachments; audio/video tags stay for the
443 // web player and federate via c2s_attachments below.
444 const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
445 const mediaHtml = media.map((a) => {
446 if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
447 // data-poster: <audio> has no poster attribute, but the tile derivation
448 // reads this one to show the waveform (post-tile/post-card).
449 if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata"${a.poster ? ` data-poster="${a.poster}"` : ''} src="${a.url}"></audio></p>`;
450 const poster = a.poster ? ` poster="${a.poster}"` : '';
451 return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
452 }).join('');
453 // De titel (shaer-uply): AS2 zet hem in `name`, en die werd hier nooit
454 // gelezen -- een client kon hem zetten en hij verdween geruisloos, het
455 // slechtste van de drie mogelijke gedragingen. Platte tekst, want dat is wat
456 // `name` per AS2 is en wat de titelkolom overal verwacht; wie er toch HTML
457 // in stopt houdt de tekst over. De grens van 200 is de huisregel voor korte
458 // vrije tekst hier (content warning, sitetitel) -- de posteditor op het web
459 // heeft geen eigen grens, dus strenger dan het web zijn we hiermee niet
460 // op een manier die iemand merkt.
461 // Vanaf de kolom doet de bestaande machinerie de rest: het web toont hem,
462 // en buildNote vouwt hem als vetgedrukte eerste regel in de content
463 // (Mastodon negeert `name` op een Note).
464 const title = HtmlSanitizerService.toPlainText(typeof object.name === 'string' ? object.name : '').trim().slice(0, 200);
465 const postId = crypto.randomUUID();
466 const slug = 'n-' + postId.slice(0, 8);
467 const now = new Date().toISOString();
468 // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
469 // public, Public in `cc` = quiet public (unlisted), followers-only = friends
470 // (rides the existing fan_only pipeline: followers-only AP delivery + web
471 // gating), neither = participants-only (kept local until mention addressing
472 // lands; still followers-gated on the web).
473 const vis = c2sVisibility(object);
474 const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
475 // Deliberately NO cover (Robins besluit, 30-7): the media lives in the
476 // content, and a cover next to it showed the same video twice on the post
477 // page. The tiles derive their picture from the content instead.
478 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)
479 VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
480 .run(postId, site.id, slug, user.id, title, html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
481 if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } }
482 try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ }
483 bakePostContentWithMentions(html + mediaHtml).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
484 // Ook in de zoekindex, en niet alleen in de kolom (shaer-uply): anders is
485 // een getitelde C2S-post wel te zien maar niet op zijn titel te vinden.
486 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 */ }
487 if (vis !== 'direct') {
488 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 */ });
489 }
490 return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
491}
Note: See TracBrowser for help on using the repository browser.