Changeset 928d1c7 in Klonkt


Ignore:
Timestamp:
07/21/2026 01:06:47 AM (7 weeks ago)
Author:
Robin <roboburr@…>
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)
Message:

Feature: paid posts slice 2, post model + teaser gate

A post can be marked paid (klonkt-demo-aki), premium-gated in the
editor with an optional per-post price; additive columns posts.paid +
paid_min_cents. On the web, a paid post shows only a teaser to anyone
who is not the owner/editor (new pages/paid-gate, mirroring the
fan-gate); the owner previews the full post. The passkey unlock arrives
in slices 3-4, so the gate says so for now.

Federation is leak-safe: buildNote federates only a PUBLIC teaser (the
excerpt, else the first paragraph, never later content) plus a
"read the full post (supporters)" link back, and no media attachments.
A short paid post can no longer spill its body: the teaser is the first
paragraph only, pinned by a test.

Changed files:
src/config/database.js

  • additive columns posts.paid, posts.paid_min_cents

src/routes/posts.js

  • create/update read paid + price (premium-gated), store them, pass to deliverCreate/Update; paidTeaser helper; the paid web gate

src/services/ActivityPubService.js

  • buildNote: paid post -> public teaser + link, first paragraph only

src/views/pages/post-edit.ejs

  • paid toggle + price field (in the premium block)

New file:
src/views/pages/paid-gate.ejs

  • teaser + supporters notice

test/paid-federation.test.js

  • teaser + link, no full content, excerpt-as-teaser, non-paid intact

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

Files:
2 added
4 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r61e3daf r928d1c7  
    455455  ensureColumn('ap_outbox', 'attachments', 'TEXT');
    456456  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
    457459  ensureColumn('ap_outbox', 'visibility', 'TEXT');  // 'direct' = private mention, never Public (shaer-tqc)
    458460  ensureColumn('ap_outbox', 'to_actors', 'TEXT');   // JSON array of recipient actor URIs for direct notes
  • src/routes/posts.js

    r61e3daf r928d1c7  
    2020import VideoCoverService from '../services/VideoCoverService.js';
    2121import ActivityPubService from '../services/ActivityPubService.js';
     22import { premiumUnlocked } from '../services/PatreonService.js';
     23import { defaultMinCents as paidDefaultMinCents } from '../services/PaidPatreonService.js';
    2224import MusicMeta from '../services/MusicMeta.js';
    2325
     
    327329  const { title, slug, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
    328330  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;
    329334  const nsfw = req.body.nsfw ? 1 : 0;
    330335  const cw = (req.body.content_warning || '').trim().slice(0, 200);
     
    381386  );
    382387  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);
    383389
    384390  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
     
    399405        id: postId, slug: finalSlug, title: title || finalSlug,
    400406        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,
    402408      }).catch(() => { /* best-effort */ });
    403409    }
     
    463469  const { title, content, excerpt, status, pinned, cover_image_url, tags, noindex, type } = req.body;
    464470  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;
    465474  const nsfw = req.body.nsfw ? 1 : 0;
    466475  const cw = (req.body.content_warning || '').trim().slice(0, 200);
     
    522531  );
    523532  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);
    524534
    525535  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
     
    544554      id: post.id, slug: finalSlug, title: title || finalSlug,
    545555      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,
    547557    };
    548558    if (post.status !== 'published') ActivityPubService.deliverCreate(site, apPost).catch(() => { /* best-effort */ });
     
    638648// post render and the fan gate (premium fan_only) so navigation is consistent
    639649// 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.
     652function 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
    640661function postNeighbors(site, post, isHub) {
    641662  const urlBaseFor = (p) => (isHub && p && p.site_slug) ? `/user/${p.site_slug}` : '';
     
    11201141      fgTitle: post.title || '',
    11211142      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,
    11221161      newerPost,
    11231162      olderPost,
  • src/services/ActivityPubService.js

    r61e3daf r928d1c7  
    278278  const escTitle = String(post.title || '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
    279279  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) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[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  }
    280302
    281303  // Images travel as AP `attachment` (Mastodon strips <img> from content). Collect
  • src/views/pages/post-edit.ejs

    r61e3daf r928d1c7  
    367367            <span>🔒 <%= t('pedit.fan_only_label') %></span>
    368368          </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>
    369383          <% var _scheduled = !!(post.publish_at && (post.status === 'scheduled' || Date.parse(String(post.publish_at).replace(' ', 'T')) > Date.now())); %>
    370384          <label class="pe-checkbox" style="margin-top:8px">
Note: See TracChangeset for help on using the changeset viewer.