Recently Written · git

subplz-web

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

Log | Files | Refs


commit 99ec7e2e0b756301359cddd1d5478e18e84c3f8c
equwal <truex@equwal.com>
2026-09-20 16:19:51 -0700

Dev sign-in links are an explicit opt-in, never inferred

With no SMTP host, /api/auth/request returned the sign-in link in its
response whenever billing was off - on the assumption that billing off
means localhost. A public server can have billing off (this one does), and
there it let anyone sign in as any address. It is now refused unless
SUBPLZ_WEB_DEV_LOGIN_LINKS is set, and the link is no longer logged.

 .env.example        |  8 +++++---
 .gitattributes      |  1 +
 backend/api.py      | 14 +++++---------
 backend/mailer.py   |  8 ++++----
 backend/settings.py | 11 +++++++++--
 tests/test_auth.py  | 17 +++++++++++------
 6 files changed, 35 insertions(+), 24 deletions(-)
diff --git a/.env.example b/.env.example
index b164734..48b6822 100644
--- a/.env.example
+++ b/.env.example
@@ -68,14 +68,16 @@ SUBPLZ_WEB_FREE_WINDOW_HOURS=24
 # SUBPLZ_WEB_STRIPE_SECRET_KEY=
 # SUBPLZ_WEB_STRIPE_WEBHOOK_SECRET=
 
-# Sign-in links go out over SMTP, so any provider works. With no host set the
-# link is written to the server log instead (and sign-in is refused outright
-# when billing is on, since there would be no safe way to deliver it).
+# Sign-in links go out over SMTP, so any provider works. With no host set,
+# sign-in is unavailable: there would be no safe way to deliver the link.
 # SUBPLZ_WEB_SMTP_HOST=smtp.example.com
 # SUBPLZ_WEB_SMTP_PORT=587
 # SUBPLZ_WEB_SMTP_USER=
 # SUBPLZ_WEB_SMTP_PASSWORD=
 # SUBPLZ_WEB_SMTP_FROM=SubRead <login@subread.space>
+# Development only: return the sign-in link in the API response instead of
+# mailing it. This signs anyone in as any address - never set it in public.
+# SUBPLZ_WEB_DEV_LOGIN_LINKS=true
 # Override the plan catalogue (see backend/pricing.py for the shape).
 # SUBPLZ_WEB_PLANS_JSON=[...]
 
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..dfdb8b7
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.sh text eol=lf
diff --git a/backend/api.py b/backend/api.py
index 573e835..7220f1e 100644
--- a/backend/api.py
+++ b/backend/api.py
@@ -281,9 +281,7 @@ def _account_out(session: Session, account: Account) -> AccountOut:
         email=account.email,
         billing_enabled=settings.billing_enabled,
         payments_available=settings.payments_configured,
-        email_sign_in_available=(
-            settings.email_configured or not settings.billing_enabled
-        ),
+        email_sign_in_available=settings.sign_in_available,
         free_allowance=ent.free_allowance,
         free_window_hours=ent.window_hours,
         free_tier_summary=pricing.free_tier_summary(),
@@ -329,9 +327,9 @@ def request_sign_in(
     except accounts.InvalidEmail as exc:
         raise HTTPException(400, str(exc)) from exc
 
-    # A public server that cannot send mail has no safe way to do this: the
-    # only fallback is showing the link, which would sign anyone in as anyone.
-    if settings.billing_enabled and not settings.email_configured:
+    # A server that cannot send mail has no safe way to do this: the only
+    # fallback is showing the link, which would sign anyone in as anyone.
+    if not settings.sign_in_available:
         raise HTTPException(
             503, "Email sign-in is not set up on this server yet."
         )
@@ -347,9 +345,7 @@ def request_sign_in(
         raise HTTPException(502, str(exc)) from exc
 
     out = {"sent": sent, "email": email}
-    if not sent:
-        # Localhost only (guarded above): there is no mailbox to check, so
-        # hand the link straight back.
+    if not sent and settings.dev_login_links:
         out["dev_link"] = link
     return out
 
diff --git a/backend/mailer.py b/backend/mailer.py
index a753569..64705e0 100644
--- a/backend/mailer.py
+++ b/backend/mailer.py
@@ -16,13 +16,13 @@ class MailError(RuntimeError):
 
 
 def send_login_link(to: str, url: str) -> bool:
-    """Email a sign-in link. Returns False when it was only logged.
+    """Email a sign-in link. Returns False when there is no mail server.
 
-    With no SMTP host configured the link goes to the server log, which keeps
-    sign-in usable on localhost without an email account to send from.
+    That only happens in development (dev_login_links), where the API hands
+    the link back itself. It is deliberately not logged: a sign-in link in a
+    log file is a credential in a log file.
     """
     if not settings.email_configured:
-        log.info("sign-in link for %s: %s", to, url)
         return False
 
     minutes = settings.login_link_minutes
diff --git a/backend/settings.py b/backend/settings.py
index 8911388..4583922 100644
--- a/backend/settings.py
+++ b/backend/settings.py
@@ -79,8 +79,7 @@ class Settings(BaseSettings):
     stripe_webhook_secret: str = ""
 
     # Sign-in links go out over plain SMTP, so any provider works. With no
-    # host set the link is written to the server log instead - fine for
-    # localhost, and it keeps sign-in testable without an email account.
+    # host set, sign-in is simply unavailable (see dev_login_links below).
     smtp_host: str = ""
     smtp_port: int = 587
     smtp_user: str = ""
@@ -88,6 +87,10 @@ class Settings(BaseSettings):
     smtp_from: str = "SubRead <login@subread.space>"
     smtp_starttls: bool = True
     login_link_minutes: int = 30
+    # Hand the sign-in link back in the API response instead of mailing it.
+    # For development ONLY: it signs anyone in as any address. Never inferred
+    # from other settings - a public server with billing off is still public.
+    dev_login_links: bool = False
 
     # --- match check -------------------------------------------------------
     # Transcribe a few short samples on upload and score them against the book,
@@ -119,6 +122,10 @@ class Settings(BaseSettings):
     def email_configured(self) -> bool:
         return bool(self.smtp_host)
 
+    @property
+    def sign_in_available(self) -> bool:
+        return self.email_configured or self.dev_login_links
+
     @property
     def resolved_database_url(self) -> str:
         if self.database_url:
diff --git a/tests/test_auth.py b/tests/test_auth.py
index f8147d4..c7d388d 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -117,16 +117,21 @@ def test_requests_are_rate_limited(client, outbox):
     assert codes[:5] == [200] * 5 and set(codes[5:]) == {429}
 
 
-def test_public_server_without_smtp_refuses_instead_of_leaking_the_link(client):
-    # billing on (public) + no SMTP host: showing the link would let anyone
-    # sign in as anyone.
+@pytest.mark.parametrize("billing", [True, False])
+def test_server_without_smtp_refuses_instead_of_leaking_the_link(
+    client, monkeypatch, billing
+):
+    # No mail server: showing the link instead would let anyone sign in as
+    # anyone. Billing being off must not soften this - a public server can
+    # perfectly well have billing off.
+    monkeypatch.setattr(settings, "billing_enabled", billing)
     r = client.post("/api/auth/request", json={"email": "x@example.com"})
-    assert r.status_code == 503
+    assert r.status_code == 503 and "dev_link" not in r.text
     assert client.get("/api/account").json()["email_sign_in_available"] is False
 
 
-def test_localhost_hands_the_link_back(client, monkeypatch):
-    monkeypatch.setattr(settings, "billing_enabled", False)
+def test_dev_mode_hands_the_link_back_only_when_asked_to(client, monkeypatch):
+    monkeypatch.setattr(settings, "dev_login_links", True)
     r = client.post("/api/auth/request", json={"email": "dev@example.com"})
     assert r.status_code == 200 and r.json()["sent"] is False
     link = r.json()["dev_link"]