source: Klonkt/src/services/ap-c2s.js@ 8a474ab

main
Last change on this file since 8a474ab was 8a474ab, checked in by Robin <roboburr@…>, 11 hours ago

Poorten per soort: ook bij het versturen, en film heeft er nu een

Twee gaten die naast elkaar stonden en elkaar versterkten.

gate_images en gate_music golden alleen bij het SERVEREN (shaer-qc9o). Wat een
ward niet te zien kreeg mocht hij wel plaatsen, dus het stond bij iedereen
behalve bij hemzelf. Dat is geen poort meer maar een filter op zijn eigen
scherm, en dat is niet wat een guardian dichtzet. De innamekant weigert nu met
gated_images / gated_music / gated_video, in dezelfde vorm als compose en
messages.

Weigeren en niet wegknippen: stilletjes de bijlage verwijderen publiceert een
bericht dat het kind niet geschreven heeft.

En film had helemaal geen poort (shaer-mxh2), dus de zwaarste soort van de drie
was de enige die altijd door mocht. Nu een gewone rij in de catalogus met een
eigen kolom, aan beide kanten afgedwongen: gateAttachments knipt video weg als
hij dicht staat (ook als hij als AS2-Video zonder mediaType binnenkomt) en de
outbox neemt hem niet aan. shaer:video staat in de caps, zodat de app het
vooraf weet in plaats van het bij de eerste weigering te ontdekken.

De reddingsboei gaat door alle drie heen, zoals door elke andere dichte deur.
Een hulpvraag draagt juist vaak een schermafdruk.

uploadMedia zelf blijft open, en dat is met opzet: de boei gebruikt dezelfde
poot en de server weet daar nog niet waar een upload voor bedoeld is. Een
bijlage die blijft liggen kost een bestand; een boei die vastloopt kost meer.
De weigering valt bij het innemen van de post, en daar is het wel bekend.

Getoetst in test/gates-family.test.js, naast de poorten die er al stonden.
Tegenproef: met de video-regel en de innamelus eruit vallen precies de twee
nieuwe toetsen om.

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