Changeset 952baf3 in Klonkt for src/routes


Ignore:
Timestamp:
08/07/2026 05:15:52 PM (5 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
ba76bf5
Parents:
f85b2c3 (diff), 0d5bd2c (diff)
Note: this is a merge changeset, the changes displayed below correspond to the merge itself.
Use the (diff) links above to see all the changes relative to each parent.
Message:

Merge GitHub-main (1.7.0) met de VPS-lijn

De twee mains waren een dag gedivergeerd en bevatten elk echt werk. GitHub had 66
commits die nooit langs prutfolio.git zijn gekomen, omdat een parallelle sessie
rechtstreeks naar GitHub pushte vanaf een kloon in /tmp op de VPS. De VPS had twee
commits die GitHub niet had. Geen van beide bevatte de ander, en stable had geen van
de twee.

Bewust een merge en geen rebase: dan blijft beide historie intact en wordt er niets
herschreven waar iemand anders al op voortbouwt.

Drie bestanden raakten beide kanten. Alle drie zijn nagekeken, want dat een merge
automatisch slaagt zegt niets over of hij inhoudelijk klopt:

src/services/ActivityPubService.js

  • de sleutelbinding staat nu boven de nieuwe asSlug-aanroep van 1.7.0, dus de controle komt nog steeds voor de handtekeningcontrole

scripts/klonkt-refresh-updater.sh

  • alleen de opzij-aanpak overleefde; systemctl mask staat nergens meer als code

deploy/MULTI-INSTANCE.md

  • spreekt zichzelf niet tegen: beschrijft opzij zetten, met de reden waarom mask weigert

remarks: het gat dat in de review naar boven kwam staat hiermee ook op de 1.7.0-lijn.
De andere bevindingen uit die review staan nog open en zijn niet in deze merge
opgelost; die horen als beads. Ook nog te doen: dezelfde sleutelbinding op stable
als 1.6.1, want daar is het gat nog open bij self-hosters.

-robo
Co-Authored-By: Claude Opus 4.8 <noreply@…>

Location:
src/routes
Files:
15 edited

Legend:

Unmodified
Added
Removed
  • src/routes/activitypub.js

    rf85b2c3 r952baf3  
    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
     
    268268// they follow) as Create(Note) items, so an app (Shaer) can build a unified
    269269// feed. Anyone else gets 403; the inbox stays write-only for the public.
    270 router.get('/ap/users/:slug/inbox', (req, res) => {
     270router.get('/ap/users/:slug/inbox', async (req, res) => {
    271271  const auth = OAuth.verifyBearer(req.headers.authorization);
    272272  if (!auth || auth.site.slug !== req.params.slug) return res.status(403).end();
    273273  const base = baseUrl(req);
     274  // Wachten is een UITBREIDING van deze lezing, geen tweede endpoint (shaer-n05).
     275  // Geef `since` (de shaer:cursor van je vorige antwoord) en `wait` mee, en het
     276  // antwoord blijft hangen tot er iets is of de tijd om is. Zonder die twee
     277  // gedraagt de route zich exact zoals altijd.
     278  //
     279  // Bewust hetzelfde antwoord in plaats van een "er is nieuws"-seintje: dan
     280  // hoeft er niets nieuws geparsed te worden, is er geen tweede beschrijving van
     281  // de kaartvorm die uit de pas kan lopen, en scheelt het de client een tweede
     282  // ronde.
     283  const wachtS = Math.min(Math.max(parseInt(req.query.wait, 10) || 0, 0), 50);
     284  if (req.query.since && wachtS > 0) {
     285    const afbreken = new AbortController();
     286    res.on('close', () => afbreken.abort());   // client hing op: niet doorgaan met wachten
     287    const uit = await AP.waitForFeedChange(auth.site.slug, {
     288      since: String(req.query.since), waitMs: wachtS * 1000, signal: afbreken.signal,
     289    });
     290    if (res.writableEnded || afbreken.signal.aborted) return undefined;
     291    // Niets veranderd? Dan een LEEG antwoord (Barts punt): de hele collectie
     292    // terugsturen terwijl er niets gebeurd is, is elke 25 seconden een tijdlijn
     293    // over de mobiele verbinding voor niets. Met 304 kost stilte niets en kost
     294    // nieuws nog steeds maar één rondje -- beter dan een apart seintje-endpoint,
     295    // dat voor nieuws twee rondjes nodig heeft.
     296    //
     297    // De '0'-uitzondering is geen franje. Ontbreekt ap_feed_state (een instance
     298    // die de migratie nog niet draaide), dan geeft feedCursor altijd '0' terug,
     299    // en zou een client hier eeuwig 304 krijgen en nooit meer inhoud zien. Bij
     300    // een lege merksteen sturen we dus gewoon de collectie.
     301    if (!uit.changed && uit.cursor !== '0') {
     302      res.set('Vary', 'Authorization');
     303      return res.status(304).end();
     304    }
     305  }
    274306  // Gated feature (FEP-633c): may this account see EXTERNAL embeds? A ward's
    275307  // world outside the fediverse is the guardians' call. The gate is applied
     
    285317  const playbackAllowed = embedsAllowed
    286318    && Guardianship.externalPlaybackAllowed(auth.site.external_playback, isWard);
    287   const posts = AP.getTimeline(auth.site.slug, 60).map((t) => ({
     319  const rows = AP.getTimeline(auth.site.slug, 60);
     320  // Eén query voor de hele pagina (shaer-9e9 fase 2): shaer:liked komt uit de
     321  // tussentabel, de bron van waarheid, en niet meer uit de afgeleide kolom op
     322  // ap_timeline. Per rij vragen zou hier een N+1 opleveren.
     323  const reacties = AP.getReactionsFor(auth.site.slug, rows.map((t) => t.id));
     324  const posts = rows.map((t) => ({
    288325    id: `${t.id}#create`,
    289326    type: 'Create',
     
    333370      // Whether THIS account already liked/boosted the note, so the app's
    334371      // detail-view buttons show the current state (and can toggle/undo).
    335       'shaer:liked': !!t.liked,
    336       'shaer:boosted': !!t.boosted,
     372      'shaer:liked': !!(reacties.get(t.id) || {}).liked,
     373      'shaer:boosted': !!(reacties.get(t.id) || {}).boosted,
    337374      // An external (non-fediverse) embed, thumbnail-only and never an iframe.
    338375      // Omitted entirely when the gate is closed (see above).
     
    439476      'shaer:externalLinks': playbackAllowed,
    440477    },
     478    // Het merk van wat hierin zit. Geef hem terug als `since` om op het
     479    // volgende te wachten. NA het samenstellen bepaald, zodat hij precies dekt
     480    // wat je in handen hebt en niet iets dat er ondertussen bij kwam.
     481    'shaer:cursor': AP.feedCursor(auth.site.slug),
    441482    totalItems: items.length,
    442483    orderedItems: items,
    443484  });
     485  return undefined;
    444486});
    445487
  • src/routes/admin-audio.js

    rf85b2c3 r952baf3  
    111111  const embedUrl = base + (res.locals.siteUrlBase || '') + '/embed';
    112112  renderPage(req, res, 'pages/admin-audio', {
     113    // admin-audio neemt de track-editor op, dus die module hoort erbij.
     114    pageJs: 'admin-audio track-editor',
    113115    pageTitleKey: 'admin.t_audio',
    114116    bodyClass: 'on-admin',
  • src/routes/admin-epk.js

    rf85b2c3 r952baf3  
    3333  try { const r = JSON.parse(getSetting('epk_tracks_' + site.id, '') || '[]'); if (Array.isArray(r)) chosen = r; } catch (e) {}
    3434  renderPage(req, res, 'pages/admin-epk', {
     35    pageJs: 'admin-epk',
    3536    pageTitleKey: 'admin.t_epk',
    3637    bodyClass: 'on-admin',
  • src/routes/admin-media.js

    rf85b2c3 r952baf3  
    8181    .sort((a, b) => b._mtime - a._mtime); // newest first
    8282  renderPage(req, res, 'pages/admin-media', {
     83    pageJs: 'admin-media',
    8384    pageTitleKey: 'admin.t_media',
    8485    bodyClass: 'on-admin',
     
    122123  if (!site) return res.status(404).send('Site required');
    123124  renderPage(req, res, 'pages/admin-videos', {
     125    pageJs: 'admin-videos',
    124126    pageTitleKey: 'admin.t_media',
    125127    bodyClass: 'on-admin',
  • src/routes/admin-paid.js

    rf85b2c3 r952baf3  
    3636  if (!gate(req, res)) return;
    3737  renderPage(req, res, 'pages/admin-paid', {
     38    pageJs: 'admin-paid',
    3839    pageTitleKey: 'apaid.t',
    3940    bodyClass: 'on-admin',
  • src/routes/admin-playlists.js

    rf85b2c3 r952baf3  
    6262  const playlists = PlaylistService.list(site.id);
    6363  renderPage(req, res, 'pages/admin-playlists', {
     64    // admin-playlists neemt de playlist-editor op, dus die module hoort erbij.
     65    pageJs: 'admin-playlists playlist-editor',
    6466    pageTitleKey: 'admin.t_playlists',
    6567    playlists,
  • src/routes/admin-push.js

    rf85b2c3 r952baf3  
    1212router.get('/', requireSiteManager, async (req, res) => {
    1313  renderPage(req, res, 'pages/admin-push', {
     14    pageJs: 'admin-push',
    1415    pageTitleKey: 'push.t',
    1516    bodyClass: 'on-admin',
  • src/routes/admin-settings.js

    rf85b2c3 r952baf3  
    6666router.get('/', requireGod, (req, res) => {
    6767  renderPage(req, res, 'pages/admin-settings', {
     68    pageJs: 'admin-settings',
    6869    pageTitleKey: 'admin.t_settings',
    6970    bodyClass: 'on-admin',
  • src/routes/admin-sites.js

    rf85b2c3 r952baf3  
    200200router.get('/new', requireGod, (req, res) => {
    201201  renderPage(req, res, 'pages/admin-site-edit', {
     202    pageJs: 'admin-site-edit',
    202203    pageTitleKey: 'admin.t_newsite',
    203204    bodyClass: 'on-admin',
     
    283284
    284285  renderPage(req, res, 'pages/admin-site-edit', {
     286    pageJs: 'admin-site-edit',
    285287    pageTitleKey: 'admin.t_editsite', pageTitleVars: { title: site.title },
    286288    bodyClass: 'on-admin',
  • src/routes/admin.js

    rf85b2c3 r952baf3  
    8787router.get('/handleiding', requireAuth, (req, res) => {
    8888  renderPage(req, res, 'pages/admin-help', {
     89    pageJs: 'admin-help',
    8990    pageTitleKey: 'admin.t_manual',
    9091    bodyClass: 'on-admin',
  • src/routes/auth.js

    rf85b2c3 r952baf3  
    115115  if (!isSetupMode()) return res.redirect('/auth/login' + (next ? '?next=' + encodeURIComponent(next) : ''));
    116116  renderPage(req, res, 'pages/auth-register', {
     117    pageJs: 'auth-register',
    117118    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
    118119    error: null, username: '', email: '', siteName: '', next,
     
    124125  const next = safeNext(req.body.next) || '';
    125126  const renderErr = (error) => renderPage(req, res, 'pages/auth-register', {
     127    pageJs: 'auth-register',
    126128    pageTitle: t(resolveLang(req), 'setup.title'), bodyClass: 'on-special',
    127129    error, username: username || '', email: email || '', siteName: siteName || '', next,
     
    205207      } else {
    206208        // Production without SMTP: NEVER log the token. Refer to the CLI break-glass.
    207         console.log(`[password-reset] aangevraagd voor ${user.email} (geen SMTP — gebruik 'npm run reset-admin')`);
     209        console.log(`[password-reset] requested for ${user.email} (no SMTP — use 'npm run reset-admin')`);
    208210      }
    209211    }
  • src/routes/download.js

    rf85b2c3 r952baf3  
    8585  const fan = req.session && req.session.user;
    8686  renderPage(req, res, 'pages/download', {
     87    pageJs: 'download',
    8788    pageTitle: track.title + ' — download',
    8889    bodyClass: 'on-download',
  • src/routes/guardian.js

    rf85b2c3 r952baf3  
    5757    'prop_line', 'prop_embeds', 'prop_play', 'prop_on', 'prop_off',
    5858    'prop_st_open', 'prop_st_accepted', 'prop_st_rejected', 'prop_st_expired',
    59     'panel_guards_far'];
     59    'panel_guards_far',
     60    // Het gate-paneel per ward (shaer-ahy.1): een rij per gate, met het soort en
     61    // de drempel erbij. De namen volgen de catalogus in gated.js.
     62    'gate_externalEmbeds', 'gate_externalPlayback', 'gate_follows',
     63    'gate_kind_setting', 'gate_kind_perRequest', 'gate_kind_handover',
     64    'gate_unknown', 'gate_threshold', 'gate_threshold_unknown',
     65    'gate_irreversible', 'gate_waiting', 'gate_blocked', 'gate_propose',
     66    // Oppikken en afhandelen van een hulpvraag (shaer-lgo).
     67    'help_pick', 'help_close', 'help_picked_by', 'help_handled_by', 'help_handled_note',
     68    'help_close_ask', 'help_close_yes', 'help_just_now', 'help_hours', 'help_days',
     69    'help_archive', 'help_archive_hide'];
    6070  const s = Object.fromEntries(keys.map((k) => [k, i18nT(L, `guardian.${k}`)]));
    6171  s.wave = i18nT(L, 'guardian.wave');
     
    7181            emoji_json, actor_emoji_json, media_json, quote_json, embed_json
    7282     FROM ap_mentions WHERE slug = ? AND help_request = 1 ORDER BY created_at DESC LIMIT 50`
    73   ).all(site.slug).map((h) => ({
     83  ).all(site.slug);
     84  // De gedeelde staat in EEN query (shaer-lgo): wie er al op af is en of het is
     85  // afgesloten. Per kaart vragen zou hier een N+1 opleveren, en dit is precies
     86  // het scherm dat een guardian in een haast openslaat.
     87  const helpStaat = Guardianship.help.statusFor(help.map((h) => h.object_uri));
     88  const helpItems = help.map((h) => ({
    7489    ...h,
     90    // Bij twijfel OPEN. Een hulpvraag die er afgehandeld uitziet terwijl hij dat
     91    // niet is, is de gevaarlijke fout -- niet andersom.
     92    state: helpStaat.get(h.object_uri) || { open: true, pickedUpBy: [], handled: null, ageMs: null },
    7593    // The dashboard is built in the browser, so it gets the body finished: the
    7694    // same partial de Krant and Berichten use. A 🛟 often carries a screenshot
     
    103121        status: Guardianship.gated.sentStatus(p, Date.now()),
    104122      })),
     123      // Alles wat voor dit kind gated is op EEN plek, met per gate het soort en
     124      // de drempel (shaer-ahy.1). Losse knoppen lieten een guardian zelf
     125      // uitzoeken wat er allemaal geldt; wat niet verstelbaar is stond nergens.
     126      gates: wardGates(site.slug, w.other_uri),
    105127    })),
    106128    offers: Guardianship.offersCollection(`${me}/queues/offers`, site.slug, me).orderedItems,
     
    113135      id: r.id, ward: r.ward_uri, proposer: r.proposer, feature: r.feature, value: !!r.value,
    114136    })),
    115     help,
     137    help: helpItems,
    116138    strings: uiStrings(L),
    117139  };
     
    192214  const site = siteForUser(req);
    193215  if (!site) return res.status(404).json({ error: 'no_site' });
     216  const L = resolveLang(req);
    194217  ensureWardConnections(site);
    195218  const wardUris = new Set(Guardianship.listWards(site.slug).map((w) => w.other_uri));
     
    209232      cw: p.cw || null,
    210233      media: p.media_json ? JSON.parse(p.media_json) : [],
     234      // Een post van je ward hoort er hetzelfde uit te zien als in de Krant en
     235      // in Berichten: dezelfde partial, dus opmaak, media, quote-kaart en
     236      // embed. Tot nu toe kreeg de PWA alleen kale content -- een guardian zag
     237      // een lege regel waar een foto stond. `content` blijft ernaast staan voor
     238      // een client die nog uit de cache draait.
     239      body_html: renderNoteBody(p, L),
    211240    }));
    212241  res.json({ items, following: wardUris.size });
     
    319348  if (!r) return res.status(502).json({ error: 'delivery' });
    320349  res.json({ ok: true, delivered: r.delivered });
     350});
     351
     352// ── Een hulpvraag oppikken of afsluiten (shaer-lgo) ───────────────
     353// Gaat naar de WARD en naar de MEDE-GUARDIANS. De ward hoort te weten dat er
     354// iemand komt -- dat is de helft van de gerustheid -- en de anderen dat het
     355// loopt, zodat niemand denkt dat de ander het al doet.
     356//
     357// OPPIKKEN mag stapelen: twee mensen die tegelijk reageren is geen probleem.
     358// AFSLUITEN kent geen terugdraai; leeft de vraag nog, dan wordt hij opnieuw
     359// gesteld. De stevige bevestiging zit in de client, net als bij het loslaten van
     360// een ward: nooit een window.confirm.
     361router.post('/api/help/:kind', requireAuth, express.json({ limit: '2kb' }), async (req, res) => {
     362  const site = siteForUser(req);
     363  if (!site) return res.status(404).json({ error: 'no_site' });
     364  const kind = req.params.kind === 'handled' ? 'handled' : 'pickup';
     365  const noteUri = String(req.body?.note || '').trim();
     366  const wardUri = String(req.body?.ward || '').trim();
     367  if (!noteUri || !/^https?:\/\//i.test(noteUri)) return res.status(400).json({ error: 'no_note' });
     368  // Alleen over een hulpvraag van een kind dat je echt bewaakt.
     369  const isWard = Guardianship.listWards(site.slug).some((w) => w.other_uri === wardUri);
     370  if (!isWard) return res.status(403).json({ error: 'not_your_ward' });
     371
     372  const me = AP.actorId((process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, ''), site.slug);
     373  // Onze eigen kopie meteen, zonder op bezorging te wachten: het scherm van
     374  // degene die klikt hoort niet te liegen omdat een andere server traag is.
     375  Guardianship.help.record(noteUri, me, kind, null);
     376
     377  const anderen = Guardianship.listGuardians(wardUri.replace(/.*\/ap\/users\//, '')) || [];
     378  const ontvangers = [wardUri, ...anderen.map((g) => g.other_uri)].filter((u) => u && u !== me);
     379  const r = await AP.deliverDirectNote(site, {
     380    recipients: ontvangers,
     381    text: kind === 'handled' ? 'Deze hulpvraag is afgehandeld.' : 'Ik kijk hiernaar.',
     382    helpMark: { kind, noteUri },
     383  }).catch(() => null);
     384  // Bezorging kan mislukken; de eigen staat staat er dan toch. Dat melden we,
     385  // want "verstuurd" zeggen terwijl het niet aankwam is hier het ergste soort
     386  // stilte.
     387  res.json({ ok: true, delivered: r ? r.delivered : 0, recipients: ontvangers.length });
    321388});
    322389
     
    531598/** The playback gate of a ward we host (5.6): the heavier sibling. */
    532599function wardPlaybackSetting(uri) { return wardGateSetting(uri, 'external_playback'); }
     600/**
     601 * De gate-rijen van een ward voor het paneel.
     602 *
     603 * De standen komen uit onze eigen kolommen als we het kind hosten; bij een ward
     604 * elders weten we ze niet en blijft het NULL -- onbekend, niet uit. Het aantal
     605 * guardians idem: dat wordt op de server van die ward bijgehouden, en zonder dat
     606 * getal wordt er geen drempel verzonnen.
     607 */
     608function wardGates(mySlug, wardUri) {
     609  const statuses = wardGuardianStatuses(wardUri);
     610  const wachtend = Guardianship.follows.listReviewsByDirection(mySlug, 'incoming')
     611    .filter((r) => r.ward_uri === wardUri).length;
     612  return Guardianship.gated.gateRows({
     613    settings: {
     614      'shaer:externalEmbeds': wardEmbedSetting(wardUri),
     615      'shaer:externalPlayback': wardPlaybackSetting(wardUri),
     616    },
     617    guardianCount: statuses ? statuses.length : null,
     618    proposals: Guardianship.gated.listSent(mySlug, wardUri).map((p) => ({
     619      feature: p.feature, value: !!p.value, status: Guardianship.gated.sentStatus(p, Date.now()),
     620    })),
     621    waiting: { 'shaer:follows': wachtend || undefined },
     622  });
     623}
     624
    533625function wardGateSetting(uri, column) {
    534626  const base = (process.env.PUBLIC_BASE_URL || '').replace(/\/+$/, '');
  • src/routes/paid.js

    rf85b2c3 r952baf3  
    7676  const blob = signBlob({ purpose: 'reg', siteId: r.site.id, cents, challenge: options.challenge }, 900);
    7777  renderPage(req, res, 'pages/paid-passkey', {
     78    pageJs: 'paid-passkey',
    7879    pageTitleKey: 'ppk.t', bodyClass: 'on-post',
    7980    optionsJson: JSON.stringify(options), regBlob: blob, postSlug: payload.post,
  • src/routes/posts.js

    rf85b2c3 r952baf3  
    256256
    257257  renderPage(req, res, 'pages/post-edit', {
     258    // post-edit neemt de playlist-editor op.
     259    pageJs: 'post-edit playlist-editor',
    258260    post: {
    259261      id: uuid(),
     
    795797  }
    796798  renderPage(req, res, 'pages/authorize-interaction', {
     799    pageJs: 'authorize-interaction',
    797800    pageTitleKey: 'fedi.remote_interact', // i18n: was hardcoded Dutch on non-NL sites
    798801    bodyClass: 'on-special',
     
    806809    liked: !!req.query.liked,
    807810    boosted: !!req.query.boosted,
    808     reacted: (site && uri) ? ActivityPubService.getMyReactions(site.slug, uri) : { liked: false, boosted: false },
     811    reacted: (site && uri) ? ActivityPubService.getReaction(site.slug, uri) : { liked: false, boosted: false },
    809812    siteTitle: site ? site.title : '',
    810813  });
     
    839842  let on = false;
    840843  if (site && uri) {
    841     on = !ActivityPubService.getMyReactions(site.slug, uri).liked;
     844    on = !ActivityPubService.getReaction(site.slug, uri).liked;
    842845    ActivityPubService.resolveRemoteNote(uri)
    843846      .then((note) => note && ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note.object_uri || uri, note.actor_uri))
    844847      .catch((e) => console.warn('[AP] remote like failed:', e.message));
    845     ActivityPubService.setMyReaction(site.slug, uri, 'like', on);
     848    // Eén schrijfpad (shaer-9e9): tussentabel + afgeleide vlag.
     849    ActivityPubService.setReaction(site.slug, uri, 'like', on);
    846850  }
    847851  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    856860  let on = false;
    857861  if (site && uri) {
    858     on = !ActivityPubService.getMyReactions(site.slug, uri).boosted;
     862    on = !ActivityPubService.getReaction(site.slug, uri).boosted;
    859863    ActivityPubService.resolveRemoteNote(uri)
    860864      .then((note) => {
     
    862866        const id = note.object_uri || uri;
    863867        return Promise.resolve(ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', id, note.actor_uri))
    864           // Boost → store the post in the timeline (even if you don't follow the author) so it
    865           // surfaces in the Cirkel; unboost → just clear the flag.
    866           .then(() => on ? ActivityPubService.upsertBoostedNote(site.slug, note) : ActivityPubService.unmarkBoosted(site.slug, id));
     868          // De note gaat mee: een boost zet niet alleen een vlag maar trekt de
     869          // post je tijdlijn in, ook als je de auteur niet volgt, zodat hij in
     870          // de Cirkel verschijnt.
     871          .then(() => ActivityPubService.setReaction(site.slug, uri, 'boost', on, { flagUri: id, note: on ? note : null }));
    867872      })
    868873      .catch((e) => console.warn('[AP] remote boost failed:', e.message));
    869     ActivityPubService.setMyReaction(site.slug, uri, 'boost', on);
     874    // Meteen zetten, zodat de knop klopt voordat de resolve terug is. Via
     875    // setReaction en niet via setMyReaction: ook dit korte moment mag geen
     876    // halve schrijfactie zijn. De resolve hierboven werkt hem daarna bij met de
     877    // note, zodat de post ook in je tijdlijn belandt.
     878    ActivityPubService.setReaction(site.slug, uri, 'boost', on);
    870879  }
    871880  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    930939    : []).filter((o) => o['shaer:ward'] === gMe && o['shaer:needsMyAccept']);
    931940  renderPage(req, res, 'pages/messages', {
    932     pageTitleKey: 'msg.title', bodyClass: 'on-special', items, seenAt,
     941    pageTitleKey: 'msg.title', bodyClass: 'on-special', pageJs: 'messages', items, seenAt,
    933942    hasMore, nextOffset: offset + FEED_PAGE, moreBase, guardianOffers,
    934943    success: req.query.success || null, error: req.query.error || null,
     
    965974  } catch { /* fall through */ }
    966975  res.redirect(back + '?error=quickreply');
     976});
     977
     978// Antwoorden vanuit een gesprek in Berichten. Twee paden, en welke het wordt
     979// bepaalt de draad zelf (zie groupConversations → replyTo):
     980//   - hangt de draad aan een post van jou, dan is dit een gewone reply op het
     981//     nieuwste ontvangen bericht erin: deliverReply, publiek zoals de thread;
     982//   - hangt hij aan een persoon, dan is het een direct bericht terug.
     983// Rijk in beide gevallen: `content` is de HTML uit de reply-editor, `text` de
     984// platte versie die de editor er altijd bij levert (en die het no-JS-formulier
     985// als enige stuurt).
     986router.post('/messages/reply', requireSiteManager, async (req, res) => {
     987  const site = res.locals.site;
     988  const back = `${res.locals.siteUrlBase || ''}/messages`;
     989  if (!site) return res.status(404).send('Site required');
     990  const text = String(req.body.text || '');
     991  const html = String(req.body.content || '');
     992  let attachments = [];
     993  try { attachments = JSON.parse(req.body.attachments || '[]'); } catch { /* geen media */ }
     994  let mentions;
     995  try { if (req.body.mentions !== undefined) mentions = JSON.parse(req.body.mentions || '[]'); } catch { mentions = undefined; }
     996  const language = String(req.body.language || '');
     997  // Leeg is leeg: een bericht zonder tekst EN zonder media is geen bericht.
     998  if (!text.trim() && !html.trim() && !attachments.length) return res.redirect(back + '?error=reply_empty');
     999
     1000  const interactionId = parseInt(req.body.interaction_id, 10) || 0;
     1001  const postSlug = String(req.body.post_slug || '');
     1002  const toActor = String(req.body.to || '');
     1003  try {
     1004    if (interactionId && postSlug) {
     1005      const post = db.prepare('SELECT id, slug FROM posts WHERE site_id = ? AND slug = ?').get(site.id, postSlug);
     1006      const parent = ActivityPubService.getInteractionById(interactionId);
     1007      // De parent MOET bij deze post horen: anders zou een gemanipuleerd
     1008      // formulier een antwoord onder andermans draad kunnen hangen.
     1009      if (!post || !parent || parent.post_id !== post.id) return res.redirect(back + '?error=reply_target');
     1010      await ActivityPubService.deliverReply(site, {
     1011        postId: post.id, postSlug: post.slug, parent, text, html, attachments, mentions, language,
     1012      });
     1013    } else if (/^https?:\/\//i.test(toActor)) {
     1014      const r = await Guardianship.deliverDirectNote(site, { recipients: [toActor], text, html, language, attachments });
     1015      if (!r) return res.redirect(back + '?error=reply_failed');
     1016    } else {
     1017      return res.redirect(back + '?error=reply_target');
     1018    }
     1019  } catch (e) {
     1020    console.warn('[AP] reply from Berichten failed:', e.message);
     1021    return res.redirect(back + '?error=reply_failed');
     1022  }
     1023  res.redirect(back + '?success=reply_sent');
    9671024});
    9681025
     
    11391196  }
    11401197  renderPage(req, res, 'pages/news', {
     1198    pageJs: 'news',
    11411199    pageTitle: 'News', bodyClass: 'on-special',
    11421200    timeline, hasMore, nextOffset: offset + FEED_PAGE, moreBase,
     
    12291287  let on = false;
    12301288  if (site && note) {
    1231     on = !ActivityPubService.getTimelineReaction(site.slug, note).liked;
     1289    on = !ActivityPubService.getReaction(site.slug, note).liked;
    12321290    try { await ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
    1233     if (on) ActivityPubService.markLiked(site.slug, note); else ActivityPubService.unmarkLiked(site.slug, note);
     1291    ActivityPubService.setReaction(site.slug, note, 'like', on);
    12341292  }
    12351293  if (req.get('X-Requested-With') === 'fetch') return res.json({ ok: true, on });
     
    12431301  let on = false;
    12441302  if (site && note) {
    1245     on = !ActivityPubService.getTimelineReaction(site.slug, note).boosted;
     1303    on = !ActivityPubService.getReaction(site.slug, note).boosted;
    12461304    try { await ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', note, (req.body.author || '').toString()); } catch (e) { /* ignore */ }
     1305    ActivityPubService.setReaction(site.slug, note, 'boost', on); // instant UI state
    12471306    if (on) {
    1248       ActivityPubService.markBoosted(site.slug, note); // instant UI state
    12491307      // Fire-and-forget: re-resolve the note so the cached row is refreshed
    12501308      // (cover/content) — boosting again heals a stale copy from EVERY boost
    12511309      // path, not just the interact page.
    12521310      ActivityPubService.resolveRemoteNote(note)
    1253         .then((n) => { if (n) ActivityPubService.upsertBoostedNote(site.slug, n); })
     1311        .then((n) => { if (n) ActivityPubService.setReaction(site.slug, note, 'boost', true, { note: n }); })
    12541312        .catch(() => { /* best-effort */ });
    1255     } else {
    1256       ActivityPubService.unmarkBoosted(site.slug, note);
    12571313    }
    12581314  }
     
    13381394    const { newerPost, olderPost } = postNeighbors(site, post);
    13391395    return renderPage(req, res, 'pages/paid-gate', {
     1396    pageJs: 'paid-gate',
    13401397      pageTitle: post.title || 'Voor supporters',
    13411398      bodyClass: 'on-special',
     
    14541511
    14551512  renderPage(req, res, 'pages/post', {
     1513    pageJs: 'post',
    14561514    post,
    14571515    poll: ActivityPubService.ownPollView(post),
     
    15051563  const kind = req.body.kind === 'boost' ? 'boost' : 'like';
    15061564  if (parent && parent.post_id === post.id && parent.object_uri) {
    1507     if (kind === 'boost') {
    1508       // Toggle: boost an unboosted comment, or retract it (Undo Announce) if already boosted.
    1509       const on = !parent.acted_boost;
    1510       ActivityPubService.sendInteraction(site, on ? 'boost' : 'unboost', parent.object_uri, parent.actor_uri)
    1511         .catch((e) => console.warn('[AP] reaction failed:', e.message));
    1512       ActivityPubService.setInteractionBoosted(parent.id, on);
    1513     } else {
    1514       // Toggle: like an unliked comment, or un-favourite (Undo Like) if already liked.
    1515       const on = !parent.acted_like;
    1516       ActivityPubService.sendInteraction(site, on ? 'like' : 'unlike', parent.object_uri, parent.actor_uri)
    1517         .catch((e) => console.warn('[AP] reaction failed:', e.message));
    1518       ActivityPubService.setInteractionLiked(parent.id, on);
    1519     }
     1565    // Toggle: react, or retract it (Undo Announce / Undo Like) if already on.
     1566    // De stand komt uit dezelfde bron als de knop die je zag; leest de toggle uit
     1567    // de kolom en de knop uit de tussentabel, dan draait een divergentie de
     1568    // richting om en stuur je een Undo voor iets dat nooit is verstuurd.
     1569    const ik = ActivityPubService.getReaction(site.slug, parent.object_uri);
     1570    const on = kind === 'boost' ? !ik.boosted : !ik.liked;
     1571    ActivityPubService.sendInteraction(site, on ? kind : `un${kind}`, parent.object_uri, parent.actor_uri)
     1572      .catch((e) => console.warn('[AP] reaction failed:', e.message));
     1573    // De tussentabel is de waarheid (shaer-ipb), gesleuteld op object_uri -- net
     1574    // als de Like die hierboven de fediverse in gaat. acted_* blijft voorlopig
     1575    // als afgeleide meelopen, hetzelfde vangnet dat ap_timeline.liked na
     1576    // shaer-9e9 is: pas weghalen als deze migratie een release heeft ingelopen.
     1577    ActivityPubService.setReaction(site.slug, parent.object_uri, kind, on);
     1578    if (kind === 'boost') ActivityPubService.setInteractionBoosted(parent.id, on);
     1579    else ActivityPubService.setInteractionLiked(parent.id, on);
    15201580  }
    15211581  res.redirect(`${res.locals.siteUrlBase || ''}/${post.slug}#fediverse`);
Note: See TracChangeset for help on using the changeset viewer.