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@…>

File:
1 edited

Legend:

Unmodified
Added
Removed
  • 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
Note: See TracChangeset for help on using the changeset viewer.