Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


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}