Changeset 928d1c7 in Klonkt
- Timestamp:
- 07/21/2026 01:06:47 AM (7 weeks ago)
- Branches:
- main
- Children:
- 9e9e6f9
- Parents:
- 61e3daf
- git-author:
- Robin <roboburr@…> (07/21/2026 01:06:45 AM)
- git-committer:
- Robin <roboburr@…> (07/21/2026 01:06:47 AM)
- Files:
-
- 2 added
- 4 edited
-
src/config/database.js (modified) (1 diff)
-
src/routes/posts.js (modified) (9 diffs)
-
src/services/ActivityPubService.js (modified) (1 diff)
-
src/views/pages/paid-gate.ejs (added)
-
src/views/pages/post-edit.ejs (modified) (1 diff)
-
test/paid-federation.test.js (added)
Legend:
- Unmodified
- Added
- Removed
-
src/config/database.js
r61e3daf r928d1c7 455 455 ensureColumn('ap_outbox', 'attachments', 'TEXT'); 456 456 ensureColumn('posts', 'ap_visibility', 'TEXT'); // public|quiet|friends|direct (C2S addressing, shaer-60b) 457 ensureColumn('posts', 'paid', 'INTEGER DEFAULT 0'); // paid post (klonkt-demo-aki) 458 ensureColumn('posts', 'paid_min_cents', 'INTEGER'); // required support; null = owner default 457 459 ensureColumn('ap_outbox', 'visibility', 'TEXT'); // 'direct' = private mention, never Public (shaer-tqc) 458 460 ensureColumn('ap_outbox', 'to_actors', 'TEXT'); // JSON array of recipient actor URIs for direct notes -
src/routes/posts.js
r61e3daf r928d1c7 20 20 import VideoCoverService from '../services/VideoCoverService.js'; 21 21 import ActivityPubService from '../services/ActivityPubService.js'; 22 import { premiumUnlocked } from '../services/PatreonService.js'; 23 import { defaultMinCents as paidDefaultMinCents } from '../services/PaidPatreonService.js'; 22 24 import MusicMeta from '../services/MusicMeta.js'; 23 25 … … 327 329 const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body; 328 330 const fanOnly = req.body.fan_only ? 1 : 0; 331 const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0; // paid posts (klonkt-demo-aki) 332 const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim(); 333 const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null; 329 334 const nsfw = req.body.nsfw ? 1 : 0; 330 335 const cw = (req.body.content_warning || '').trim().slice(0, 200); … … 381 386 ); 382 387 cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model) 388 db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, postId); 383 389 384 390 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks … … 399 405 id: postId, slug: finalSlug, title: title || finalSlug, 400 406 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language, 401 published_at: publishedAt, created_at: now, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,407 published_at: publishedAt, created_at: now, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson, 402 408 }).catch(() => { /* best-effort */ }); 403 409 } … … 463 469 const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body; 464 470 const fanOnly = req.body.fan_only ? 1 : 0; 471 const paid = (premiumUnlocked() && req.body.paid) ? 1 : 0; // paid posts (klonkt-demo-aki) 472 const paidEur = String(req.body.paid_min_eur || '').replace(',', '.').trim(); 473 const paidMinCents = paid && paidEur ? Math.round(parseFloat(paidEur) * 100) : null; 465 474 const nsfw = req.body.nsfw ? 1 : 0; 466 475 const cw = (req.body.content_warning || '').trim().slice(0, 200); … … 522 531 ); 523 532 cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model) 533 db.prepare('UPDATE posts SET paid = ?, paid_min_cents = ? WHERE id = ?').run(paid, paidMinCents, post.id); 524 534 525 535 // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks … … 544 554 id: post.id, slug: finalSlug, title: title || finalSlug, 545 555 content: cleanContent, cover_image_url: cover_image_url || null, cover_video_url: req.body.cover_video_url || null, cover_alt: coverAlt, language, 546 published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, nsfw, content_warning: cw, poll_json: pollJson,556 published_at: publishedAt, created_at: post.created_at, fan_only: fanOnly, paid, paid_min_cents: paidMinCents, excerpt: excerpt || '', nsfw, content_warning: cw, poll_json: pollJson, 547 557 }; 548 558 if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ }); … … 638 648 // post render and the fan gate (premium fan_only) so navigation is consistent 639 649 // everywhere. Solo: within the site (pinned first, then date). Hub: globally by date. 650 // A short public teaser for a paid post: its excerpt, else the first ~280 chars 651 // of the (stripped) content. Shared by the web gate and federation. 652 function paidTeaser(post, max = 280) { 653 if (post && post.excerpt && String(post.excerpt).trim()) return String(post.excerpt).trim(); 654 // Only the FIRST paragraph: a paid teaser must never spill later content. 655 const html = String((post && post.content) || ''); 656 const firstP = (html.match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, html])[1] || ''; 657 const text = firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim(); 658 return text.length > max ? text.slice(0, max).replace(/\s+\S*$/, '') + '…' : text; 659 } 660 640 661 function postNeighbors(site, post, isHub) { 641 662 const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : ''; … … 1120 1141 fgTitle: post.title || '', 1121 1142 fgNext: (res.locals.siteUrlBase || '') + '/' + post.slug, 1143 newerPost, 1144 olderPost, 1145 }); 1146 } 1147 1148 // Paid gate (klonkt-demo-aki): a paid post shows only a teaser to anyone who 1149 // is not the owner/editor. The passkey unlock arrives in slices 3-4; for now 1150 // the owner previews the full post, everyone else sees the teaser + notice. 1151 const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site); 1152 if (post.paid && !canEditThis) { 1153 const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub'); 1154 return renderPage(req, res, 'pages/paid-gate', { 1155 pageTitle: post.title || 'Voor supporters', 1156 bodyClass: 'on-special', 1157 pgTitle: post.title || '', 1158 pgTeaser: paidTeaser(post), 1159 pgCents: post.paid_min_cents || paidDefaultMinCents(site.id), 1160 pgSlug: post.slug, 1122 1161 newerPost, 1123 1162 olderPost, -
src/services/ActivityPubService.js
r61e3daf r928d1c7 278 278 const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); 279 279 const titleHtml = post.title ? `<p><strong>${escTitle}</strong></p>` : ''; 280 281 // Paid post (klonkt-demo-aki): federate a PUBLIC teaser + link, never the full 282 // content, so nothing leaks past the paywall. No media attachments either. 283 if (post.paid) { 284 const esc = (x) => String(x || '').replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])); 285 const _firstP = (String(post.content || '').match(/<p[^>]*>([\s\S]*?)<\/p>/i) || [null, ''])[1] || ''; 286 const rawTeaser = String(post.excerpt || '').trim() 287 || _firstP.replace(/<[^>]+>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim().slice(0, 280); 288 return { 289 '@context': AP_CONTEXT, 290 id, 291 type: 'Note', 292 attributedTo: aId, 293 content: `${titleHtml}<p>${esc(rawTeaser)}${rawTeaser ? '…' : ''}</p><p><a href="${human}">Lees de volledige post (supporters)</a></p>`, 294 url: human, 295 published: toISO(post.published_at || post.created_at || Date.now()), 296 to: [PUBLIC], 297 cc: [`${aId}/followers`], 298 tag: [...hashtagTags(base, post.content)], 299 replies: `${id}/replies`, 300 }; 301 } 280 302 281 303 // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect -
src/views/pages/post-edit.ejs
r61e3daf r928d1c7 367 367 <span>🔒 <%= t('pedit.fan_only_label') %></span> 368 368 </label> 369 <label class="pe-checkbox" style="margin-top:8px"> 370 <input type="checkbox" name="paid" value="1" id="pe-paid" <%= post.paid ? 'checked' : '' %>> 371 <span>💶 Betaalde post (supporters ontgrendelen met Patreon)</span> 372 </label> 373 <div id="pe-paid-price" style="margin:6px 0 0 26px;<%= post.paid ? '' : 'display:none' %>"> 374 <label style="font-size:.85rem;opacity:.8">Vereist steunbedrag (euro, leeg = standaard) 375 <input type="text" name="paid_min_eur" inputmode="decimal" style="width:100px" 376 value="<%= post.paid_min_cents ? (post.paid_min_cents/100).toFixed(2) : '' %>"> 377 </label> 378 </div> 379 <script nonce="<%= cspNonce %>"> 380 (function(){ var p=document.getElementById('pe-paid'), box=document.getElementById('pe-paid-price'); 381 if (p&&box&&!p.__wired){ p.__wired=true; p.addEventListener('change', function(){ box.style.display=p.checked?'':'none'; }); } })(); 382 </script> 369 383 <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %> 370 384 <label class="pe-checkbox" style="margin-top:8px">
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)