From 98631df47aac735340c2a8b4e09b2056f39cda26 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 29 Mar 2026 13:21:23 +0500 Subject: [PATCH] feat(billing): Polar.sh Pro subscription integration - Webhook handler verifies HMAC-SHA256 sig and updates user role on subscription.created / subscription.updated / subscription.revoked - Audio endpoint gated: free users limited to 3 chapters/day via Valkey counter; returns 402 {error:'pro_required'} when limit reached - Translation proxy endpoint enforces 402 for non-pro users - AudioPlayer.svelte surfaces 402 via onProRequired callback + upgrade banner - Chapter page shows lock icon + upgrade prompts for gated translation langs - Profile page: subscription section shows Pro badge + manage link (active) or monthly/annual checkout buttons (free); isPro resolved fresh from DB - i18n: 13 new profile_subscription_* keys across all 5 locales --- scripts/pb-init-v3.sh | 2 + ui/messages/en.json | 12 ++ ui/messages/fr.json | 12 ++ ui/messages/id.json | 12 ++ ui/messages/pt-BR.json | 12 ++ ui/messages/ru.json | 12 ++ ui/src/app.d.ts | 2 + ui/src/hooks.server.ts | 14 ++- ui/src/lib/components/AudioPlayer.svelte | 14 ++- ui/src/lib/server/pocketbase.ts | 24 ++++ ui/src/lib/server/polar.ts | 107 ++++++++++++++++++ ui/src/routes/+layout.server.ts | 1 + ui/src/routes/api/audio/[slug]/[n]/+server.ts | 56 ++++++++- .../api/translation/[slug]/[n]/+server.ts | 69 +++++++++++ .../translation/status/[slug]/[n]/+server.ts | 23 ++++ ui/src/routes/api/webhooks/polar/+server.ts | 52 +++++++++ .../books/[slug]/chapters/[n]/+page.server.ts | 21 ++-- .../books/[slug]/chapters/[n]/+page.svelte | 44 ++++++- ui/src/routes/profile/+page.svelte | 63 +++++++++++ 19 files changed, 539 insertions(+), 13 deletions(-) create mode 100644 ui/src/lib/server/polar.ts create mode 100644 ui/src/routes/api/translation/[slug]/[n]/+server.ts create mode 100644 ui/src/routes/api/translation/status/[slug]/[n]/+server.ts create mode 100644 ui/src/routes/api/webhooks/polar/+server.ts diff --git a/scripts/pb-init-v3.sh b/scripts/pb-init-v3.sh index 8e43e36..67f4913 100755 --- a/scripts/pb-init-v3.sh +++ b/scripts/pb-init-v3.sh @@ -272,5 +272,7 @@ add_field "app_users" "verification_token" "text" add_field "app_users" "verification_token_exp" "text" add_field "app_users" "oauth_provider" "text" add_field "app_users" "oauth_id" "text" +add_field "app_users" "polar_customer_id" "text" +add_field "app_users" "polar_subscription_id" "text" log "done" diff --git a/ui/messages/en.json b/ui/messages/en.json index a48d0ae..2d99711 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -329,6 +329,18 @@ "profile_password_changed_ok": "Password changed successfully.", "profile_playback_speed": "Playback speed \u2014 {speed}x", + "profile_subscription_heading": "Subscription", + "profile_plan_pro": "Pro", + "profile_plan_free": "Free", + "profile_pro_active": "Your Pro subscription is active.", + "profile_pro_perks": "Unlimited audio, all translation languages, and voice selection are enabled.", + "profile_manage_subscription": "Manage subscription", + "profile_upgrade_heading": "Upgrade to Pro", + "profile_upgrade_desc": "Unlock unlimited audio, translations in 4 languages, and voice selection.", + "profile_upgrade_monthly": "Monthly \u2014 $6 / mo", + "profile_upgrade_annual": "Annual \u2014 $48 / yr", + "profile_free_limits": "Free plan: 3 audio chapters per day, English reading only.", + "user_currently_reading": "Currently Reading", "user_library_count": "Library ({n})", "user_joined": "Joined {date}", diff --git a/ui/messages/fr.json b/ui/messages/fr.json index 5958aa8..79d35c0 100644 --- a/ui/messages/fr.json +++ b/ui/messages/fr.json @@ -329,6 +329,18 @@ "profile_password_changed_ok": "Mot de passe modifié avec succès.", "profile_playback_speed": "Vitesse de lecture — {speed}x", + "profile_subscription_heading": "Abonnement", + "profile_plan_pro": "Pro", + "profile_plan_free": "Gratuit", + "profile_pro_active": "Votre abonnement Pro est actif.", + "profile_pro_perks": "Audio illimité, toutes les langues de traduction et la sélection de voix sont activées.", + "profile_manage_subscription": "Gérer l'abonnement", + "profile_upgrade_heading": "Passer au Pro", + "profile_upgrade_desc": "Débloquez l'audio illimité, les traductions en 4 langues et la sélection de voix.", + "profile_upgrade_monthly": "Mensuel — 6 $ / mois", + "profile_upgrade_annual": "Annuel — 48 $ / an", + "profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.", + "user_currently_reading": "En cours de lecture", "user_library_count": "Bibliothèque ({n})", "user_joined": "Inscrit le {date}", diff --git a/ui/messages/id.json b/ui/messages/id.json index db15e0c..8c06381 100644 --- a/ui/messages/id.json +++ b/ui/messages/id.json @@ -329,6 +329,18 @@ "profile_password_changed_ok": "Kata sandi berhasil diubah.", "profile_playback_speed": "Kecepatan pemutaran — {speed}x", + "profile_subscription_heading": "Langganan", + "profile_plan_pro": "Pro", + "profile_plan_free": "Gratis", + "profile_pro_active": "Langganan Pro kamu aktif.", + "profile_pro_perks": "Audio tanpa batas, semua bahasa terjemahan, dan pilihan suara tersedia.", + "profile_manage_subscription": "Kelola langganan", + "profile_upgrade_heading": "Tingkatkan ke Pro", + "profile_upgrade_desc": "Buka audio tanpa batas, terjemahan dalam 4 bahasa, dan pilihan suara.", + "profile_upgrade_monthly": "Bulanan — $6 / bln", + "profile_upgrade_annual": "Tahunan — $48 / thn", + "profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.", + "user_currently_reading": "Sedang Dibaca", "user_library_count": "Perpustakaan ({n})", "user_joined": "Bergabung {date}", diff --git a/ui/messages/pt-BR.json b/ui/messages/pt-BR.json index f9dd7ff..9eb071d 100644 --- a/ui/messages/pt-BR.json +++ b/ui/messages/pt-BR.json @@ -329,6 +329,18 @@ "profile_password_changed_ok": "Senha alterada com sucesso.", "profile_playback_speed": "Velocidade de reprodução — {speed}x", + "profile_subscription_heading": "Assinatura", + "profile_plan_pro": "Pro", + "profile_plan_free": "Gratuito", + "profile_pro_active": "Sua assinatura Pro está ativa.", + "profile_pro_perks": "Áudio ilimitado, todos os idiomas de tradução e seleção de voz estão habilitados.", + "profile_manage_subscription": "Gerenciar assinatura", + "profile_upgrade_heading": "Assinar o Pro", + "profile_upgrade_desc": "Desbloqueie áudio ilimitado, traduções em 4 idiomas e seleção de voz.", + "profile_upgrade_monthly": "Mensal — $6 / mês", + "profile_upgrade_annual": "Anual — $48 / ano", + "profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.", + "user_currently_reading": "Lendo Agora", "user_library_count": "Biblioteca ({n})", "user_joined": "Entrou em {date}", diff --git a/ui/messages/ru.json b/ui/messages/ru.json index 1003684..ea6763f 100644 --- a/ui/messages/ru.json +++ b/ui/messages/ru.json @@ -329,6 +329,18 @@ "profile_password_changed_ok": "Пароль успешно изменён.", "profile_playback_speed": "Скорость воспроизведения — {speed}x", + "profile_subscription_heading": "Подписка", + "profile_plan_pro": "Pro", + "profile_plan_free": "Бесплатно", + "profile_pro_active": "Ваша подписка Pro активна.", + "profile_pro_perks": "Безлимитное аудио, все языки перевода и выбор голоса доступны.", + "profile_manage_subscription": "Управление подпиской", + "profile_upgrade_heading": "Перейти на Pro", + "profile_upgrade_desc": "Разблокируйте безлимитное аудио, переводы на 4 языка и выбор голоса.", + "profile_upgrade_monthly": "Ежемесячно — $6 / мес", + "profile_upgrade_annual": "Ежегодно — $48 / год", + "profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.", + "user_currently_reading": "Сейчас читает", "user_library_count": "Библиотека ({n})", "user_joined": "Зарегистрирован {date}", diff --git a/ui/src/app.d.ts b/ui/src/app.d.ts index e1c2b69..c336716 100644 --- a/ui/src/app.d.ts +++ b/ui/src/app.d.ts @@ -6,9 +6,11 @@ declare global { interface Locals { sessionId: string; user: { id: string; username: string; role: string; authSessionId: string } | null; + isPro: boolean; } interface PageData { user?: { id: string; username: string; role: string; authSessionId: string } | null; + isPro?: boolean; } // interface PageState {} // interface Platform {} diff --git a/ui/src/hooks.server.ts b/ui/src/hooks.server.ts index a249658..38999a1 100644 --- a/ui/src/hooks.server.ts +++ b/ui/src/hooks.server.ts @@ -6,7 +6,7 @@ import { randomBytes, createHmac } from 'node:crypto'; import { env } from '$env/dynamic/private'; import { env as pubEnv } from '$env/dynamic/public'; import { log } from '$lib/server/logger'; -import { createUserSession, touchUserSession, isSessionRevoked } from '$lib/server/pocketbase'; +import { createUserSession, touchUserSession, isSessionRevoked, getUserById } from '$lib/server/pocketbase'; import { drain as drainPresignCache } from '$lib/server/presignCache'; import { NodeSDK } from '@opentelemetry/sdk-node'; import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; @@ -210,6 +210,18 @@ const appHandle: Handle = async ({ event, resolve }) => { event.locals.user = null; } + // ── isPro: read fresh from DB so role changes take effect without re-login ── + if (event.locals.user) { + try { + const dbUser = await getUserById(event.locals.user.id); + event.locals.isPro = dbUser?.role === 'pro' || dbUser?.role === 'admin'; + } catch { + event.locals.isPro = false; + } + } else { + event.locals.isPro = false; + } + return resolve(event); }; diff --git a/ui/src/lib/components/AudioPlayer.svelte b/ui/src/lib/components/AudioPlayer.svelte index e90ad39..0b216f6 100644 --- a/ui/src/lib/components/AudioPlayer.svelte +++ b/ui/src/lib/components/AudioPlayer.svelte @@ -67,6 +67,8 @@ chapters?: { number: number; title: string }[]; /** List of available voices from the backend. */ voices?: Voice[]; + /** Called when the server returns 402 (free daily limit reached). */ + onProRequired?: () => void; } let { @@ -77,7 +79,8 @@ cover = '', nextChapter = null, chapters = [], - voices = [] + voices = [], + onProRequired = undefined }: Props = $props(); // ── Derived: voices grouped by engine ────────────────────────────────── @@ -563,6 +566,15 @@ headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ voice }) }); + + if (res.status === 402) { + // Free daily limit reached — surface upgrade CTA + audioStore.status = 'idle'; + stopProgress(); + onProRequired?.(); + return; + } + if (!res.ok) throw new Error(`Generation failed: HTTP ${res.status}`); if (res.status === 200) { diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index 7f3f4a1..bde49f3 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -71,6 +71,8 @@ export interface User { verification_token_exp?: string; oauth_provider?: string; oauth_id?: string; + polar_customer_id?: string; + polar_subscription_id?: string; } // ─── Auth token cache ───────────────────────────────────────────────────────── @@ -572,6 +574,28 @@ export async function getUserByOAuth(provider: string, oauthId: string): Promise ); } +/** + * Look up a user by their Polar customer ID. Returns null if not found. + */ +export async function getUserByPolarCustomerId(polarCustomerId: string): Promise { + return listOne( + 'app_users', + `polar_customer_id="${polarCustomerId.replace(/"/g, '\\"')}"` + ); +} + +/** + * Patch arbitrary fields on an app_user record. + */ +export async function patchUser(userId: string, fields: Partial>): Promise { + const res = await pbPatch(`/api/collections/app_users/records/${encodeURIComponent(userId)}`, fields); + if (!res.ok) { + const body = await res.text().catch(() => ''); + log.error('pocketbase', 'patchUser failed', { userId, status: res.status, body }); + throw new Error(`patchUser failed: ${res.status} — ${body}`); + } +} + /** * Create a new user via OAuth (no password). email_verified is true since the * provider already verified it. Throws on DB errors. diff --git a/ui/src/lib/server/polar.ts b/ui/src/lib/server/polar.ts new file mode 100644 index 0000000..528dda9 --- /dev/null +++ b/ui/src/lib/server/polar.ts @@ -0,0 +1,107 @@ +/** + * Polar.sh integration — server-side only. + * + * Responsibilities: + * - Verify webhook signatures (HMAC-SHA256) + * - Patch app_users.polar_customer_id / polar_subscription_id / role on subscription events + * - Expose isPro(userId) helper for gating + * + * Product IDs (Polar dashboard): + * Monthly : 1376fdf5-b6a9-492b-be70-7c905131c0f9 + * Annual : b6190307-79aa-4905-80c8-9ed941378d21 + */ + +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { env } from '$env/dynamic/private'; +import { log } from '$lib/server/logger'; +import { getUserById, getUserByPolarCustomerId, patchUser } from '$lib/server/pocketbase'; + +export const POLAR_PRO_PRODUCT_IDS = new Set([ + '1376fdf5-b6a9-492b-be70-7c905131c0f9', // monthly + 'b6190307-79aa-4905-80c8-9ed941378d21' // annual +]); + +// ─── Webhook signature verification ────────────────────────────────────────── + +/** + * Verify the Polar webhook signature. + * Polar signs with HMAC-SHA256 over the raw body; header is "webhook-signature". + * Header format: "v1=" (may be comma-separated list of sigs) + */ +export function verifyPolarWebhook(rawBody: string, signatureHeader: string): boolean { + const secret = env.POLAR_WEBHOOK_SECRET; + if (!secret) { + log.warn('polar', 'POLAR_WEBHOOK_SECRET not set — rejecting webhook'); + return false; + } + + const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); + const expectedBuf = Buffer.from(`v1=${expected}`); + + // Header may contain multiple sigs separated by ", " + const sigs = signatureHeader.split(',').map((s) => s.trim()); + for (const sig of sigs) { + try { + const sigBuf = Buffer.from(sig); + if (sigBuf.length === expectedBuf.length && timingSafeEqual(sigBuf, expectedBuf)) { + return true; + } + } catch { + // length mismatch etc — try next + } + } + return false; +} + +// ─── Subscription event handler ─────────────────────────────────────────────── + +interface PolarSubscription { + id: string; + status: string; // "active" | "canceled" | "past_due" | "unpaid" | "incomplete" | ... + product_id: string; + customer_id: string; + customer_email?: string; + user_id?: string; // Polar user id (not our user id) +} + +/** + * Handle a Polar subscription event. + * Finds the matching app_user by email and updates role + polar fields. + */ +export async function handleSubscriptionEvent( + eventType: string, + subscription: PolarSubscription +): Promise { + const { id: subId, status, product_id, customer_id, customer_email } = subscription; + + log.info('polar', 'subscription event', { eventType, subId, status, product_id, customer_email }); + + if (!customer_email) { + log.warn('polar', 'subscription event missing customer_email — cannot match user', { subId }); + return; + } + + // Find user by their polar_customer_id first (faster on repeat events), then by email + let user = await getUserByPolarCustomerId(customer_id).catch(() => null); + if (!user) { + const { getUserByEmail } = await import('$lib/server/pocketbase'); + user = await getUserByEmail(customer_email).catch(() => null); + } + + if (!user) { + log.warn('polar', 'no app_user found for polar customer', { customer_email, customer_id }); + return; + } + + const isProProduct = POLAR_PRO_PRODUCT_IDS.has(product_id); + const isActive = status === 'active'; + const newRole = isProProduct && isActive ? 'pro' : (user.role === 'admin' ? 'admin' : 'user'); + + await patchUser(user.id, { + role: newRole, + polar_customer_id: customer_id, + polar_subscription_id: isActive ? subId : '' + }); + + log.info('polar', 'user role updated', { userId: user.id, username: user.username, newRole, status }); +} diff --git a/ui/src/routes/+layout.server.ts b/ui/src/routes/+layout.server.ts index e1285ba..575a5a9 100644 --- a/ui/src/routes/+layout.server.ts +++ b/ui/src/routes/+layout.server.ts @@ -30,6 +30,7 @@ export const load: LayoutServerLoad = async ({ locals, url }) => { return { user: locals.user, + isPro: locals.isPro, settings }; }; diff --git a/ui/src/routes/api/audio/[slug]/[n]/+server.ts b/ui/src/routes/api/audio/[slug]/[n]/+server.ts index 8ec2760..56b7d5d 100644 --- a/ui/src/routes/api/audio/[slug]/[n]/+server.ts +++ b/ui/src/routes/api/audio/[slug]/[n]/+server.ts @@ -2,6 +2,34 @@ import { error } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { log } from '$lib/server/logger'; import { backendFetch } from '$lib/server/scraper'; +import * as cache from '$lib/server/cache'; + +const FREE_DAILY_AUDIO_LIMIT = 3; + +/** + * Return the number of audio chapters a user/session has generated today, + * and increment the counter. Uses a Valkey key that expires at midnight UTC. + * + * Key: audio:daily:: + */ +async function incrementDailyAudioCount(identifier: string): Promise { + const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD + const key = `audio:daily:${identifier}:${today}`; + // Seconds until end of day UTC + const now = new Date(); + const endOfDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1)); + const ttl = Math.ceil((endOfDay.getTime() - now.getTime()) / 1000); + // Use raw get/set with increment so we can read + increment atomically + try { + const raw = await cache.get(key); + const current = (raw ?? 0) + 1; + await cache.set(key, current, ttl); + return current; + } catch { + // On cache failure, fail open (don't block audio for cache errors) + return 0; + } +} /** * POST /api/audio/[slug]/[n] @@ -15,14 +43,39 @@ import { backendFetch } from '$lib/server/scraper'; * GET /api/presign/audio to obtain a direct MinIO presigned URL. * 202 { task_id: string, status: "pending"|"generating" } — generation * enqueued; poll GET /api/audio/status/[slug]/[n]?voice=... until done. + * 402 { error: "pro_required", limit: 3 } — free daily limit reached. */ -export const POST: RequestHandler = async ({ params, request }) => { +export const POST: RequestHandler = async ({ params, request, locals }) => { const { slug, n } = params; const chapter = parseInt(n, 10); if (!slug || !chapter || chapter < 1) { error(400, 'Invalid slug or chapter number'); } + // ── Paywall: 3 audio chapters/day for free users ─────────────────────────── + if (!locals.isPro) { + // Check if audio already exists (cached) before counting — no charge for + // re-requesting something already generated + const statusRes = await backendFetch( + `/api/audio/status/${slug}/${chapter}` + ).catch(() => null); + const statusData = statusRes?.ok + ? ((await statusRes.json().catch(() => ({}))) as { status?: string }) + : {}; + + if (statusData.status !== 'done') { + const identifier = locals.user?.id ?? locals.sessionId; + const count = await incrementDailyAudioCount(identifier); + if (count > FREE_DAILY_AUDIO_LIMIT) { + log.info('polar', 'free audio limit reached', { identifier, count }); + return new Response( + JSON.stringify({ error: 'pro_required', limit: FREE_DAILY_AUDIO_LIMIT }), + { status: 402, headers: { 'Content-Type': 'application/json' } } + ); + } + } + } + let body: { voice?: string } = {}; try { body = await request.json(); @@ -62,4 +115,3 @@ export const POST: RequestHandler = async ({ params, request }) => { { headers: { 'Content-Type': 'application/json' } } ); }; - diff --git a/ui/src/routes/api/translation/[slug]/[n]/+server.ts b/ui/src/routes/api/translation/[slug]/[n]/+server.ts new file mode 100644 index 0000000..4fb6d09 --- /dev/null +++ b/ui/src/routes/api/translation/[slug]/[n]/+server.ts @@ -0,0 +1,69 @@ +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { backendFetch } from '$lib/server/scraper'; + +const SUPPORTED_LANGS = new Set(['ru', 'id', 'pt', 'fr']); + +/** + * POST /api/translation/[slug]/[n]?lang= + * Proxy to backend translation enqueue endpoint. + * Enforces Pro gate — free users cannot enqueue translations. + * + * GET /api/translation/[slug]/[n]?lang= + * Proxy to backend translation fetch (no gate — already gated at page.server.ts). + */ +export const GET: RequestHandler = async ({ params, url }) => { + const { slug, n } = params; + const lang = url.searchParams.get('lang') ?? ''; + const res = await backendFetch( + `/api/translation/${encodeURIComponent(slug)}/${n}?lang=${lang}` + ); + if (!res.ok) { + return new Response(null, { status: res.status }); + } + const data = await res.json(); + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' } + }); +}; + +export const POST: RequestHandler = async ({ params, url, locals }) => { + const { slug, n } = params; + const chapter = parseInt(n, 10); + const lang = url.searchParams.get('lang') ?? ''; + + if (!slug || !chapter || chapter < 1) error(400, 'Invalid slug or chapter'); + if (!SUPPORTED_LANGS.has(lang)) error(400, 'Unsupported language'); + + // ── Pro gate ────────────────────────────────────────────────────────────── + if (!locals.isPro) { + log.info('polar', 'translation blocked for free user', { + userId: locals.user?.id, + slug, + chapter, + lang + }); + return new Response( + JSON.stringify({ error: 'pro_required' }), + { status: 402, headers: { 'Content-Type': 'application/json' } } + ); + } + + const res = await backendFetch( + `/api/translation/${encodeURIComponent(slug)}/${chapter}?lang=${lang}`, + { method: 'POST' } + ); + + if (!res.ok) { + const text = await res.text().catch(() => ''); + log.error('translation', 'backend translation enqueue failed', { slug, chapter, lang, status: res.status, body: text }); + error(res.status as Parameters[0], text || 'Translation enqueue failed'); + } + + const data = await res.json(); + return new Response(JSON.stringify(data), { + status: res.status, + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/ui/src/routes/api/translation/status/[slug]/[n]/+server.ts b/ui/src/routes/api/translation/status/[slug]/[n]/+server.ts new file mode 100644 index 0000000..9e74b72 --- /dev/null +++ b/ui/src/routes/api/translation/status/[slug]/[n]/+server.ts @@ -0,0 +1,23 @@ +import type { RequestHandler } from './$types'; +import { backendFetch } from '$lib/server/scraper'; + +/** + * GET /api/translation/status/[slug]/[n]?lang= + * Proxies the translation status check to the backend. + */ +export const GET: RequestHandler = async ({ params, url }) => { + const { slug, n } = params; + const lang = url.searchParams.get('lang') ?? ''; + const res = await backendFetch( + `/api/translation/status/${encodeURIComponent(slug)}/${n}?lang=${lang}` + ); + if (!res.ok) { + return new Response(JSON.stringify({ status: 'idle' }), { + headers: { 'Content-Type': 'application/json' } + }); + } + const data = await res.json(); + return new Response(JSON.stringify(data), { + headers: { 'Content-Type': 'application/json' } + }); +}; diff --git a/ui/src/routes/api/webhooks/polar/+server.ts b/ui/src/routes/api/webhooks/polar/+server.ts new file mode 100644 index 0000000..1dfc649 --- /dev/null +++ b/ui/src/routes/api/webhooks/polar/+server.ts @@ -0,0 +1,52 @@ +import type { RequestHandler } from './$types'; +import { log } from '$lib/server/logger'; +import { verifyPolarWebhook, handleSubscriptionEvent } from '$lib/server/polar'; + +/** + * POST /api/webhooks/polar + * + * Receives Polar subscription lifecycle events and syncs user roles in PocketBase. + * Signature is verified via HMAC-SHA256 before any processing. + */ +export const POST: RequestHandler = async ({ request }) => { + const rawBody = await request.text(); + const signature = request.headers.get('webhook-signature') ?? ''; + + if (!verifyPolarWebhook(rawBody, signature)) { + log.warn('polar', 'webhook signature verification failed'); + return new Response('Unauthorized', { status: 401 }); + } + + let event: { type: string; data: Record }; + try { + event = JSON.parse(rawBody); + } catch { + return new Response('Bad Request', { status: 400 }); + } + + const { type, data } = event; + log.info('polar', 'webhook received', { type }); + + try { + switch (type) { + case 'subscription.created': + case 'subscription.updated': + case 'subscription.revoked': + await handleSubscriptionEvent(type, data as unknown as Parameters[1]); + break; + + case 'order.created': + // One-time purchases — no role change needed for now + log.info('polar', 'order.created (no action)', { orderId: data.id }); + break; + + default: + log.debug('polar', 'unhandled webhook event type', { type }); + } + } catch (err) { + // Log but return 200 — Polar retries on non-2xx, we don't want retry storms + log.error('polar', 'webhook handler error', { type, err: String(err) }); + } + + return new Response('OK', { status: 200 }); +}; diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts index 21aa89d..2a45c2e 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.server.ts @@ -17,8 +17,10 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { const isPreview = url.searchParams.get('preview') === '1'; const chapterUrl = url.searchParams.get('chapter_url') ?? ''; const chapterTitle = url.searchParams.get('title') ?? ''; - const lang = url.searchParams.get('lang') ?? ''; - const useTranslation = SUPPORTED_LANGS.has(lang); + const rawLang = url.searchParams.get('lang') ?? ''; + // Non-pro users can only read EN — silently ignore lang param + const lang = locals.isPro && SUPPORTED_LANGS.has(rawLang) ? rawLang : ''; + const useTranslation = lang !== ''; if (isPreview) { // ── Preview path: scrape chapter live, nothing from PocketBase/MinIO ── @@ -83,7 +85,8 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { sessionId: locals.sessionId, isPreview: true, lang: '', - translationStatus: 'unavailable' as string + translationStatus: 'unavailable' as string, + isPro: locals.isPro }; } @@ -129,10 +132,11 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { prev: prevChapter ? prevChapter.number : null, next: nextChapter ? nextChapter.number : null, chapters: chapters.map((c) => ({ number: c.number, title: c.title })), - sessionId: locals.sessionId, - isPreview: false, - lang, - translationStatus: 'done' + sessionId: locals.sessionId, + isPreview: false, + lang, + translationStatus: 'done', + isPro: locals.isPro }; } // 404 = not generated yet — fall through to original, UI can trigger generation @@ -188,6 +192,7 @@ export const load: PageServerLoad = async ({ params, url, locals }) => { sessionId: locals.sessionId, isPreview: false, lang: useTranslation ? lang : '', - translationStatus + translationStatus, + isPro: locals.isPro }; }; diff --git a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte index 96d43a3..5774dc6 100644 --- a/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte +++ b/ui/src/routes/books/[slug]/chapters/[n]/+page.svelte @@ -11,6 +11,7 @@ let html = $state(untrack(() => data.html)); let fetchingContent = $state(untrack(() => !data.isPreview && !data.html)); let fetchError = $state(''); + let audioProRequired = $state(false); // Translation state const SUPPORTED_LANGS = [ @@ -35,6 +36,10 @@ } async function requestTranslation(lang: string) { + if (!data.isPro) { + // Don't even attempt — show upgrade inline + return; + } translatingLang = lang; translationStatus = 'pending'; try { @@ -42,6 +47,11 @@ `/api/translation/${encodeURIComponent(data.book.slug)}/${data.chapter.number}?lang=${lang}`, { method: 'POST' } ); + if (res.status === 402) { + translationStatus = 'idle'; + translatingLang = ''; + return; + } const d = (await res.json()) as { status: string }; translationStatus = d.status ?? 'pending'; if (d.status === 'done') { @@ -192,7 +202,19 @@ {#each SUPPORTED_LANGS as { code, label }} - {#if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')} + {#if !data.isPro} + + + + + + {label} + + {:else if currentLang() === code && (translationStatus === 'pending' || translationStatus === 'running')} @@ -216,11 +238,29 @@ >{label} {/if} {/each} + + {#if !data.isPro} + Upgrade to Pro + {/if} {/if} {#if !data.isPreview} +{#if audioProRequired} +
+
+

Daily audio limit reached

+

Free users can listen to 3 chapters per day. Upgrade to Pro for unlimited audio.

+
+ + Upgrade + +
+{:else} { audioProRequired = true; }} /> +{/if} {:else}
{m.reader_preview_audio_notice()} diff --git a/ui/src/routes/profile/+page.svelte b/ui/src/routes/profile/+page.svelte index 5cb3f89..edecc89 100644 --- a/ui/src/routes/profile/+page.svelte +++ b/ui/src/routes/profile/+page.svelte @@ -275,6 +275,69 @@
+ +
+
+

{m.profile_subscription_heading()}

+ {#if data.isPro} + + + + + {m.profile_plan_pro()} + + {:else} + + {m.profile_plan_free()} + + {/if} +
+ + {#if data.isPro} +

{m.profile_pro_active()}

+

{m.profile_pro_perks()}

+ + {m.profile_manage_subscription()} + + + + + {:else} +

{m.profile_free_limits()}

+
+

{m.profile_upgrade_heading()}

+

{m.profile_upgrade_desc()}

+ +
+ {/if} +
+

{m.profile_appearance_heading()}