Changeset 1d5ffc0 in Klonkt for src


Ignore:
Timestamp:
08/24/2026 04:01:56 PM (2 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
b05eb97
Parents:
df7de6e
Message:

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.

Location:
src/services
Files:
1 added
1 edited

Legend:

Unmodified
Added
Removed
  • src/services/ActivityPubService.js

    rdf7de6e r1d5ffc0  
    4242  verifyRequest, signedGetHeaders, signedGetJson,
    4343};
     44// Stap 4 (shaer-drc): de C2S-inname woont in ap-c2s.js. Die is een coordinator
     45// en krijgt zijn werktuigen uit de dienstlaag onderaan dit bestand via
     46// wireC2S -- de regel blijft dat een module NOOIT uit dit bestand importeert.
     47import { ingestOutboxActivity, wireC2S } from './ap-c2s.js';
     48export { ingestOutboxActivity };
    4449// Doorgeven wat hier altijd vandaan kwam, zodat elke bestaande aanroep blijft werken.
    4550export { AP_CONTEXT, actorId, noteId, guessMediaType };
     
    31463151}
    31473152
    3148 // ── ActivityPub Client-to-Server: ingest an activity POSTed to the outbox ──
    3149 // The C2S counterpart of handleInbox: a native/web client (Shaer) posts an
    3150 // activity here and we translate it onto the SAME delivery machinery the web UI
    3151 // uses (deliverReply / sendInteraction / followActor / deliverCreate). Returns
    3152 // { status, id?, url?, error? }. Auth + site-ownership are checked by the route.
    3153 const c2sIdOf = (x) => (typeof x === 'string' ? x : (x && (x.id || x.href))) || null;
    3154 
    3155 export async function ingestOutboxActivity(site, user, activity) {
    3156   const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    3157   if (!base || !site || !activity || typeof activity !== 'object') return { status: 400, error: 'invalid_activity' };
    3158 
    3159   // AP §6: a client MAY POST a bare object; the server wraps it in a Create.
    3160   let type = activity.type;
    3161   let object = activity.object;
    3162   if (type === 'Note' || type === 'Article') { object = activity; type = 'Create'; }
    3163   if (Array.isArray(type)) type = type.find((t) => typeof t === 'string');
    3164 
    3165   // FEP-633c: the adoption handshake (Offer/Accept/Reject on a guardianship
    3166   // Relationship) belongs to the guardianship module; anything else falls
    3167   // through to the switch below.
    3168   if (type === 'Offer' || type === 'Accept' || type === 'Reject') {
    3169     const g = await Guardianship.handleGuardianshipOutbox(site, activity).catch(() => null);
    3170     if (g) return g;
    3171   }
    3172   // Een gate-voorstel uit de app (5.6, shaer-8ru): een Offer van een
    3173   // shaer:GatedSetting, de vorm die 5.6 al beschrijft.
    3174   //
    3175   // HIER, EN GEEN `case` IN DE SWITCH. Dat was hij eerst, en die claimde ELKE
    3176   // Offer: wat geen gate-voorstel was kreeg 400 unsupported_offer -- ook de
    3177   // adoptie-handshake, en straks elke Offer-vorm die we nog toevoegen. Barts
    3178   // honderd aanbiedingen liepen er meteen op stuk. Alleen claimen wat je
    3179   // herkent, en de rest laten doorlopen.
    3180   if (type === 'Offer') {
    3181     const gs = Guardianship.gated.parseGatedSetting(activity.object);
    3182     if (gs) {
    3183       const uit = proposeGate(site, gs.ward, gs.feature, gs.value);
    3184       return uit.status === 200 ? { ...uit, status: 201, id: uit.offerId } : uit;
    3185     }
    3186   }
    3187 
    3188   try {
    3189     switch (type) {
    3190       case 'Create': {
    3191         if (!object || typeof object !== 'object') return { status: 400, error: 'missing_object' };
    3192         // Innamepoorten (shaer-ahy.1, 8-8): wat de ward niet mag versturen
    3193         // wordt HIER geweigerd, niet in de app verstopt -- een knop die de
    3194         // client alleen verbergt is geen poort. De reddingsboei gaat ALTIJD
    3195         // voor: een hulpvraag aan de guardians mag door elke dichte deur heen,
    3196         // anders sluit een messages-poort precies het kanaal af dat het kind
    3197         // veilig houdt.
    3198         {
    3199           const isWard = (() => { try { return Guardianship.listGuardians(site.slug).length > 0; } catch { return false; } })();
    3200           const isHelp = object['shaer:helpRequest'] === true || object.helpRequest === true;
    3201           // Een poortverzoek van het kind zelf (shaer-8ru) gaat langs de
    3202           // messages-poort. Dat lijkt een gat en is het niet: het verzoek draagt
    3203           // ALLEEN de naam van de feature, geen vrije tekst, dus er ontstaat geen
    3204           // kanaal om omheen die poort te praten. Zonder deze uitzondering kan
    3205           // een kind met berichten dicht nergens meer om vragen -- en dan is de
    3206           // hele weg dood op precies het moment dat hij nodig is.
    3207           const isGateReq = !!Guardianship.gatereq.parseRequest(object);
    3208           const direct = c2sVisibility(object) === 'direct';
    3209           if (!isHelp && !isGateReq) {
    3210             if (direct && !Guardianship.wardGateAllowed(site.gate_messages, isWard)) {
    3211               return { status: 403, error: 'gated_messages' };
    3212             }
    3213             if (!direct && !object.inReplyTo && !Guardianship.wardGateAllowed(site.gate_compose, isWard)) {
    3214               return { status: 403, error: 'gated_compose' };
    3215             }
    3216             // Meedoen aan een gesprek is ook iets (Bart, 8-8). Hier stond de
    3217             // aanname dat een antwoord geen eigen podium is en dus onder compose
    3218             // door mocht. Dat is teruggedraaid: antwoorden heeft een EIGEN poort,
    3219             // los van compose in beide richtingen -- je kunt willen dat een kind
    3220             // meepraat zonder podium, en ook andersom.
    3221             //
    3222             // Geldt ook voor een DIRECT antwoord, bovenop de messages-poort: een
    3223             // privé-antwoord is allebei, en dan mag allebei hem tegenhouden.
    3224             if (object.inReplyTo && !Guardianship.wardGateAllowed(site.gate_replies, isWard)) {
    3225               return { status: 403, error: 'gated_replies' };
    3226             }
    3227           }
    3228         }
    3229         // Client sends `source` (plain/markdown) + `content` (HTML). deliverReply
    3230         // re-escapes, so it needs plain text; a top-level post keeps sanitized HTML.
    3231         const plain = (object.source && object.source.content) || HtmlSanitizerService.toPlainText(object.content || '');
    3232         // A picture (or a recording) can be the whole message: media-only
    3233         // notes pass here; c2sCreatePost validates the attachments themselves.
    3234         if (!plain.trim() && !object.content && !(Array.isArray(object.attachment) && object.attachment.length)) {
    3235           return { status: 400, error: 'empty_note' };
    3236         }
    3237         // Direct (private mention, shaer-tqc): NOT a post. Delivered over the
    3238         // outbox machinery to the addressed inboxes only; shows under Messages.
    3239         if (c2sVisibility(object) === 'direct') {
    3240           const arr = (v) => (Array.isArray(v) ? v : (v ? [v] : [])).filter((x) => typeof x === 'string');
    3241           const recipients = [...new Set([...arr(object.to), ...arr(object.cc)])]
    3242             .filter((u) => /^https?:\/\//i.test(u) && !/\/followers\/?$/.test(u) && u !== PUBLIC);
    3243           if (!recipients.length) return { status: 400, error: 'no_recipients' };
    3244           // AS2 attachments (e.g. the help-buoy capture, uploaded via
    3245           // uploadMedia): normalize our own absolute /media/ URLs to relative
    3246           // so the deliverReply-style validation applies unchanged.
    3247           const atts = (Array.isArray(object.attachment) ? object.attachment : [])
    3248             .map((a) => a && typeof a === 'object' ? {
    3249               url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
    3250               mediaType: String(a.mediaType || ''),
    3251               name: String(a.name || '').slice(0, 120),
    3252             } : null)
    3253             .filter(Boolean);
    3254           const help = object['shaer:helpRequest'] === true || object.helpRequest === true;
    3255           // FEP-633c 3.6.1: a guardian here declaring itself away to its
    3256           // wards. An away without a (future) end fails loudly, exactly as
    3257           // the daemon refuses it: stored quietly it would be a nominal
    3258           // guardian holding a seat.
    3259           let awayUntil = null;
    3260           if (Guardianship.availability.isAway(object)) {
    3261             awayUntil = Guardianship.availability.parseEndTime(object.endTime);
    3262             if (!awayUntil || awayUntil <= Date.now()) return { status: 400, error: 'away_needs_an_end' };
    3263             // No local shortcut here: the note below reaches a ward on this
    3264             // instance through the loopback, and its inbox handler applies the
    3265             // absence like it does for a ward anywhere else. One path.
    3266           }
    3267           const gateReq = Guardianship.gatereq.parseRequest(object);
    3268           // Een hulpvraag oppikken of afsluiten vanuit de app (5.2.1, shaer-lgo).
    3269           // De markering IS al een gewone directe note met een shaer:-eigenschap,
    3270           // dus hier hoeft niets nieuws bij: de app stuurt precies wat de PWA
    3271           // stuurt, en het gaat over dezelfde bezorging naar de mede-guardians.
    3272           //
    3273           // We boeken hem ook LOKAAL. Zonder dat zou de guardian die de knop
    3274           // indrukt zijn eigen markering pas zien als hij bij zichzelf
    3275           // terugkomt -- en die weg bestaat niet.
    3276           const mark = Guardianship.help.parseMarker(object);
    3277           if (mark) {
    3278             const base2 = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
    3279             // Met de VOLLEDIGE handle. Hier stond `@${site.slug}` -- zonder host,
    3280             // dus een derde vorm naast de kale URI van de PWA-route en de echte
    3281             // handle die een binnengekomen markering draagt. Drie spellingen van
    3282             // dezelfde naam, en "door wie" was de hele vraag van shaer-lgo.
    3283             const mij = actorId(base2, site.slug);
    3284             Guardianship.help.record(mark.noteUri, mij, mark.kind, deriveHandle(mij));
    3285           }
    3286           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 });
    3287           if (!r || !r.id) return { status: 502, error: 'direct_failed' };
    3288           return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
    3289         }
    3290         if (object.inReplyTo) {
    3291           const parent = await resolveRemoteNote(c2sIdOf(object.inReplyTo), { asSlug: site.slug }).catch(() => null);
    3292           if (!parent) return { status: 502, error: 'cannot_resolve_inReplyTo' };
    3293           // The attachments ride along (Robins melding, 30-7: "502
    3294           // reply_failed" op een reply met een foto): deliverReply validates
    3295           // them itself (own /media only, image|audio|video, max 4) and a
    3296           // media-only reply is a valid reply there. Dropping them here made
    3297           // a photo reply arrive naked, and a photo-ONLY reply fail outright.
    3298           const atts = (Array.isArray(object.attachment) ? object.attachment : [])
    3299             .map((a) => a && typeof a === 'object' ? {
    3300               url: String(a.url || '').replace(new RegExp('^' + base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), ''),
    3301               mediaType: String(a.mediaType || ''),
    3302               name: String(a.name || '').slice(0, 120),
    3303             } : null)
    3304             .filter(Boolean);
    3305           // Honour the client's visibility for the reply: 'friends' (followers-
    3306           // only, the Shaer detail-view Reply) drops Public; anything else stays
    3307           // quiet-public. 'direct' was already handled above.
    3308           const r = await deliverReply(site, {
    3309             postId: parent.localPostId || '', postSlug: null, parent, text: plain,
    3310             html: object.content || null, attachments: atts,
    3311             language: object.language || null, visibility: c2sVisibility(object),
    3312           });
    3313           if (!r || !r.id) return { status: 502, error: 'reply_failed' };
    3314           return { status: 201, id: r.id, url: `${base}/ap/notes/${r.id}` };
    3315         }
    3316         return await c2sCreatePost(base, site, user, object);
    3317       }
    3318       // ── Gelezen tot hier (shaer-frontend-3tx) ───────────────────
    3319       //
    3320       // AS2 kent Read: 'the actor has read the object'. Geen shaer:seen
    3321       // verzinnen, en geen zetbare stand: dit is een GEBEURTENIS, dus twee
    3322       // toestellen kunnen elkaar niet terugzetten. Blijft lokaal -- een
    3323       // leesbevestiging heeft in de fediverse niets te zoeken.
    3324       case 'Read': {
    3325         const targetUri = c2sIdOf(object);
    3326         if (!targetUri) return { status: 400, error: 'missing_object' };
    3327         const uit = markRead(site.slug, targetUri);
    3328         // Kennen we die note niet, dan is er niets gelezen om te onthouden.
    3329         // Geen fout: een client mag best een oud bericht aanwijzen.
    3330         return { status: uit ? 200 : 202 };
    3331       }
    3332       case 'Like':
    3333       case 'Announce': {
    3334         const targetUri = c2sIdOf(object);
    3335         if (!targetUri) return { status: 400, error: 'missing_object' };
    3336         // A non-public local note cannot be boosted or liked into the open
    3337         // (shaer-tqc hardening; the Mastodon 422 equivalent).
    3338         const localPid = postIdFromNoteUrl(targetUri, base);
    3339         if (localPid) {
    3340           const p = db.prepare('SELECT fan_only, ap_visibility FROM posts WHERE id = ?').get(localPid);
    3341           if (p && (p.fan_only || p.ap_visibility === 'direct' || p.ap_visibility === 'friends')) {
    3342             return { status: 403, error: 'not_public' };
    3343           }
    3344         }
    3345         const note = await resolveRemoteNote(targetUri, { asSlug: site.slug }).catch(() => null);
    3346         const objUri = (note && note.object_uri) || targetUri;
    3347         const authorUri = note && note.actor_uri;
    3348         const kind = type === 'Announce' ? 'boost' : 'like';
    3349         await sendInteraction(site, kind, objUri, authorUri);
    3350         // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag in één keer.
    3351         // De note gaat mee zodat een boost de post je tijdlijn in trekt.
    3352         try { setReaction(site.slug, targetUri, kind, true, { flagUri: objUri, note: type === 'Announce' ? note : null }); }
    3353         catch { /* non-fatal: een reactie mag nooit de bezorging blokkeren */ }
    3354         // Een Like uit een app moet ook in ap_timeline.liked landen, want dat
    3355         // is wat de C2S-tijdlijn als shaer:liked teruggeeft. Zonder dit werd
    3356         // de reactie wel opgeslagen (setMyReaction, de webroute leest die),
    3357         // maar kreeg de app altijd liked:false terug: het hartje sprong bij de
    3358         // eerste herlaadbeurt uit, en un-liken kon niet meer -- de app bood
    3359         // alleen nog "Like" aan en stuurde bij elke tik een nieuwe Like.
    3360         // Anders dan bij een boost geen upsert: een like hoort een post niet
    3361         // in je tijdlijn te trekken, dus staat de post er niet in, dan is dit
    3362         // terecht een no-op.
    3363         return { status: 202, url: objUri };
    3364       }
    3365       case 'Follow': {
    3366         const actorUri = c2sIdOf(object);
    3367         if (!actorUri) return { status: 400, error: 'missing_object' };
    3368         // FEP-633c §5.3 outbound (shaer-p729): a ward asks its guardians first.
    3369         // A held request is a THIRD outcome — not sent, not failed — and it
    3370         // travels to the app as one, so Shaer can show "waiting" instead of a
    3371         // tile that already looks followed.
    3372         const held = await gateOutgoingFollow(site, actorUri);
    3373         if (held) {
    3374           return {
    3375             status: 202, url: actorUri, id: held.id,
    3376             state: held.status === 'denied' ? 'refused_by_guardian' : 'awaiting_guardian',
    3377           };
    3378         }
    3379         // The error REACHES the app (Robins melding, 31-7): swallowing it
    3380         // made a failed follow look exactly like a successful one.
    3381         const r = await followActor(site, actorUri);
    3382         if (r && r.error) return { status: 502, error: 'follow_failed', detail: r.error };
    3383         return { status: 202, url: actorUri };
    3384       }
    3385       // Shaer "in Orbit" = a real Block (FEP-c648 client side): lands in
    3386       // ap_blocks, shows in the Block tab, and purges the actor's cached
    3387       // content. Client-side filtering becomes a cache of this state.
    3388       case 'Block': {
    3389         const targetUri = c2sIdOf(object);
    3390         if (!targetUri) return { status: 400, error: 'missing_object' };
    3391         const r = await blockTarget(site, targetUri);
    3392         if (r && r.error) return { status: 400, error: r.error };
    3393         return { status: 202, url: targetUri };
    3394       }
    3395       case 'Undo': {
    3396         const inner = object && typeof object === 'object' ? object : null;
    3397         let innerType = inner && inner.type;
    3398         if (Array.isArray(innerType)) innerType = innerType.find((t) => typeof t === 'string');
    3399         const innerTarget = c2sIdOf(inner && inner.object);
    3400         if (innerType === 'Follow') { await unfollowActor(site, innerTarget); return { status: 202, url: innerTarget }; }
    3401         if (innerType === 'Block') {
    3402           if (!innerTarget) return { status: 400, error: 'missing_object' };
    3403           unblock(site, innerTarget).catch(() => {});   // release from Orbit
    3404           return { status: 202, url: innerTarget };
    3405         }
    3406         if (innerType === 'Like' || innerType === 'Announce') {
    3407           const kind = innerType === 'Announce' ? 'unboost' : 'unlike';
    3408           const note = await resolveRemoteNote(innerTarget, { asSlug: site.slug }).catch(() => null);
    3409           const objUri = (note && note.object_uri) || innerTarget;
    3410           await sendInteraction(site, kind, objUri, note && note.actor_uri);
    3411           try { setReaction(site.slug, innerTarget, innerType === 'Announce' ? 'boost' : 'like', false, { flagUri: objUri }); }
    3412           catch { /* non-fatal */ }
    3413           return { status: 202, url: objUri };
    3414         }
    3415         return { status: 400, error: 'unsupported_undo' };
    3416       }
    3417       // Delete your OWN note (Robins verzoek, 30-7: long-press delete in de
    3418       // app). Scope stays narrow: this account's posts and outbound replies,
    3419       // nothing else. The web delete route is the model: Tombstone to the
    3420       // followers first, then the cascade, so nobody keeps a live copy of a
    3421       // post the child took back.
    3422       case 'Delete': {
    3423         const targetUri = c2sIdOf(object);
    3424         if (!targetUri) return { status: 400, error: 'missing_object' };
    3425         const pid = postIdFromNoteUrl(targetUri, base);
    3426         if (pid) {
    3427           const post = db.prepare('SELECT * FROM posts WHERE id = ?').get(pid);
    3428           if (post) {
    3429             if (post.site_id !== site.id) return { status: 403, error: 'not_your_note' };
    3430             if (post.status === 'published') deliverDelete(site, post).catch(() => { /* best-effort */ });
    3431             db.transaction(() => {
    3432               db.prepare('DELETE FROM comments WHERE post_id = ?').run(post.id);
    3433               try { db.prepare('DELETE FROM posts_fts WHERE post_id = ?').run(post.id); } catch { /* FTS optional */ }
    3434               db.prepare('DELETE FROM posts WHERE id = ?').run(post.id);
    3435             })();
    3436             return { status: 202, url: targetUri };
    3437           }
    3438           // Same /ap/notes/ namespace: one of our outbound replies/messages.
    3439           // deliverOutboxDelete checks the site itself and tombstones too.
    3440           if (await deliverOutboxDelete(site, pid)) return { status: 202, url: targetUri };
    3441         }
    3442         return { status: 404, error: 'not_your_note' };
    3443       }
    3444       // Update of arbitrary objects needs the post-edit pipeline; tracked
    3445       // separately (klonkt-demo-c2s-del). Reject clearly rather than half-doing it.
    3446       default:
    3447         return { status: 400, error: 'unsupported_type', detail: String(type || 'none') };
    3448     }
    3449   } catch (e) {
    3450     console.warn('[AP] C2S ingest failed:', e && e.message);
    3451     return { status: 500, error: 'ingest_error' };
    3452   }
    3453 }
    3454 
    3455 // Create a top-level microblog post from a C2S Note and federate it. Minimal
    3456 // sibling of the /posts/create route: sanitized HTML content, no title/cover.
    3457 async function c2sCreatePost(base, site, user, object) {
    3458   const html = HtmlSanitizerService.sanitize(object.content || (object.source && object.source.content) || '');
    3459   // Media on a top-level post (shaer-j3uh/-oqxk/-df3i): same rules as
    3460   // deliverReply — only our OWN uploads, image/audio/video, max 4. They used
    3461   // to be silently dropped here, so a photo post from the app arrived naked.
    3462   const media = (Array.isArray(object.attachment) ? object.attachment : [])
    3463     .filter((a) => a && typeof a.url === 'string' && /^\/media\/[\w./-]+$/.test(a.url)
    3464       && /^(image|audio|video)\//.test(String(a.mediaType || '')))
    3465     .slice(0, 4)
    3466     .map((a) => {
    3467       const entry = { url: a.url, mediaType: String(a.mediaType), name: String(a.name || '').slice(0, 120) };
    3468       // The poster the upload leg made, when it did: a video's still frame
    3469       // (shaer-zowq, .poster.jpg) or an audio's waveform (Robins vraag 30-7,
    3470       // .poster.png). Rides along so the tag, the federated attachment and
    3471       // the apps all have something to show instead of a bare box.
    3472       const posterExt = entry.mediaType.startsWith('video/') ? '.poster.jpg'
    3473         : entry.mediaType.startsWith('audio/') ? '.poster.png' : null;
    3474       if (posterExt) {
    3475         try {
    3476           const mediaRoot = path.resolve(process.env.MEDIA_PATH || './storage/media');
    3477           const rel = entry.url.replace(/^\/media\//, '');
    3478           if (fs.existsSync(path.join(mediaRoot, rel + posterExt))) entry.poster = entry.url + posterExt;
    3479         } catch { /* no poster is fine */ }
    3480       }
    3481       return entry;
    3482     });
    3483   if (!html.trim() && !media.length) return { status: 400, error: 'empty_note' };
    3484   // The web reads the post's content, so the media goes IN it (we build these
    3485   // tags ourselves from validated paths, after the sanitizer). buildNote
    3486   // strips <img> back out into AS2 attachments; audio/video tags stay for the
    3487   // web player and federate via c2s_attachments below.
    3488   const esc = (t) => String(t).replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;');
    3489   const mediaHtml = media.map((a) => {
    3490     if (a.mediaType.startsWith('image/')) return `<p><img src="${a.url}" alt="${esc(a.name)}"></p>`;
    3491     // data-poster: <audio> has no poster attribute, but the tile derivation
    3492     // reads this one to show the waveform (post-tile/post-card).
    3493     if (a.mediaType.startsWith('audio/')) return `<p><audio controls preload="metadata"${a.poster ? ` data-poster="${a.poster}"` : ''} src="${a.url}"></audio></p>`;
    3494     const poster = a.poster ? ` poster="${a.poster}"` : '';
    3495     return `<p><video controls playsinline preload="metadata"${poster} src="${a.url}"></video></p>`;
    3496   }).join('');
    3497   // De titel (shaer-uply): AS2 zet hem in `name`, en die werd hier nooit
    3498   // gelezen -- een client kon hem zetten en hij verdween geruisloos, het
    3499   // slechtste van de drie mogelijke gedragingen. Platte tekst, want dat is wat
    3500   // `name` per AS2 is en wat de titelkolom overal verwacht; wie er toch HTML
    3501   // in stopt houdt de tekst over. De grens van 200 is de huisregel voor korte
    3502   // vrije tekst hier (content warning, sitetitel) -- de posteditor op het web
    3503   // heeft geen eigen grens, dus strenger dan het web zijn we hiermee niet
    3504   // op een manier die iemand merkt.
    3505   // Vanaf de kolom doet de bestaande machinerie de rest: het web toont hem,
    3506   // en buildNote vouwt hem als vetgedrukte eerste regel in de content
    3507   // (Mastodon negeert `name` op een Note).
    3508   const title = HtmlSanitizerService.toPlainText(typeof object.name === 'string' ? object.name : '').trim().slice(0, 200);
    3509   const postId = crypto.randomUUID();
    3510   const slug = 'n-' + postId.slice(0, 8);
    3511   const now = new Date().toISOString();
    3512   // Visibility from the note's addressing (shaer-60b): Public in `to` = loud
    3513   // public, Public in `cc` = quiet public (unlisted), followers-only = friends
    3514   // (rides the existing fan_only pipeline: followers-only AP delivery + web
    3515   // gating), neither = participants-only (kept local until mention addressing
    3516   // lands; still followers-gated on the web).
    3517   const vis = c2sVisibility(object);
    3518   const fanOnly = (vis === 'friends' || vis === 'direct') ? 1 : 0;
    3519   // Deliberately NO cover (Robins besluit, 30-7): the media lives in the
    3520   // content, and a cover next to it showed the same video twice on the post
    3521   // page. The tiles derive their picture from the content instead.
    3522   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)
    3523               VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
    3524     .run(postId, site.id, slug, user.id, title, html + mediaHtml, '', 'published', 'post', object.language || 'nl', fanOnly, vis, now, now, now);
    3525   if (media.length) { try { db.prepare('UPDATE posts SET c2s_attachments = ? WHERE id = ?').run(JSON.stringify(media), postId); } catch { /* column exists via ensureColumn */ } }
    3526   try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(bakePostContent(html + mediaHtml), postId); } catch { /* render fallback covers it */ }
    3527   bakePostContentWithMentions(html + mediaHtml).then((h) => { try { db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?').run(h, postId); } catch { /* keep sync bake */ } }).catch(() => {});
    3528   // Ook in de zoekindex, en niet alleen in de kolom (shaer-uply): anders is
    3529   // een getitelde C2S-post wel te zien maar niet op zijn titel te vinden.
    3530   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 */ }
    3531   if (vis !== 'direct') {
    3532     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 */ });
    3533   }
    3534   return { status: 201, id: postId, url: `${base}/ap/notes/${postId}` };
    3535 }
    3536 
    35373153// The direct-note leg (ward call-for-help) lives in the guardianship module
    35383154// (src/services/guardianship/delivery.js); wired with our AP helpers at the
     
    66796295});
    66806296
     6297// De C2S-inname zijn werktuigen geven (stap 4, shaer-drc). Onderaan, zodat
     6298// elke const hierboven al bestaat; een verzoek kan pas na deze evaluatie
     6299// binnenkomen, dus de koppeling is altijd eerder dan de eerste aanroep.
     6300wireC2S({
     6301  proposeGate, deriveHandle, resolveRemoteNote, deliverReply, markRead,
     6302  postIdFromNoteUrl, sendInteraction, setReaction, gateOutgoingFollow,
     6303  followActor, unfollowActor, blockTarget, unblock, deliverDelete,
     6304  deliverOutboxDelete, bakePostContent, bakePostContentWithMentions,
     6305  deliverCreate,
     6306});
     6307
    66816308export default {
    66826309  movedLock,
Note: See TracChangeset for help on using the changeset viewer.