Changeset b4af6f3 in Klonkt for src


Ignore:
Timestamp:
08/24/2026 07:22:58 AM (2 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
27cb1e3
Parents:
fbacddc
Message:

Open audio travels with a paid post, and plays on the site itself

A paid post federated a bare teaser and dropped every attachment. But its
fedi_open tracks federate on their own anyway, as public Audio objects with
their context pointing back at that post. So the early return kept no secret:
it withheld only the ORDER and the fact that this is one tape. In the hub a
mixtape fell apart into loose tracks under an empty teaser card
(boiert.eu/introducing-this-machine).

The wall belongs around the TEXT. fedi_open is a separate, one-way, per-track
choice by the owner, and buildMixtapeObject already builds from
playlistOpenTracks — the tape object can only ever carry open tracks. So the
paid branch now carries them, and the redaction of the body is untouched.

The same rule on the site itself: the paid gate now shows the player when the
post's audio is open. Without it the music was available everywhere except on
the site releasing it. Only when ALL of it is open — a shortcode renders its
whole list, and /audio/stream lets a same-origin fetch through, so half open
has to count as closed. The gate gets a post that is nothing but the audio
shortcodes, so the body cannot leak through it.

Extracted openAudioAttachments() and mixtapeAttachment() out of buildNote so
both branches use one implementation.

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

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/posts.js

    rfbacddc rb4af6f3  
    822822}
    823823
     824// De muziek van een betaalde post op de poortpagina zelf.
     825//
     826// WAAROM DIE DAAR HOORT. Zodra een nummer `fedi_open` is, federeert het als
     827// eigen Audio-object en speelt het bij iedereen die de post in een hub of in
     828// Mastodon tegenkomt. Toonde de poort het dan NIET, dan was de muziek overal
     829// beschikbaar behalve op de site die hem uitbrengt -- en dat is de verkeerde
     830// kant op (Robin, 24-8). De muur staat om de tekst.
     831//
     832// ALLES OF NIETS. Alleen als ELK nummer waar de post naar wijst open staat.
     833// Een shortcode rendert zijn hele lijst, dus bij een half-open bandje zou de
     834// speler ook de gesloten nummers krijgen -- en /audio/stream laat een
     835// gelijke-oorsprong-fetch door, dus dat is geen theoretisch lek maar een echt.
     836// Half open is hier dus dicht.
     837//
     838// De TEKST komt hier niet langs: we geven renderPostBodyHtml een post mee die
     839// alleen uit de audio-shortcodes bestaat. Wat niet meegegeven wordt kan ook
     840// niet lekken -- dezelfde regel als bij readerItems.
     841export function paidOpenAudioHtml(site, post, req) {
     842  if (!postAudioFediOpen(site.id, post.content)) return '';
     843  const codes = String(post.content || '').match(/\[\[(?:track|album|playlist):[^\]]+\]\]/gi) || [];
     844  if (!codes.length) return '';
     845  const alleenMuziek = codes.join('\n');
     846  try {
     847    return renderPostBodyHtml(site, { ...post, content: alleenMuziek, content_rendered: alleenMuziek }, req);
     848  } catch { return ''; /* geen speler is geen kapotte poort */ }
     849}
     850
    824851function postNeighbors(site, post) {
    825852  const ordered = db.prepare(`
     
    15731600  if (post.paid && !canEditThis && !_unlocked) {
    15741601    const { newerPost, olderPost } = postNeighbors(site, post);
     1602    const pgAudio = paidOpenAudioHtml(site, post, req);
    15751603    return renderPage(req, res, 'pages/paid-gate', {
    1576     pageJs: 'paid-gate',
     1604    pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''),
    15771605      pageTitle: post.title || 'Voor supporters',
    15781606      bodyClass: 'on-special',
    15791607      pgTitle: post.title || '',
    15801608      pgTeaser: paidTeaser(post),
     1609      pgAudio,
    15811610      pgCents: post.paid_min_cents || paidDefaultMinCents(site.id),
    15821611      pgSlug: post.slug,
  • src/services/ActivityPubService.js

    rfbacddc rb4af6f3  
    496496}
    497497
     498// fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
     499// EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
     500// stay link/card-only — the file is never exposed for them. Resolve from post.content so a
     501// later body mutation can't affect it.
     502//
     503// Staat apart en niet meer midden in buildNote, omdat een BETAALDE post hem ook
     504// nodig heeft: daar staat de muur om de TEKST en niet om de muziek.
     505function openAudioAttachments(base, site, post) {
     506  const openAudio = [];
     507  if (!/\[\[(track|album|playlist):/i.test(post.content || '')) return openAudio;
     508  const abs = (u) => !u ? null : (/^https?:/i.test(u) ? u : `${base}${u.startsWith('/') ? '' : '/'}${u}`);
     509  const seenA = new Set();
     510  const addRow = (r) => {
     511    const fn = r.filename || (r.storage_path || '').split('/').pop();
     512    if (!fn || seenA.has(fn)) return; seenA.add(fn);
     513    const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
     514    // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
     515    // Mastodon renders it as the artwork thumbnail on its native audio player.
     516    const art = abs(r.cover_url || post.cover_image_url || null);
     517    if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
     518    openAudio.push(a);
     519  };
     520  const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
     521  try {
     522    for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
     523    for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
     524    for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
     525  } catch { /* non-fatal */ }
     526  return openAudio;
     527}
     528
     529// HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
     530// eigen code: de playlist-collectie blijft namelijk een OrderedCollection
     531// (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
     532// object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
     533// Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
     534// niet voor, en de hub gooide zo'n bandje daarom stil weg.
     535//
     536// Als bijlage en niet als het object zelf: de post blijft een Note, zodat
     537// Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
     538// ziet. Wie het type wel kent, vindt het bandje als geheel.
     539//
     540// Het bandje draagt alleen wat al open staat: playlistOpenTracks filtert op
     541// fedi_open. Daarom is het veilig om hem ook aan een betaalde teaser te hangen.
     542function mixtapeAttachment(base, site, post) {
     543  try {
     544    const soort = postMusicType(post.content || '', site.id);
     545    if (!soort || soort.type !== 'mixtape' || !soort.collectie || !soort.collectie.id) return null;
     546    const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
     547      .get(soort.collectie.id, site.id);
     548    if (!pl) return null;
     549    return buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id)) || null;
     550  } catch { return null; /* een bandje minder is geen kapotte post */ }
     551}
     552
    498553// A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery).
    499554export function buildNote(base, site, post, opts = {}) {
     
    565620
    566621  // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full
    567   // content, so nothing leaks past the paywall. No media attachments either.
     622  // content, so nothing leaks past the paywall. Images stay home too.
     623  //
     624  // MAAR DE OPENGEZETTE AUDIO REIST WEL MEE (Robin, 24-8, naar aanleiding van
     625  // boiert.eu/introducing-this-machine). De muur staat om de TEKST. `fedi_open`
     626  // is een aparte, eenrichtings, per nummer bewust gezette vlag van de eigenaar,
     627  // en die nummers federeren toch al los als eigen Audio-objecten met hun
     628  // `context` naar deze post. Hield deze tak het bandje tegen, dan hield hij
     629  // niets geheim -- alleen de VOLGORDE en het feit dat het een cassette is. In
     630  // de hub viel het bandje daardoor uiteen in vier losse nummers onder een kale
     631  // teaserkaart. Een cassette die terugwijst naar "lees verder (supporters)"
     632  // dient de betaalde post beter dan vier weesnummers.
    568633  if (post.paid) {
    569634    const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
     
    571636    const rawTeaser = String(post.excerpt || '').trim()
    572637      || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280);
     638    const openBijlagen = openAudioAttachments(base, site, post);
     639    const band = mixtapeAttachment(base, site, post);
     640    // Het bandje alleen als er ook echt iets open in zit: een cassette waarvan
     641    // elk nummer gesloten is, is een lege doos met een titel erop.
     642    if (band && openBijlagen.length) openBijlagen.push(band);
    573643    return {
    574644      '@context': AP_CONTEXT,
     
    579649      url: human,
    580650      published: toISO(post.published_at || post.created_at || Date.now()),
     651      ...(openBijlagen.length ? { attachment: openBijlagen } : {}),
    581652      to: [PUBLIC],
    582653      cc: [`${aId}/followers`],
     
    705776    }
    706777  } catch { /* non-fatal */ }
    707   // fedi_open tracks → real AS2 Audio attachments (the actual file URL, served ungated) so
    708   // EVERY client incl. the Mastodon apps plays them inline natively. Gated tracks (default)
    709   // stay link/card-only — the file is never exposed for them. Resolve from post.content so a
    710   // later body mutation can't affect it.
    711   const openAudio = [];
    712   if (hadAudio) {
    713     const seenA = new Set();
    714     const addRow = (r) => {
    715       const fn = r.filename || (r.storage_path || '').split('/').pop();
    716       if (!fn || seenA.has(fn)) return; seenA.add(fn);
    717       const a = { type: 'Audio', mediaType: r.mime_type || 'audio/mpeg', url: `${base}/audio/stream/${encodeURIComponent(fn)}`, name: r.title || 'Audio' };
    718       // Cover art on the Audio attachment (AS2 `icon`): track cover, else the post cover.
    719       // Mastodon renders it as the artwork thumbnail on its native audio player.
    720       const art = abs(r.cover_url || post.cover_image_url || null);
    721       if (art) a.icon = { type: 'Image', mediaType: guessMediaType(art), url: art };
    722       openAudio.push(a);
    723     };
    724     const SEL = 'SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM audio_tracks t JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND ';
    725     try {
    726       for (const mm of (post.content || '').matchAll(/\[\[track:([A-Za-z0-9_-]+)\]\]/g)) { const r = db.prepare(SEL + 't.id = ?').get(mm[1]); if (r) addRow(r); }
    727       for (const mm of (post.content || '').matchAll(/\[\[album:([^\]]+)\]\]/g)) for (const r of db.prepare(SEL + 't.site_id = ? AND t.album = ? ORDER BY t.rowid').all(site.id, mm[1].trim())) addRow(r);
    728       for (const mm of (post.content || '').matchAll(/\[\[playlist:([A-Za-z0-9_-]+)\]\]/g)) for (const r of db.prepare('SELECT t.title, t.cover_url, m.filename, m.storage_path, m.mime_type FROM playlist_tracks pt JOIN audio_tracks t ON t.id = pt.track_id JOIN media m ON m.id = t.media_id WHERE t.fedi_open = 1 AND pt.playlist_id = ? ORDER BY pt.position').all(mm[1])) addRow(r);
    729     } catch { /* non-fatal */ }
    730   }
     778  const openAudio = openAudioAttachments(base, site, post);
    731779  // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt:
    732780  // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een
     
    795843  for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players
    796844
    797   // HET BANDJE OP DE DRAAD. Zonder dit stuk bestaat `Mixtape` alleen in onze
    798   // eigen code: de playlist-collectie blijft namelijk een OrderedCollection
    799   // (dat moet, anders verliest een lezer die `type` als tekst uitpakt het hele
    800   // object), en dan zegt niets naar buiten toe ooit dat dit een cassette is.
    801   // Gemeten op 21-8: in de Note van een mixtape-post kwam het woord Mixtape
    802   // niet voor, en de hub gooide zo'n bandje daarom stil weg.
    803   //
    804   // Als bijlage en niet als het object zelf: de post blijft een Note, zodat
    805   // Mastodon en alles wat `Mixtape` niet kent gewoon een bericht met audio
    806   // ziet. Wie het type wel kent, vindt het bandje als geheel.
    807   try {
    808     const soort = postMusicType(post.content || '', site.id);
    809     if (soort && soort.type === 'mixtape' && soort.collectie && soort.collectie.id) {
    810       const pl = db.prepare('SELECT * FROM playlists WHERE id = ? AND site_id = ?')
    811         .get(soort.collectie.id, site.id);
    812       if (pl) {
    813         const tape = buildMixtapeObject(base, site, { ...pl, _post: post }, playlistOpenTracks(pl.id));
    814         if (tape) attachment.push(tape);
    815       }
    816     }
    817   } catch { /* een bandje minder is geen kapotte post */ }
     845  // Het bandje als bijlage — zie mixtapeAttachment() voor het waarom.
     846  const tape = mixtapeAttachment(base, site, post);
     847  if (tape) attachment.push(tape);
    818848
    819849  // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only
  • src/views/pages/paid-gate.ejs

    rfbacddc rb4af6f3  
    88  <% if (typeof pgTeaser !== 'undefined' && pgTeaser) { %>
    99    <div class="pg-teaser"><p><%= pgTeaser %></p></div>
     10  <% } %>
     11
     12  <%# De opengezette muziek staat VOOR de poort en niet erachter: wie hem in een
     13      hub of in Mastodon al kan afspelen, hoort hem ook hier te kunnen afspelen.
     14      Alleen de tekst zit achter de poort, en die staat hier niet -- de route
     15      geeft alleen de audio-shortcodes mee (paidOpenAudioHtml). %>
     16  <% if (typeof pgAudio !== 'undefined' && pgAudio) { %>
     17    <div class="pg-audio post-content"><%- pgAudio %></div>
    1018  <% } %>
    1119
     
    3947  .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95;
    4048    -webkit-mask-image: linear-gradient(180deg, #000 55%, transparent); mask-image: linear-gradient(180deg, #000 55%, transparent); }
     49  .pg-audio { margin: 1.5rem 0 0; }
    4150  .pg-card { margin: 1.5rem 0 0; border: 1px solid color-mix(in srgb, var(--ink, #000) 16%, transparent); border-radius: 18px; padding: 30px 26px; text-align: center; }
    4251  .pg-lock { font-size: 38px; margin-bottom: 6px; }
Note: See TracChangeset for help on using the changeset viewer.