From 815fb7d98a8e90ccfd9dbc843ef3817fe8245b30 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sun, 22 Feb 2026 17:03:13 +0100 Subject: [PATCH] feat: replace sequential IDs with opaque tokens in public URLs Sequential IDs in /planner/export/ and /leads//unlock leaked business volume (e.g. export_id=47 reveals ~47 PDFs sold). Replace with 22-char URL-safe tokens that carry no countable information. - Migration 0017: adds `token TEXT` to business_plan_exports and lead_requests, backfills existing rows with secrets.token_urlsafe(16), creates unique indexes for fast lookups - billing/routes.py: INSERT into business_plan_exports includes token - leads/routes.py: INSERT into lead_requests includes token; enqueue payload includes lead_token; verify_quote() looks up by token - planner/routes.py: /export/ route (was /export/) - suppliers/routes.py: /leads//unlock (was /leads/) - worker.py: email links use token for both export and verify URLs - Templates: url_for() calls use token= param - test_phase0.py: _submit_guest_quote() returns (lead_id, auth_token, lead_token); verify URL tests use opaque lead token Integer PKs unchanged; admin routes unchanged. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/billing/routes.py | 7 +-- web/src/padelnomics/leads/routes.py | 23 ++++++---- .../versions/0017_add_public_tokens.py | 46 +++++++++++++++++++ web/src/padelnomics/planner/routes.py | 10 ++-- .../padelnomics/planner/templates/export.html | 2 +- .../planner/templates/export_success.html | 2 +- web/src/padelnomics/suppliers/routes.py | 10 +++- .../suppliers/partials/dashboard_leads.html | 2 +- .../suppliers/partials/lead_card.html | 2 +- web/src/padelnomics/worker.py | 8 +++- web/tests/test_phase0.py | 29 ++++++------ 11 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 web/src/padelnomics/migrations/versions/0017_add_public_tokens.py diff --git a/web/src/padelnomics/billing/routes.py b/web/src/padelnomics/billing/routes.py index 8a45453..b22bdd2 100644 --- a/web/src/padelnomics/billing/routes.py +++ b/web/src/padelnomics/billing/routes.py @@ -4,6 +4,7 @@ Payment provider: paddle """ import json +import secrets from datetime import datetime from pathlib import Path @@ -453,10 +454,10 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None: transaction_id = data.get("id", "") export_id = await execute( """INSERT INTO business_plan_exports - (user_id, scenario_id, paddle_transaction_id, language, status, created_at) - VALUES (?, ?, ?, ?, 'pending', ?)""", + (user_id, scenario_id, paddle_transaction_id, language, status, token, created_at) + VALUES (?, ?, ?, ?, 'pending', ?, ?)""", (int(user_id), int(scenario_id) if scenario_id else 0, - transaction_id, language, now), + transaction_id, language, secrets.token_urlsafe(16), now), ) # Enqueue PDF generation from ..worker import enqueue diff --git a/web/src/padelnomics/leads/routes.py b/web/src/padelnomics/leads/routes.py index 0cbbe48..6c77a94 100644 --- a/web/src/padelnomics/leads/routes.py +++ b/web/src/padelnomics/leads/routes.py @@ -333,8 +333,8 @@ async def quote_request(): previous_supplier_contact, services_needed, additional_info, contact_name, contact_email, contact_phone, contact_company, stakeholder_type, - heat_score, status, credit_cost, created_at) - VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + heat_score, status, credit_cost, token, created_at) + VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", ( user_id, form.get("court_count", 0), @@ -361,6 +361,7 @@ async def quote_request(): heat, status, credit_cost, + secrets.token_urlsafe(16), datetime.utcnow().isoformat(), ), ) @@ -422,11 +423,17 @@ async def quote_request(): token = secrets.token_urlsafe(32) await create_auth_token(new_user_id, token, minutes=60) + lead_token_row = await fetch_one( + "SELECT token FROM lead_requests WHERE id = ?", (lead_id,) + ) + lead_token = lead_token_row["token"] + from ..worker import enqueue await enqueue("send_quote_verification", { "email": contact_email, "token": token, "lead_id": lead_id, + "lead_token": lead_token, "lang": g.get("lang", "en"), "contact_name": form.get("contact_name", ""), "facility_type": form.get("facility_type", ""), @@ -466,10 +473,10 @@ async def quote_request(): async def verify_quote(): """Verify email from quote submission — activates lead and logs user in.""" token_str = request.args.get("token") - lead_id = request.args.get("lead") + lead_token = request.args.get("lead") _t = get_translations(g.get("lang", "en")) - if not token_str or not lead_id: + if not token_str or not lead_token: await flash(_t["flash_verify_invalid"], "error") return redirect(url_for("leads.quote_request")) @@ -479,10 +486,10 @@ async def verify_quote(): await flash(_t["flash_verify_expired"], "error") return redirect(url_for("leads.quote_request")) - # Validate lead exists and is pending + # Validate lead exists and is pending — look up by opaque token lead = await fetch_one( - "SELECT * FROM lead_requests WHERE id = ? AND status = 'pending_verification'", - (lead_id,), + "SELECT * FROM lead_requests WHERE token = ? AND status = 'pending_verification'", + (lead_token,), ) if not lead: await flash(_t["flash_verify_invalid_lead"], "error") @@ -497,7 +504,7 @@ async def verify_quote(): now = datetime.utcnow().isoformat() await execute( "UPDATE lead_requests SET status = 'new', verified_at = ?, credit_cost = ? WHERE id = ?", - (now, credit_cost, lead_id), + (now, credit_cost, lead["id"]), ) # Set user name from contact_name if not already set diff --git a/web/src/padelnomics/migrations/versions/0017_add_public_tokens.py b/web/src/padelnomics/migrations/versions/0017_add_public_tokens.py new file mode 100644 index 0000000..d47608e --- /dev/null +++ b/web/src/padelnomics/migrations/versions/0017_add_public_tokens.py @@ -0,0 +1,46 @@ +"""Add opaque public tokens to business_plan_exports and lead_requests. + +Sequential integer IDs in public URLs leak business volume (e.g. export_id=47 +reveals ~47 PDFs sold). Opaque tokens hide this while keeping integer PKs for +internal use. +""" +import secrets + + +def up(conn): + # Add token columns (nullable to allow backfill before NOT NULL constraint) + conn.execute( + "ALTER TABLE business_plan_exports ADD COLUMN token TEXT" + ) + conn.execute( + "ALTER TABLE lead_requests ADD COLUMN token TEXT" + ) + + # Backfill existing rows + export_ids = [row[0] for row in conn.execute( + "SELECT id FROM business_plan_exports WHERE token IS NULL" + ).fetchall()] + for export_id in export_ids: + conn.execute( + "UPDATE business_plan_exports SET token = ? WHERE id = ?", + (secrets.token_urlsafe(16), export_id), + ) + + lead_ids = [row[0] for row in conn.execute( + "SELECT id FROM lead_requests WHERE token IS NULL" + ).fetchall()] + for lead_id in lead_ids: + conn.execute( + "UPDATE lead_requests SET token = ? WHERE id = ?", + (secrets.token_urlsafe(16), lead_id), + ) + + # Unique indexes for fast token lookups + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS" + " idx_business_plan_exports_token ON business_plan_exports (token)" + ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS" + " idx_lead_requests_token ON lead_requests (token)" + ) diff --git a/web/src/padelnomics/planner/routes.py b/web/src/padelnomics/planner/routes.py index b21b06a..598a7f7 100644 --- a/web/src/padelnomics/planner/routes.py +++ b/web/src/padelnomics/planner/routes.py @@ -534,13 +534,13 @@ async def export_success(): return await render_template("export_success.html", exports=exports) -@bp.route("/export/") +@bp.route("/export/") @login_required -async def export_download(export_id: int): +async def export_download(token: str): """Download a generated PDF.""" export = await fetch_one( - "SELECT * FROM business_plan_exports WHERE id = ? AND user_id = ?", - (export_id, g.user["id"]), + "SELECT * FROM business_plan_exports WHERE token = ? AND user_id = ?", + (token, g.user["id"]), ) if not export: return jsonify({"error": "Export not found."}), 404 @@ -562,7 +562,7 @@ async def export_download(export_id: int): pdf_bytes, mimetype="application/pdf", headers={ - "Content-Disposition": f'attachment; filename="padel-business-plan-{export_id}.pdf"' + "Content-Disposition": f'attachment; filename="padel-business-plan-{export["id"]}.pdf"' }, ) diff --git a/web/src/padelnomics/planner/templates/export.html b/web/src/padelnomics/planner/templates/export.html index 367014e..a2446dd 100644 --- a/web/src/padelnomics/planner/templates/export.html +++ b/web/src/padelnomics/planner/templates/export.html @@ -85,7 +85,7 @@
{% if e.status == 'ready' %} - {{ t.export_download }} + {{ t.export_download }} {% elif e.status == 'pending' or e.status == 'generating' %} {{ t.export_generating }} {% else %} diff --git a/web/src/padelnomics/planner/templates/export_success.html b/web/src/padelnomics/planner/templates/export_success.html index 6e24af3..deac913 100644 --- a/web/src/padelnomics/planner/templates/export_success.html +++ b/web/src/padelnomics/planner/templates/export_success.html @@ -10,7 +10,7 @@ {% if exports %} {% for e in exports %} {% if e.status == 'ready' %} - {{ t.export_download }} + {{ t.export_download }} {% else %}
{{ t.export_success_status }} diff --git a/web/src/padelnomics/suppliers/routes.py b/web/src/padelnomics/suppliers/routes.py index 496f0dc..0a2747a 100644 --- a/web/src/padelnomics/suppliers/routes.py +++ b/web/src/padelnomics/suppliers/routes.py @@ -504,16 +504,22 @@ async def lead_feed(): ) -@bp.route("/leads//unlock", methods=["POST"]) +@bp.route("/leads//unlock", methods=["POST"]) @_lead_tier_required @csrf_protect -async def unlock_lead(lead_id: int): +async def unlock_lead(token: str): """Spend credits to unlock a lead. Returns full-details card via HTMX.""" from ..credits import InsufficientCredits from ..credits import unlock_lead as do_unlock supplier = g.supplier + # Resolve opaque token to integer ID for internal operations + lead_row = await fetch_one("SELECT id FROM lead_requests WHERE token = ?", (token,)) + if not lead_row: + return jsonify({"error": "Lead not found."}), 404 + lead_id = lead_row["id"] + try: result = await do_unlock(supplier["id"], lead_id) except InsufficientCredits as e: diff --git a/web/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_leads.html b/web/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_leads.html index fc468ad..7b38cf2 100644 --- a/web/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_leads.html +++ b/web/src/padelnomics/suppliers/templates/suppliers/partials/dashboard_leads.html @@ -121,7 +121,7 @@ {% endif %}
{{ lead.credit_cost or '?' }} {{ t.sd_leads_credits_to_unlock }}
-
+
diff --git a/web/src/padelnomics/suppliers/templates/suppliers/partials/lead_card.html b/web/src/padelnomics/suppliers/templates/suppliers/partials/lead_card.html index ea1bf6b..d6038b8 100644 --- a/web/src/padelnomics/suppliers/templates/suppliers/partials/lead_card.html +++ b/web/src/padelnomics/suppliers/templates/suppliers/partials/lead_card.html @@ -25,7 +25,7 @@
{{ lead.credit_cost or '?' }} {{ t.sd_card_credits }} -
diff --git a/web/src/padelnomics/worker.py b/web/src/padelnomics/worker.py index 41f6264..6b8fa4b 100644 --- a/web/src/padelnomics/worker.py +++ b/web/src/padelnomics/worker.py @@ -206,7 +206,7 @@ async def handle_send_quote_verification(payload: dict) -> None: lang = payload.get("lang", "en") link = ( f"{config.BASE_URL}/{lang}/leads/verify" - f"?token={payload['token']}&lead={payload['lead_id']}" + f"?token={payload['token']}&lead={payload['lead_token']}" ) if config.DEBUG: @@ -526,12 +526,16 @@ async def handle_generate_business_plan(payload: dict) -> None: ) # Notify user via email + export_row = await fetch_one( + "SELECT token FROM business_plan_exports WHERE id = ?", (export_id,) + ) + export_token = export_row["token"] user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,)) if user: body = ( f'

Your Business Plan is Ready

' f"

Your padel business plan PDF has been generated and is ready for download.

" - f'{_email_button(f"{config.BASE_URL}/planner/export/{export_id}", "Download PDF")}' + f'{_email_button(f"{config.BASE_URL}/planner/export/{export_token}", "Download PDF")}' ) await send_email( to=user["email"], diff --git a/web/tests/test_phase0.py b/web/tests/test_phase0.py index eb06007..9e61fd9 100644 --- a/web/tests/test_phase0.py +++ b/web/tests/test_phase0.py @@ -514,7 +514,7 @@ class TestQuoteVerification: } async def _submit_guest_quote(self, client, db, email="verify@example.com"): - """Helper: submit a quote as a guest, return (lead_id, token).""" + """Helper: submit a quote as a guest, return (lead_id, auth_token, lead_token).""" await client.get("/en/leads/quote") async with client.session_transaction() as sess: csrf = sess.get("csrf_token", "") @@ -522,20 +522,21 @@ class TestQuoteVerification: await client.post("/en/leads/quote", form=form) async with db.execute( - "SELECT id FROM lead_requests WHERE contact_email = ?", (email,) + "SELECT id, token FROM lead_requests WHERE contact_email = ?", (email,) ) as cur: - lead_id = (await cur.fetchone())[0] + row = await cur.fetchone() + lead_id, lead_token = row[0], row[1] async with db.execute( "SELECT token FROM auth_tokens ORDER BY id DESC LIMIT 1" ) as cur: - token = (await cur.fetchone())[0] + auth_token = (await cur.fetchone())[0] - return lead_id, token + return lead_id, auth_token, lead_token async def test_guest_quote_creates_pending_lead(self, client, db): """Guest quote creates lead with status='pending_verification'.""" - lead_id, _ = await self._submit_guest_quote(client, db) + lead_id, _, _lt = await self._submit_guest_quote(client, db) async with db.execute( "SELECT status FROM lead_requests WHERE id = ?", (lead_id,) @@ -593,9 +594,9 @@ class TestQuoteVerification: async def test_verify_link_activates_lead(self, client, db): """GET /leads/verify with valid token sets status='new' and verified_at.""" - lead_id, token = await self._submit_guest_quote(client, db) + lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db) - resp = await client.get(f"/en/leads/verify?token={token}&lead={lead_id}") + resp = await client.get(f"/en/leads/verify?token={auth_token}&lead={lead_token}") assert resp.status_code == 200 async with db.execute( @@ -607,30 +608,30 @@ class TestQuoteVerification: async def test_verify_sets_session(self, client, db): """Verification link logs the user in (sets session user_id).""" - lead_id, token = await self._submit_guest_quote(client, db) + _lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db) - await client.get(f"/en/leads/verify?token={token}&lead={lead_id}") + await client.get(f"/en/leads/verify?token={auth_token}&lead={lead_token}") async with client.session_transaction() as sess: assert "user_id" in sess async def test_verify_expired_token(self, client, db): """Expired/used token redirects with error.""" - lead_id, token = await self._submit_guest_quote(client, db) + _lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db) # Expire the token await db.execute("UPDATE auth_tokens SET expires_at = '2000-01-01T00:00:00'") await db.commit() resp = await client.get( - f"/en/leads/verify?token={token}&lead={lead_id}", + f"/en/leads/verify?token={auth_token}&lead={lead_token}", follow_redirects=False, ) assert resp.status_code == 302 async def test_verify_already_verified_lead(self, client, db): """Attempting to verify an already-activated lead shows error.""" - lead_id, token = await self._submit_guest_quote(client, db) + lead_id, auth_token, lead_token = await self._submit_guest_quote(client, db) # Manually activate the lead await db.execute( @@ -639,7 +640,7 @@ class TestQuoteVerification: await db.commit() resp = await client.get( - f"/en/leads/verify?token={token}&lead={lead_id}", + f"/en/leads/verify?token={auth_token}&lead={lead_token}", follow_redirects=False, ) assert resp.status_code == 302