| 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 | */
|
|---|
| 16 | import crypto from 'crypto';
|
|---|
| 17 | import fs from 'fs';
|
|---|
| 18 | import path from 'path';
|
|---|
| 19 | import db from '../config/database.js';
|
|---|
| 20 | import HtmlSanitizerService from './HtmlSanitizerService.js';
|
|---|
| 21 | import * as Guardianship from './guardianship/index.js';
|
|---|
| 22 | import { 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.
|
|---|
| 27 | const c2sVisibility = Guardianship.c2sVisibility;
|
|---|
| 28 | const 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.
|
|---|
| 33 | let proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
|
|---|
| 34 | postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
|
|---|
| 35 | followActor, unfollowActor, blockTarget, unblock, deliverDelete,
|
|---|
| 36 | deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
|
|---|
| 37 | deliverCreate;
|
|---|
| 38 | export 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.
|
|---|
| 51 | const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
|
|---|
| 52 |
|
|---|
| 53 | export 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.
|
|---|
| 355 | async 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, '&').replace(/"/g, '"').replace(/</g, '<');
|
|---|
| 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 | }
|
|---|