Changeset 072a242 in Klonkt


Ignore:
Timestamp:
07/21/2026 03:26:24 AM (7 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
b9beb16
Parents:
dede82e
git-author:
Robin <roboburr@…> (07/21/2026 03:26:22 AM)
git-committer:
Robin <roboburr@…> (07/21/2026 03:26:24 AM)
Message:

Fix: unlocked post renders via its own page; no duplicate supporter button

Two things the tester hit once the passkey flow worked:

  1. Duplicate "Word supporter". The gate's no-WebAuthn fallback relabelled the unlock button to "Word supporter", which sat next to the real "Word supporter op Patreon" button. In the two-button layout that fallback now hides the (unusable) unlock button instead; the single-button layout keeps the relabel since it's the only button.
  1. The unlocked content sat in a bare <div>, so it lost the post page's layout and scoped typography ("div niet in een goeie div"). Instead of injecting HTML into the gate, /paid/unlock now returns a short-lived (120s) signed unlock capability and the client reloads the real post URL with it (?u=). GET /:slug renders the FULL post through its normal template when the capability is valid: correct wrapper, scoped styles, and working audio players (which bind on load and couldn't init in injected HTML). Not a cookie and not stored: the token lives only in that one URL and expires.

Changed files:
src/routes/paid.js

  • /unlock returns { ok, redirect } with a 120s unlock blob; drop the renderPostBodyHtml import (no longer injected here)

src/routes/posts.js

  • a valid ?u= capability bypasses the paid gate and renders the post

src/views/pages/paid-gate.ejs

  • no-WebAuthn fallback hides the unlock button when a Patreon page exists; success reloads the real post via the redirect

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

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/routes/paid.js

    rdede82e r072a242  
    1515import PaidPatreon from '../services/PaidPatreonService.js';
    1616import Passkey from '../services/PasskeyService.js';
    17 import { renderPostBodyHtml } from './posts.js';
    1817
    1918const router = express.Router();
     
    128127  const post = db.prepare("SELECT * FROM posts WHERE site_id = ? AND slug = ? AND status = 'published'").get(r.site.id, String(payload.post || ''));
    129128  if (!post || !post.paid) return res.status(404).json({ error: 'gone' });
    130   res.json({ ok: true, title: post.title || '', html: renderPostBodyHtml(r.site, post, req) });
     129  // Hand back a short-lived, single-post unlock capability. The client reloads
     130  // the real post page with it (?u=), so the post renders through its normal
     131  // template: correct layout, scoped styles, working audio. Not a cookie and
     132  // not stored: a 120s signed blob that lives only in that one URL.
     133  const token = signBlob({ purpose: 'unlocked', siteId: r.site.id, post: post.slug }, 120);
     134  res.json({ ok: true, redirect: `${res.locals.siteUrlBase || ''}/${encodeURIComponent(post.slug)}?u=${encodeURIComponent(token)}` });
    131135});
    132136
  • src/routes/posts.js

    rdede82e r072a242  
    2222import { premiumUnlocked } from '../services/PatreonService.js';
    2323import { defaultMinCents as paidDefaultMinCents, patreonUrl as paidPatronUrl } from '../services/PaidPatreonService.js';
     24import { verifyBlob } from '../services/CryptoBox.js';
    2425import MusicMeta from '../services/MusicMeta.js';
    2526
     
    12381239  // never see the unlock button).
    12391240  const canEditThis = req.session?.user && PermissionsService.canEditPost(req.session.user, post, site);
    1240   if (post.paid && !canEditThis) {
     1241  // A fresh unlock capability (?u=) from /paid/unlock lets a just-verified
     1242  // supporter render the FULL post through this normal template (correct layout,
     1243  // scoped styles, working audio). Short-lived signed blob, single post, not a
     1244  // cookie and not stored.
     1245  const _u = req.query.u ? verifyBlob(String(req.query.u)) : null;
     1246  const _unlocked = _u && _u.purpose === 'unlocked' && _u.siteId === site.id && String(_u.post) === String(post.slug);
     1247  if (post.paid && !canEditThis && !_unlocked) {
    12411248    const { newerPost, olderPost } = postNeighbors(site, post, res.locals.tenancy === 'hub');
    12421249    return renderPage(req, res, 'pages/paid-gate', {
  • src/views/pages/paid-gate.ejs

    rdede82e r072a242  
    3737  var base = "<%= (typeof siteUrlBase !== 'undefined' && siteUrlBase ? siteUrlBase : '') %>";
    3838  var slug = "<%= pgSlug %>";
     39  var hasPatron = <%= _hasPatron ? 'true' : 'false' %>;
    3940  var btn = document.getElementById('pg-unlock');
    4041  var status = document.getElementById('pg-status');
    41   var page = document.getElementById('pg-page');
    4242  function say(msg, err) { status.hidden = false; status.textContent = msg; status.classList.toggle('is-err', !!err); }
    4343  function toLink() { location.href = base + '/paid/link?post=' + encodeURIComponent(slug); }
    4444
    45   if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) { btn.textContent = 'Word supporter'; btn.addEventListener('click', toLink); return; }
     45  // No WebAuthn here: an assertion is impossible. With a Patreon page there is
     46  // already a "Word supporter" button, so hide the (dead) unlock button rather
     47  // than turn it into a second "Word supporter". Without one, this IS the button.
     48  if (!window.SimpleWebAuthnBrowser || !window.PublicKeyCredential) {
     49    if (hasPatron) { btn.style.display = 'none'; }
     50    else { btn.textContent = 'Word supporter'; btn.addEventListener('click', toLink); }
     51    return;
     52  }
    4653
    4754  btn.addEventListener('click', function () {
     
    6168      .then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
    6269      .then(function (res) {
    63         if (res.j && res.j.ok) {
    64           // Swap the gate for the full post, client-side (no cookie kept).
    65           var h = document.createElement('div');
    66           h.innerHTML = (res.j.title ? '<h1 class="post-title">' + res.j.title + '</h1>' : '') +
    67             '<div class="post-content">' + res.j.html + '</div>';
    68           page.replaceWith(h);
     70        if (res.j && res.j.ok && res.j.redirect) {
     71          // Reload the real post page via the one-shot unlock capability, so it
     72          // renders through its normal template (layout, styles, audio).
     73          location.href = res.j.redirect;
    6974        } else if (res.status === 403) {
    7075          toLink();   // no valid passkey yet (or lapsed tier): link via Patreon
Note: See TracChangeset for help on using the changeset viewer.