backend/subrep.py (16003 bytes)
1 """Subrep Pro: the plan of the Subrep desktop app, sold with the accounts of this site. 2 3 Subrep (https://honjimaku.com/subrep/) makes live captions of the sound on the 4 customer's own computer. The free edition makes live captions. Pro unlocks the 5 paid features of the desktop app (FEATURES in subrep/licensing.py of the 6 Subrep repo). The owner chose the prices on 2026-09-22: 7 8 yearly $9.99 a year (first on the page: 16% less than 12 x $0.99) 9 monthly $0.99 a month 10 11 There is no free trial: the free edition is the trial. 12 13 How the desktop app gets Pro: 14 15 1. The customer pays here. A Stripe subscription makes the account Pro until 16 the end of the paid period (SubrepLicence). 17 2. The Subrep page (/subrep.html) shows the email of the account and a setup 18 key (the refresh key). The customer enters the two in the Licence card of 19 the desktop control panel, or runs the link command, once on each 20 computer. 21 3. The app sends the two to POST /api/subrep/refresh and gets a licence token: 22 JSON claims, signed with the Ed25519 key of this server 23 (SUBPLZ_WEB_SUBREP_LICENSE_KEY, made by tools/subrep_key.py). The app 24 checks the token offline with the public key in its build, and asks for a 25 new one before the token ends. 26 27 The token format is that of sign_token in subrep/licensing.py: 28 base64url(JSON with sorted keys) "." base64url(signature). A token ends with 29 the paid period, and after TOKEN_DAYS at most, so a refund or a chargeback 30 ends Pro within a month. 31 32 A Subrep plan is not a book plan: billing.py does not see it, and an account 33 can have both. payments.py sells the plans and gives each subscription event 34 to apply_subscription first. 35 36 The iOS waitlist is here too: the emails that ask to hear when an iPhone app 37 ships. The owner counts them on 2026-11-30 (waitlist_count): 30 is one of the 38 conditions to build the app. 39 """ 40 from __future__ import annotations 41 42 import base64 43 import hmac 44 import importlib.util 45 import json 46 import logging 47 import secrets 48 from datetime import datetime, timedelta 49 from typing import Annotated 50 51 from fastapi import APIRouter, Depends, HTTPException 52 from fastapi.responses import JSONResponse 53 from pydantic import BaseModel 54 from sqlalchemy import DateTime, ForeignKey, Integer, String, UniqueConstraint 55 from sqlalchemy.exc import IntegrityError 56 from sqlalchemy.orm import Mapped, Session, mapped_column 57 58 from . import accounts, billing, payments 59 from .api import get_account, get_session 60 from .db import Account, Base, utcnow 61 from .pricing import Plan 62 from .settings import settings 63 64 log = logging.getLogger(__name__) 65 66 # A plan that was sold must stay in this list: a subscription that still runs 67 # names it. Change a price with a new plan id. 68 PLANS: list[Plan] = [ 69 Plan(id="subrep_year", name="Subrep Pro, yearly", credits=0, price_cents=999, 70 recurring=True, interval="year", 71 blurb="Renews each year until you cancel. 16% less than monthly."), 72 Plan(id="subrep_month", name="Subrep Pro, monthly", credits=0, price_cents=99, 73 recurring=True, interval="month", 74 blurb="Renews each month until you cancel."), 75 ] 76 77 # The longest time that one licence token is good for. 78 TOKEN_DAYS = 31 79 80 WAITLISTS = {"ios"} 81 82 83 class SubrepLicence(Base): 84 """The Subrep Pro subscription of an account, and its refresh key.""" 85 86 __tablename__ = "subrep_licences" 87 88 account_id: Mapped[str] = mapped_column( 89 String(64), ForeignKey("accounts.id"), primary_key=True 90 ) 91 # The desktop app sends it to get a licence. It is kept as it is, like the 92 # device token of an account: the page shows the same link command on 93 # each visit, so that each computer of the customer can use it. 94 refresh_key: Mapped[str | None] = mapped_column(String(64), nullable=True) 95 subscription_id: Mapped[str | None] = mapped_column(String(64), nullable=True) 96 status: Mapped[str | None] = mapped_column(String(32), nullable=True) 97 plan_id: Mapped[str | None] = mapped_column(String(32), nullable=True) 98 period_end: Mapped[datetime | None] = mapped_column( 99 DateTime(timezone=True), nullable=True 100 ) 101 102 103 class WaitlistEntry(Base): 104 """An email that asks to hear when an app ships (kind "ios").""" 105 106 __tablename__ = "waitlist" 107 __table_args__ = (UniqueConstraint("kind", "email"),) 108 109 id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) 110 kind: Mapped[str] = mapped_column(String(16)) 111 email: Mapped[str] = mapped_column(String(320)) 112 account_id: Mapped[str | None] = mapped_column(String(64), nullable=True) 113 created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) 114 115 116 def plan(plan_id: str) -> Plan | None: 117 return next((p for p in PLANS if p.id == plan_id), None) 118 119 120 def licence(session: Session, account_id: str) -> SubrepLicence | None: 121 return session.get(SubrepLicence, account_id) 122 123 124 def is_pro(row: SubrepLicence | None, now: datetime | None = None) -> bool: 125 """Whether the subscription is paid for the current period.""" 126 if row is None or row.status not in billing._SUBSCRIBED: 127 return False 128 end = billing._aware(row.period_end) 129 return end is None or end > (now or utcnow()) 130 131 132 def can_sign() -> bool: 133 """A key is set, and the package that signs with it is installed.""" 134 return bool(settings.subrep_license_key) and ( 135 importlib.util.find_spec("cryptography") is not None 136 ) 137 138 139 def available() -> bool: 140 """Subrep Pro is for sale: the server can take the money and sign a licence.""" 141 return can_sign() and settings.payments_configured 142 143 144 # --------------------------------------------------------------------------- 145 # subscriptions 146 # --------------------------------------------------------------------------- 147 148 def _plan_of(sub: dict) -> Plan | None: 149 """The Subrep plan of a subscription, or None if it is not one. 150 151 As in payments._our_plan_id: the lookup key of the price names the plan 152 after a switch in the customer portal, and the checkout metadata names it 153 before that. 154 """ 155 items = (sub.get("items") or {}).get("data") or [] 156 price = (items[0].get("price") or {}) if items else {} 157 for plan_id in (price.get("lookup_key"), (sub.get("metadata") or {}).get("plan_id")): 158 found = plan(plan_id or "") 159 if found is not None: 160 return found 161 return None 162 163 164 def apply_subscription(session: Session, sub: dict) -> bool: 165 """Mirror a Stripe subscription of a Subrep plan. False if it is not one.""" 166 found = _plan_of(sub) 167 if found is None: 168 return False 169 meta = sub.get("metadata") or {} 170 account = payments._account_for(session, meta.get("account_id"), sub.get("customer")) 171 if account is None: 172 log.warning("subrep subscription %s matches no account", sub.get("id")) 173 return True 174 row = licence(session, account.id) or SubrepLicence(account_id=account.id) 175 # The end of an old subscription must not end its replacement. 176 if row.subscription_id and row.subscription_id != sub.get("id") \ 177 and sub.get("status") in ("canceled", "incomplete_expired"): 178 return True 179 row.subscription_id = sub.get("id") 180 row.status = sub.get("status") 181 row.plan_id = found.id 182 row.period_end = payments._period_end(sub) 183 session.add(row) 184 if sub.get("customer") and not account.stripe_customer_id: 185 account.stripe_customer_id = sub["customer"] 186 session.commit() 187 return True 188 189 190 def merge(session: Session, src: Account, dst: Account) -> None: 191 """Called by accounts.merge: the Subrep plan of `src` goes to `dst`.""" 192 row = licence(session, src.id) 193 if row is None: 194 return 195 target = licence(session, dst.id) 196 if target is None or (is_pro(row) and not is_pro(target)): 197 target = target or SubrepLicence(account_id=dst.id) 198 target.subscription_id, target.status = row.subscription_id, row.status 199 target.plan_id, target.period_end = row.plan_id, row.period_end 200 session.add(target) 201 # The computers that were linked to `src` keep their link command. 202 target.refresh_key = target.refresh_key or row.refresh_key 203 session.delete(row) 204 205 206 # --------------------------------------------------------------------------- 207 # licence tokens 208 # --------------------------------------------------------------------------- 209 210 def _b64e(raw: bytes) -> str: 211 return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") 212 213 214 def _private_key(): 215 # cryptography only here: a server without Subrep Pro does not need it. 216 from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey 217 218 text = settings.subrep_license_key 219 return Ed25519PrivateKey.from_private_bytes( 220 base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) 221 ) 222 223 224 def sign(claims: dict) -> str: 225 """A licence token, in the format of sign_token in subrep/licensing.py.""" 226 payload = json.dumps(claims, separators=(",", ":"), sort_keys=True).encode() 227 return _b64e(payload) + "." + _b64e(_private_key().sign(payload)) 228 229 230 def public_key() -> str: 231 """The key to build into the desktop app (SUBREP_LICENSE_PUBKEY).""" 232 return _b64e(_private_key().public_key().public_bytes_raw()) 233 234 235 class LicenceRefused(Exception): 236 def __init__(self, status: int, message: str): 237 super().__init__(message) 238 self.status = status 239 self.message = message 240 241 242 def token(session: Session, account_id: str, refresh_key: str, 243 now: datetime | None = None) -> str: 244 """A licence token for the desktop app, or LicenceRefused. 245 246 `account_id` is the id of the account, or its email: the Licence card of 247 the desktop control panel asks for the email that paid. 248 """ 249 now = now or utcnow() 250 ident = (account_id or "").strip() 251 if "@" in ident: 252 account = accounts.by_email(session, ident.lower()) 253 else: 254 account = session.get(Account, ident) 255 if account is not None: 256 account = accounts.resolve(session, account) 257 row = licence(session, account.id) if account is not None else None 258 if row is None or not row.refresh_key or not hmac.compare_digest( 259 row.refresh_key.encode(), (refresh_key or "").encode()): 260 raise LicenceRefused( 261 403, "This link command is not valid. " 262 "Copy it again from the Subrep page of subread.space.") 263 if not is_pro(row, now): 264 raise LicenceRefused(402, "This account has no active Subrep Pro plan.") 265 latest = now + timedelta(days=TOKEN_DAYS) 266 # "paid" is what the customer sees: the end of the paid period. "exp" is 267 # when the app must ask again. 268 paid = billing._aware(row.period_end) or latest 269 return sign({"sub": account.email or account.id, "tier": "pro", 270 "plan": row.plan_id, "iat": int(now.timestamp()), 271 "paid": int(paid.timestamp()), 272 "exp": int(min(paid, latest).timestamp())}) 273 274 275 def link(session: Session, account: Account) -> dict: 276 """The link command of a Pro account. The first call makes its refresh key.""" 277 row = licence(session, account.id) 278 if row is None: 279 raise LicenceRefused(402, "This account has no Subrep Pro plan.") 280 if not row.refresh_key: 281 row.refresh_key = secrets.token_urlsafe(24) 282 session.commit() 283 server = f"{settings.public_base_url.rstrip('/')}/api/subrep" 284 return { 285 "account": account.id, 286 "email": account.email, 287 "refresh_key": row.refresh_key, 288 "server": server, 289 "command": f'subrep license --link "{account.id}" "{row.refresh_key}" "{server}"', 290 } 291 292 293 # --------------------------------------------------------------------------- 294 # waitlist 295 # --------------------------------------------------------------------------- 296 297 def join(session: Session, kind: str, email: str, account_id: str | None) -> bool: 298 """Put `email` on a waitlist. False if it was on it already.""" 299 session.add(WaitlistEntry(kind=kind, email=email, account_id=account_id)) 300 try: 301 session.commit() 302 except IntegrityError: 303 session.rollback() 304 return False 305 return True 306 307 308 def waitlist_count(session: Session, kind: str) -> int: 309 return session.query(WaitlistEntry).filter(WaitlistEntry.kind == kind).count() 310 311 312 # --------------------------------------------------------------------------- 313 # routes 314 # --------------------------------------------------------------------------- 315 316 router = APIRouter(prefix="/api/subrep") 317 318 319 class PlanIn(BaseModel): 320 plan_id: str 321 322 323 class RefreshIn(BaseModel): 324 account: str = "" 325 refresh_key: str = "" 326 327 328 class WaitlistIn(BaseModel): 329 email: str 330 kind: str = "ios" 331 332 333 def _needs_sign_in(account: Account) -> bool: 334 # A $0.99 checkout draws card testing. Where email sign-in exists, only a 335 # verified account may pay. 336 return settings.sign_in_available and not account.email_verified 337 338 339 @router.get("") 340 def subrep_state( 341 account: Annotated[Account, Depends(get_account)], 342 session: Annotated[Session, Depends(get_session)], 343 ): 344 row = licence(session, account.id) 345 pro = is_pro(row) 346 end = billing._aware(row.period_end) if pro and row is not None else None 347 return { 348 "available": available(), 349 "public_key": public_key() if can_sign() else None, 350 "plans": [ 351 {"id": p.id, "name": p.name, "interval": p.interval, 352 "price_cents": p.price_cents, "price_display": p.price_display, 353 "blurb": p.blurb} 354 for p in PLANS 355 ], 356 "pro": pro, 357 "plan_id": row.plan_id if pro and row is not None else None, 358 "period_end": end.isoformat() if end else None, 359 "link": link(session, account) if pro else None, 360 "needs_sign_in": _needs_sign_in(account), 361 "email": account.email, 362 "can_manage": bool(account.stripe_customer_id), 363 } 364 365 366 @router.post("/checkout") 367 def subrep_checkout( 368 body: PlanIn, 369 account: Annotated[Account, Depends(get_account)], 370 session: Annotated[Session, Depends(get_session)], 371 ): 372 found = plan(body.plan_id) 373 if found is None: 374 raise HTTPException(404, "No such plan.") 375 if not available(): 376 raise HTTPException(503, "Subrep Pro is not for sale on this server yet.") 377 if _needs_sign_in(account): 378 raise HTTPException(403, "Sign in with your email first.") 379 if is_pro(licence(session, account.id)): 380 raise HTTPException( 381 400, "You already have Subrep Pro. Change or cancel it with Manage plan.") 382 try: 383 return {"url": payments.start_checkout(session, account, found, page="/subrep.html")} 384 except payments.PaymentsUnavailable as exc: 385 raise HTTPException(503, str(exc)) from exc 386 except payments.PaymentError as exc: 387 raise HTTPException(400, str(exc)) from exc 388 389 390 @router.post("/refresh") 391 def subrep_refresh( 392 body: RefreshIn, 393 session: Annotated[Session, Depends(get_session)], 394 ): 395 """The desktop app asks for a licence (refresh in subrep/licensing.py). 396 397 No cookie: the app is not a browser. A refusal is {"error": ...}, because 398 the app shows that text to the customer. 399 """ 400 if not can_sign(): 401 return JSONResponse({"error": "Subrep Pro is not set up on this server."}, 402 status_code=503) 403 try: 404 return {"token": token(session, body.account, body.refresh_key)} 405 except LicenceRefused as exc: 406 return JSONResponse({"error": exc.message}, status_code=exc.status) 407 408 409 @router.post("/waitlist") 410 def subrep_waitlist( 411 body: WaitlistIn, 412 account: Annotated[Account, Depends(get_account)], 413 session: Annotated[Session, Depends(get_session)], 414 ): 415 if body.kind not in WAITLISTS: 416 raise HTTPException(404, "No such waitlist.") 417 try: 418 email = accounts.normalize_email(body.email) 419 except accounts.InvalidEmail as exc: 420 raise HTTPException(400, str(exc)) from exc 421 join(session, body.kind, email, account.id) 422 return {"joined": True, "kind": body.kind}