Changeset b4af6f3 in Klonkt
- Timestamp:
- 08/24/2026 07:22:58 AM (2 weeks ago)
- Branches:
- main
- Children:
- 27cb1e3
- Parents:
- fbacddc
- Files:
-
- 1 added
- 3 edited
-
src/routes/posts.js (modified) (2 diffs)
-
src/services/ActivityPubService.js (modified) (6 diffs)
-
src/views/pages/paid-gate.ejs (modified) (2 diffs)
-
test/paid-open-audio.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/routes/posts.js
rfbacddc rb4af6f3 822 822 } 823 823 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. 841 export 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 824 851 function postNeighbors(site, post) { 825 852 const ordered = db.prepare(` … … 1573 1600 if (post.paid && !canEditThis && !_unlocked) { 1574 1601 const { newerPost, olderPost } = postNeighbors(site, post); 1602 const pgAudio = paidOpenAudioHtml(site, post, req); 1575 1603 return renderPage(req, res, 'pages/paid-gate', { 1576 pageJs: 'paid-gate' ,1604 pageJs: 'paid-gate' + (pgAudio ? ' tape' : ''), 1577 1605 pageTitle: post.title || 'Voor supporters', 1578 1606 bodyClass: 'on-special', 1579 1607 pgTitle: post.title || '', 1580 1608 pgTeaser: paidTeaser(post), 1609 pgAudio, 1581 1610 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id), 1582 1611 pgSlug: post.slug, -
src/services/ActivityPubService.js
rfbacddc rb4af6f3 496 496 } 497 497 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. 505 function 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. 542 function 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 498 553 // A single post as an AS2 Note (the object), and as a Create activity (for outbox/delivery). 499 554 export function buildNote(base, site, post, opts = {}) { … … 565 620 566 621 // 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. 568 633 if (post.paid) { 569 634 const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); … … 571 636 const rawTeaser = String(post.excerpt || '').trim() 572 637 || _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); 573 643 return { 574 644 '@context': AP_CONTEXT, … … 579 649 url: human, 580 650 published: toISO(post.published_at || post.created_at || Date.now()), 651 ...(openBijlagen.length ? { attachment: openBijlagen } : {}), 581 652 to: [PUBLIC], 582 653 cc: [`${aId}/followers`], … … 705 776 } 706 777 } 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); 731 779 // ONVERTAALD voor een verhuizing (FEP-1580). De doelinstantie IS een Klonkt: 732 780 // die rendert [[track:]], [[album:]] en [[playlist:]] zelf en maakt er een … … 795 843 for (const a of openAudio) attachment.push(a); // fedi_open tracks → native Audio players 796 844 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); 818 848 819 849 // Inline @user@host mentions: the Mention tag objects + the mentioned actor URIs. Only -
src/views/pages/paid-gate.ejs
rfbacddc rb4af6f3 8 8 <% if (typeof pgTeaser !== 'undefined' && pgTeaser) { %> 9 9 <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> 10 18 <% } %> 11 19 … … 39 47 .pg-teaser { font-family: var(--font-body, serif); font-size: 1.1rem; line-height: 1.7; color: var(--ink); opacity: .95; 40 48 -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; } 41 50 .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; } 42 51 .pg-lock { font-size: 38px; margin-bottom: 6px; }
Note:
See TracChangeset
for help on using the changeset viewer.
![(please configure the [header_logo] section in trac.ini)](/chrome/site/your_project_logo.png)