Changeset 1e172f3 in Klonkt


Ignore:
Timestamp:
08/07/2026 12:00:22 PM (5 weeks ago)
Author:
roboburr <roboburr@…>
Branches:
main
Children:
4c1e327
Parents:
ea138c0
git-author:
Robin <roboburr@…> (08/07/2026 12:00:20 PM)
git-committer:
roboburr <roboburr@…> (08/07/2026 12:00:22 PM)
Message:

Modules laden vanuit de shell in plaats van inline script (shaer-bqr, stap 1)

Het mechanisme uit optie C, met de bottom-tab als eerste geval zodat het ook
te bewijzen is.

WAAROM. De CSP-nonce rouleert per verzoek (shaer-0i6). Een script dat via htmx
binnenkomt draagt dus een nonce die het document niet kent en wordt geweigerd.
De chrome komt bij ELKE navigatie out-of-band opnieuw binnen, dus daar valt de
JS bij de eerste klik binnen de site al weg.

HOE. Een bootstrap in shell.ejs -- die komt alleen bij een volledige laadbeurt
binnen en heeft dus wel de goede nonce. Hij leest body[data-js], een lijst
modulenamen, en importeert ze uit /assets/js/mod/. Een dynamische import vanuit
een vertrouwd script is precies waar strict-dynamic voor bedoeld is, dus de
module zelf heeft geen nonce nodig.

Bij een htmx-navigatie zet de pcmsNav-trigger data-js opnieuw en haalt de
bootstrap op wat er nieuw bij staat. 'chrome' staat er altijd bij.

De naam wordt een PAD, dus hij moet door /[a-z0-9-]+$/ -- geen punt, geen
schuine streep.

EERSTE GEVAL: de zoekknop van de bottom-tab. Geen servergegevens erin, al
gedelegeerd, al voorzien van een slot -- dus de verhuizing verandert niets aan de
logica en het mechanisme is er echt mee te toetsen.

WAT DIT BLOOTLEGT VOOR DE VOLGENDE STAP: het topnav-script interpoleert
vertalingen (<%= t('search.section_posts') %>) en kan dus niet zomaar een
statisch bestand worden. Servergegevens horen via een data-attribuut naar een
module, niet via interpolatie in de code. Dat is een eigen stap en staat als
zodanig in mod/chrome.js opgeschreven.

Templates compileren, suite 551/551. Het echte bewijs is een klik BINNEN de site:
na een herlading werkt alles toch al.

Location:
src
Files:
1 added
8 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    rea138c0 r1e172f3  
    106106    PRIMARY KEY (guardian_slug, id)
    107107  )`);
     108  // Guardianship Fase 2 (shaer-jdb): een doorgestuurde follow-goedkeuring draagt
     109  // een RICHTING. Bij een inkomende is de follower iemand anders en de ward het
     110  // doel; bij een uitgaande is de ward zelf de follower en staat het doel in het
     111  // Follow-object. Zonder deze twee kolommen werd een uitgaande opgeslagen als
     112  // "deze ward wil deze ward volgen" en viel het doel weg -- dan valt er niets
     113  // zinnigs te tonen, hoe je de wachtrij ook vult.
     114  ensureColumn('ap_follow_reviews', 'direction', "TEXT DEFAULT 'incoming'");
     115  ensureColumn('ap_follow_reviews', 'target_uri', 'TEXT');
     116  ensureColumn('ap_follow_reviews', 'target_handle', 'TEXT');
    108117  ensureColumn('sites', 'profile_photo', 'TEXT');
    109118  ensureColumn('audio_tracks', 'cover_url', 'TEXT');
  • src/middleware/render.js

    rea138c0 r1e172f3  
    286286        ? _site.accent : '#e8b04b';
    287287      const _navPalette = (_site && _site.palette) ? _site.palette : 'klonkt';
     288      // Welke modules de nieuwe pagina wil (shaer-bqr). De bootstrap in de shell
     289      // zet dit op de body en haalt op wat er nieuw bij staat; 'chrome' hoort er
     290      // altijd bij, want die komt bij elke navigatie opnieuw binnen.
     291      const _navJs = ('chrome ' + (locals.pageJs || '')).trim();
    288292      const triggerJson = JSON.stringify({
    289         pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette },
     293        pcmsNav: { bodyClass: locals.bodyClass, accent: _navAccent, palette: _navPalette, js: _navJs },
    290294        pcmsPostSwap: data.post ? {
    291295          title: data.post.title,
  • src/routes/activitypub.js

    rea138c0 r1e172f3  
    253253}
    254254queueRoute('offers', (id, slug, me) => Guardianship.offersCollection(id, slug, me));
    255 queueRoute('follows', (id) => Guardianship.followsCollection(id));
     255queueRoute('follows', (id, slug, me) => Guardianship.followsCollection(id, slug, me));
    256256// §5.3 turned around (shaer-p729): what this ward has asked to follow, still
    257257// waiting on its guardians. Owner-only like the rest — who a child wants to
  • src/services/ActivityPubService.js

    rea138c0 r1e172f3  
    49024902      const wardDoc = await fetchActor(wardUri).catch(() => null);
    49034903      const fai = actorInfo(await fetchActor(follower).catch(() => null), follower);
    4904       Guardianship.follows.recordReview(gslug, { id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox, follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo) });
     4904      // De RICHTING bewaren (shaer-jdb). shaer:direction wordt sinds de uitgaande
     4905      // gate meegestuurd maar werd nergens gelezen, dus een uitgaande belandde
     4906      // hier als "deze ward wil deze ward volgen" met het doel weggegooid.
     4907      // Terugval voor oudere afzenders: is de volger de ward zelf, dan is het
     4908      // uitgaand -- dat volgt uit de vorm en hoeft niet geloofd te worden.
     4909      const uitgaand = act['shaer:direction'] === 'outgoing' || follower === wardUri;
     4910      const doel = uitgaand ? (typeof fo.object === 'string' ? fo.object : (fo.object && fo.object.id)) : null;
     4911      const dai = uitgaand ? actorInfo(await fetchActor(doel).catch(() => null), doel) : null;
     4912      Guardianship.follows.recordReview(gslug, {
     4913        id: followId, wardUri, wardInbox: wardDoc && wardDoc.inbox,
     4914        follower, followerHandle: fai.handle, followerIcon: fai.icon, followJson: JSON.stringify(fo),
     4915        direction: uitgaand ? 'outgoing' : 'incoming',
     4916        target: doel || null, targetHandle: dai ? dai.handle : null,
     4917      });
    49054918      const L = pushLang(gslug);
    49064919      pushEvent(gslug, { type: 'guardian', title: i18nT(L, 'push.n_guard_cog_t'), body: i18nT(L, 'push.n_guard_cog_b', { who: fai.name || fai.handle || i18nT(L, 'notif.someone') }), url: `${pushPrefix(gslug)}/guardian` });
  • src/services/guardianship/follows.js

    rea138c0 r1e172f3  
    8181    _r = {
    8282      ins: db.prepare(`INSERT OR IGNORE INTO ap_follow_reviews
    83         (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json, created_at)
    84         VALUES (?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
     83        (id, guardian_slug, ward_uri, ward_inbox, follower_uri, follower_handle, follower_icon, follow_json,
     84         direction, target_uri, target_handle, created_at)
     85        VALUES (?,?,?,?,?,?,?,?,?,?,?, CURRENT_TIMESTAMP)`),
    8586      get: db.prepare('SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND id = ?'),
    8687      bySlug: db.prepare("SELECT * FROM ap_follow_reviews WHERE guardian_slug = ? AND status = 'pending' ORDER BY created_at DESC"),
     
    9192}
    9293
     94/**
     95 * De guardian-zijdige kopie van een gate-verzoek op een REMOTE ward.
     96 *
     97 * `direction` is niet cosmetisch (shaer-jdb). Bij een INKOMENDE is de follower
     98 * iemand anders en de ward het doel. Bij een UITGAANDE is de ward zelf de
     99 * follower en staat het doel in het Follow-object -- die werd hiervoor
     100 * opgeslagen als "deze ward wil deze ward volgen", met het doel weggegooid.
     101 */
    93102export function recordReview(guardianSlug, r) {
    94   rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null, r.followerIcon || null, r.followJson || null);
     103  const richting = r.direction === 'outgoing' ? 'outgoing' : 'incoming';
     104  rstmts().ins.run(r.id, guardianSlug, r.wardUri, r.wardInbox || null, r.follower, r.followerHandle || null,
     105    r.followerIcon || null, r.followJson || null, richting, r.target || null, r.targetHandle || null);
    95106  return rstmts().get.get(guardianSlug, r.id);
     107}
     108
     109/**
     110 * Een openstaande review als wachtrij-item, in dezelfde vorm die de clients al
     111 * lezen (offers en outgoing-follows doen het net zo).
     112 */
     113export function reviewQueueItem(r, me, guardianCount) {
     114  // guardianCount blijft WEG als we hem niet kennen. Bij een remote ward wordt
     115  // de guardian-set op diens eigen server bijgehouden, en 0 sturen zou lezen als
     116  // "dit kind heeft geen guardians" -- het tegenovergestelde van onbekend.
     117  const stemmen = (() => {
     118    try { return db.prepare('SELECT guardian_uri, decision FROM ap_pending_follow_approvals WHERE follow_id = ?').all(r.id); }
     119    catch { return []; }
     120  })();
     121  const uitgaand = r.direction === 'outgoing';
     122  return {
     123    id: r.id,
     124    type: 'Follow',
     125    // Bij een uitgaande is de WARD de volger; bij een inkomende is dat de vreemde.
     126    actor: uitgaand ? r.ward_uri : r.follower_uri,
     127    object: uitgaand ? (r.target_uri || '') : r.ward_uri,
     128    'shaer:direction': uitgaand ? 'outgoing' : 'incoming',
     129    'shaer:ward': r.ward_uri,
     130    'shaer:target': uitgaand ? (r.target_uri || undefined) : undefined,
     131    'shaer:targetHandle': uitgaand ? (r.target_handle || undefined) : undefined,
     132    'shaer:follower': uitgaand ? undefined : r.follower_uri,
     133    'shaer:followerHandle': uitgaand ? undefined : (r.follower_handle || undefined),
     134    'shaer:quorum': 'all',
     135    'shaer:approvals': stemmen.filter((x) => x.decision === 'approve').length,
     136    'shaer:guardianCount': guardianCount || undefined,
     137    'shaer:myVote': stemmen.some((x) => x.guardian_uri === me),
     138    published: r.created_at,
     139  };
     140}
     141
     142/** De openstaande reviews van een guardian, per richting. */
     143export function listReviewsByDirection(guardianSlug, direction) {
     144  return listReviews(guardianSlug).filter((r) => (r.direction === 'outgoing' ? 'outgoing' : 'incoming') === direction);
    96145}
    97146export function getReview(guardianSlug, id) { return rstmts().get.get(guardianSlug, id); }
     
    102151  recordPending, getPending, listForWard, decide, remove,
    103152  recordReview, getReview, listReviews, removeReview,
     153  listReviewsByDirection, reviewQueueItem,
    104154};
  • src/services/guardianship/queues.js

    rea138c0 r1e172f3  
    66 *  - offers:  pending handshake offers where I am a party (§3), with the full
    77 *             accept tally so the client shows the right action
    8  *  - follows: pending gated follows for my wards (§5.3) — Fase 2, empty for now
     8 *  - follows: pending gated follows ON my wards (§5.3), Fase 2 (shaer-jdb)
    99 *  - wards:   my committed wards
    1010 */
     
    1313import * as availability from './availability.js';
    1414import * as outgoing from './outgoing.js';
     15import * as follows from './follows.js';
    1516import * as handshake from './handshake.js';
    1617
     
    3536}
    3637
    37 /** Gated follows awaiting guardian approval — not built in Klonkt yet (Fase 2). */
    38 export function followsCollection(id) {
    39   return collection(id, []);
     38/**
     39 * Gate-verzoeken OP mijn wards die op mijn antwoord wachten (Guardianship Fase 2,
     40 * shaer-jdb). Dit was een lege stub: de gating zelf werkt sinds shaer-hxg, maar
     41 * werd nooit aan een C2S-client doorgegeven omdat de koers toen op de PWA lag.
     42 *
     43 * Twee bronnen, want een guardian kan wards op andere servers hebben en (nog)
     44 * op deze:
     45 *   - ap_follow_reviews: de doorgestuurde kopie van een REMOTE ward
     46 *   - ap_pending_follows: een ward op deze instance
     47 * Zie shaer-h6u: die tweede hoort op termijn ook over de lijn te gaan.
     48 */
     49export function followsCollection(id, slug, me) {
     50  const items = follows.listReviewsByDirection(slug, 'incoming')
     51    .map((r) => follows.reviewQueueItem(r, me));
     52  for (const w of relations.listWards(slug)) {
     53    const wardSlug = slugOf(w.other_uri);
     54    if (!wardSlug) continue;
     55    for (const p of follows.listForWard(wardSlug)) {
     56      items.push({
     57        id: p.id, type: 'Follow', actor: p.follower_uri, object: w.other_uri,
     58        'shaer:direction': 'incoming', 'shaer:ward': w.other_uri,
     59        'shaer:follower': p.follower_uri, 'shaer:followerHandle': p.follower_handle || undefined,
     60        'shaer:quorum': p.quorum || 'any', published: p.created_at,
     61      });
     62    }
     63  }
     64  return collection(id, items);
    4065}
    4166
    42 /** §5.3 outbound: this ward's own follow requests, waiting for its guardians. */
     67/** De slug van een actor-uri op DEZE instance, of null als hij elders woont. */
     68function slugOf(uri) {
     69  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
     70  if (!base || !String(uri || '').startsWith(`${base}/ap/users/`)) return null;
     71  return decodeURIComponent(String(uri).slice(`${base}/ap/users/`.length).split(/[/?#]/)[0]) || null;
     72}
     73
     74/**
     75 * §5.3 uitgaand. Twee lezers, een wachtrij, en dat kan omdat §1 een ward en een
     76 * guardian wederzijds uitsluit: je bent het een of het ander.
     77 *
     78 *   ALS WARD      wat IK wil volgen en waar mijn guardians nog over moeten
     79 *   ALS GUARDIAN  wat mijn WARDS willen volgen en waar IK over moet (shaer-jdb)
     80 *
     81 * Dat tweede ontbrak. De wachtrij serveerde alleen listForWard(slug), en voor
     82 * een guardian is dat per definitie leeg -- dus het scherm "Your wards want to
     83 * follow" kon nooit iets tonen.
     84 */
    4385export function outgoingFollowsCollection(id, slug, me) {
    44   return collection(id, outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me)));
     86  const items = outgoing.listForWard(slug).map((o) => outgoing.queueItem(o, me));
     87  for (const r of follows.listReviewsByDirection(slug, 'outgoing')) {
     88    items.push(follows.reviewQueueItem(r, me));
     89  }
     90  return collection(id, items);
    4591}
    4692
  • src/views/partials/bottom-tab.ejs

    rea138c0 r1e172f3  
    302302</style>
    303303
    304 <script>
    305 // Bottom-tab search button → opens #search-overlay. Using event delegation + looking
    306 // up the overlay on EVERY click: the overlay lives in the chrome that is OOB-replaced
    307 // during htmx navigation, so a once-captured reference goes stale
    308 // (button did nothing afterwards). Guard against double-wiring.
    309 (function() {
    310   if (window.__btSearchWired) return;
    311   window.__btSearchWired = true;
    312   document.addEventListener('click', function(e) {
    313     if (!e.target.closest || !e.target.closest('#bottom-tab-search-toggle')) return;
    314     var o = document.getElementById('search-overlay');
    315     if (o) { o.hidden = false; var i = o.querySelector('input'); if (i) i.focus(); }
    316   });
    317 })();
    318 </script>
     304<%# De zoekknop zit nu in de chrome-module (assets/js/mod/chrome.js).
     305    Inline script hier wordt door de CSP geweigerd zodra deze partial via htmx
     306    binnenkomt, en dat is bij elke navigatie -- zie shaer-0i6. %>
  • src/views/shell.ejs

    rea138c0 r1e172f3  
    339339</head>
    340340
    341 <body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
     341<body class="<%= bodyClass || 'on-home' %> has-bottom-tab" data-js="chrome<%= (typeof pageJs !== 'undefined' && pageJs) ? ' ' + pageJs : '' %>" data-feed-view="<%- _e(safeSite.feed_view_default || 'timeline') %>" data-grid-cols="3" data-site-base="<%- _e((typeof siteUrlBase !== 'undefined' && siteUrlBase) ? siteUrlBase : '') %>">
    342342
    343343<% if (typeof isViewer !== 'undefined' && isViewer) { %>
     
    449449    }
    450450
     451    // Welke modules deze pagina wil (shaer-bqr). De bootstrap hieronder leest
     452    // dit zodra deze handler klaar is.
     453    if (typeof d.js === 'string' && /^[a-z0-9 -]*$/.test(d.js)) {
     454      document.body.setAttribute('data-js', d.js);
     455    }
     456
    451457    var next = d.bodyClass;
    452458    if (!next) return;
     
    467473  //  de pcmsNav-trigger (accent/palette/bodyClass) correct toepast. De vroegere
    468474  //  htmx:historyRestore-handler is vervallen nu htmx-history uitstaat.)
     475})();
     476</script>
     477
     478<!-- Modules laden (shaer-bqr). Inline script in gewisselde inhoud wordt door de
     479     CSP geweigerd: de nonce rouleert per verzoek, dus een script dat via htmx
     480     binnenkomt draagt er een die dit document niet kent (shaer-0i6). Alles wat
     481     bij een pagina hoort komt daarom uit een module, en die wordt HIER geladen —
     482     vanuit de shell, die alleen bij een volledige laadbeurt binnenkomt en dus
     483     wél de goede nonce heeft.
     484
     485     Een dynamische import vanuit een vertrouwd (genonced) script is precies waar
     486     'strict-dynamic' voor bedoeld is, dus de module zelf heeft geen nonce nodig. -->
     487<script>
     488(function () {
     489  if (window.__modBoot) return;
     490  window.__modBoot = true;
     491
     492  var loaded = {};
     493  function load() {
     494    var names = (document.body.getAttribute('data-js') || '').trim().split(/\s+/);
     495    names.forEach(function (name) {
     496      // Streng: deze waarde komt uit een template en wordt een PAD. Alleen
     497      // kleine letters, cijfers en streepjes; nooit een punt of een schuine
     498      // streep.
     499      if (!name || loaded[name] || !/^[a-z0-9-]+$/.test(name)) return;
     500      loaded[name] = 1;
     501      import('/assets/js/mod/' + name + '.js').catch(function (e) {
     502        console.warn('[mod] ' + name + ' laadde niet:', e && e.message);
     503      });
     504    });
     505  }
     506  load();
     507  // Bij een htmx-navigatie wisselt de INHOUD, niet de body. De nav-trigger
     508  // hieronder zet data-js opnieuw; daarna halen we op wat er nieuw bij staat.
     509  // Een module die er al is wordt niet opnieuw geimporteerd -- vandaar dat elke
     510  // module gedelegeerd moet werken en tegen een tweede aanroep moet kunnen.
     511  document.body.addEventListener('pcmsNav', load);
    469512})();
    470513</script>
Note: See TracChangeset for help on using the changeset viewer.