feat: replace sequential IDs with opaque tokens in public URLs

Sequential IDs in /planner/export/<id> and /leads/<id>/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/<token> route (was /export/<int:export_id>)
- suppliers/routes.py: /leads/<token>/unlock (was /leads/<int:lead_id>)
- 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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-22 17:03:13 +01:00
parent 35fe934fec
commit 815fb7d98a
11 changed files with 103 additions and 38 deletions

View File

@@ -4,6 +4,7 @@ Payment provider: paddle
""" """
import json import json
import secrets
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -453,10 +454,10 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
transaction_id = data.get("id", "") transaction_id = data.get("id", "")
export_id = await execute( export_id = await execute(
"""INSERT INTO business_plan_exports """INSERT INTO business_plan_exports
(user_id, scenario_id, paddle_transaction_id, language, status, created_at) (user_id, scenario_id, paddle_transaction_id, language, status, token, created_at)
VALUES (?, ?, ?, ?, 'pending', ?)""", VALUES (?, ?, ?, ?, 'pending', ?, ?)""",
(int(user_id), int(scenario_id) if scenario_id else 0, (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 # Enqueue PDF generation
from ..worker import enqueue from ..worker import enqueue

View File

@@ -333,8 +333,8 @@ async def quote_request():
previous_supplier_contact, services_needed, additional_info, previous_supplier_contact, services_needed, additional_info,
contact_name, contact_email, contact_phone, contact_company, contact_name, contact_email, contact_phone, contact_company,
stakeholder_type, stakeholder_type,
heat_score, status, credit_cost, created_at) heat_score, status, credit_cost, token, created_at)
VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", VALUES (?, 'quote', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
user_id, user_id,
form.get("court_count", 0), form.get("court_count", 0),
@@ -361,6 +361,7 @@ async def quote_request():
heat, heat,
status, status,
credit_cost, credit_cost,
secrets.token_urlsafe(16),
datetime.utcnow().isoformat(), datetime.utcnow().isoformat(),
), ),
) )
@@ -422,11 +423,17 @@ async def quote_request():
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
await create_auth_token(new_user_id, token, minutes=60) 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 from ..worker import enqueue
await enqueue("send_quote_verification", { await enqueue("send_quote_verification", {
"email": contact_email, "email": contact_email,
"token": token, "token": token,
"lead_id": lead_id, "lead_id": lead_id,
"lead_token": lead_token,
"lang": g.get("lang", "en"), "lang": g.get("lang", "en"),
"contact_name": form.get("contact_name", ""), "contact_name": form.get("contact_name", ""),
"facility_type": form.get("facility_type", ""), "facility_type": form.get("facility_type", ""),
@@ -466,10 +473,10 @@ async def quote_request():
async def verify_quote(): async def verify_quote():
"""Verify email from quote submission — activates lead and logs user in.""" """Verify email from quote submission — activates lead and logs user in."""
token_str = request.args.get("token") 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")) _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") await flash(_t["flash_verify_invalid"], "error")
return redirect(url_for("leads.quote_request")) return redirect(url_for("leads.quote_request"))
@@ -479,10 +486,10 @@ async def verify_quote():
await flash(_t["flash_verify_expired"], "error") await flash(_t["flash_verify_expired"], "error")
return redirect(url_for("leads.quote_request")) 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( lead = await fetch_one(
"SELECT * FROM lead_requests WHERE id = ? AND status = 'pending_verification'", "SELECT * FROM lead_requests WHERE token = ? AND status = 'pending_verification'",
(lead_id,), (lead_token,),
) )
if not lead: if not lead:
await flash(_t["flash_verify_invalid_lead"], "error") await flash(_t["flash_verify_invalid_lead"], "error")
@@ -497,7 +504,7 @@ async def verify_quote():
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
await execute( await execute(
"UPDATE lead_requests SET status = 'new', verified_at = ?, credit_cost = ? WHERE id = ?", "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 # Set user name from contact_name if not already set

View File

@@ -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)"
)

View File

@@ -534,13 +534,13 @@ async def export_success():
return await render_template("export_success.html", exports=exports) return await render_template("export_success.html", exports=exports)
@bp.route("/export/<int:export_id>") @bp.route("/export/<token>")
@login_required @login_required
async def export_download(export_id: int): async def export_download(token: str):
"""Download a generated PDF.""" """Download a generated PDF."""
export = await fetch_one( export = await fetch_one(
"SELECT * FROM business_plan_exports WHERE id = ? AND user_id = ?", "SELECT * FROM business_plan_exports WHERE token = ? AND user_id = ?",
(export_id, g.user["id"]), (token, g.user["id"]),
) )
if not export: if not export:
return jsonify({"error": "Export not found."}), 404 return jsonify({"error": "Export not found."}), 404
@@ -562,7 +562,7 @@ async def export_download(export_id: int):
pdf_bytes, pdf_bytes,
mimetype="application/pdf", mimetype="application/pdf",
headers={ headers={
"Content-Disposition": f'attachment; filename="padel-business-plan-{export_id}.pdf"' "Content-Disposition": f'attachment; filename="padel-business-plan-{export["id"]}.pdf"'
}, },
) )

View File

@@ -85,7 +85,7 @@
</div> </div>
<div> <div>
{% if e.status == 'ready' %} {% if e.status == 'ready' %}
<a href="{{ url_for('planner.export_download', export_id=e.id) }}" class="btn-outline" style="font-size:0.75rem;padding:4px 12px">{{ t.export_download }}</a> <a href="{{ url_for('planner.export_download', token=e.token) }}" class="btn-outline" style="font-size:0.75rem;padding:4px 12px">{{ t.export_download }}</a>
{% elif e.status == 'pending' or e.status == 'generating' %} {% elif e.status == 'pending' or e.status == 'generating' %}
<span class="exp-status exp-status--pending">{{ t.export_generating }}</span> <span class="exp-status exp-status--pending">{{ t.export_generating }}</span>
{% else %} {% else %}

View File

@@ -10,7 +10,7 @@
{% if exports %} {% if exports %}
{% for e in exports %} {% for e in exports %}
{% if e.status == 'ready' %} {% if e.status == 'ready' %}
<a href="{{ url_for('planner.export_download', export_id=e.id) }}" class="btn" style="display:inline-block;padding:12px 32px">{{ t.export_download }}</a> <a href="{{ url_for('planner.export_download', token=e.token) }}" class="btn" style="display:inline-block;padding:12px 32px">{{ t.export_download }}</a>
{% else %} {% else %}
<div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:10px;padding:1rem;margin-bottom:1rem;font-size:0.875rem;color:#92400E"> <div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:10px;padding:1rem;margin-bottom:1rem;font-size:0.875rem;color:#92400E">
{{ t.export_success_status }} {{ t.export_success_status }}

View File

@@ -504,16 +504,22 @@ async def lead_feed():
) )
@bp.route("/leads/<int:lead_id>/unlock", methods=["POST"]) @bp.route("/leads/<token>/unlock", methods=["POST"])
@_lead_tier_required @_lead_tier_required
@csrf_protect @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.""" """Spend credits to unlock a lead. Returns full-details card via HTMX."""
from ..credits import InsufficientCredits from ..credits import InsufficientCredits
from ..credits import unlock_lead as do_unlock from ..credits import unlock_lead as do_unlock
supplier = g.supplier 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: try:
result = await do_unlock(supplier["id"], lead_id) result = await do_unlock(supplier["id"], lead_id)
except InsufficientCredits as e: except InsufficientCredits as e:

View File

@@ -121,7 +121,7 @@
{% endif %} {% endif %}
<div class="lf-card__foot"> <div class="lf-card__foot">
<div class="lf-card__cost"><strong>{{ lead.credit_cost or '?' }}</strong> {{ t.sd_leads_credits_to_unlock }}</div> <div class="lf-card__cost"><strong>{{ lead.credit_cost or '?' }}</strong> {{ t.sd_leads_credits_to_unlock }}</div>
<form hx-post="{{ url_for('suppliers.unlock_lead', lead_id=lead.id) }}" hx-target="#lead-card-{{ lead.id }}" hx-swap="innerHTML"> <form hx-post="{{ url_for('suppliers.unlock_lead', token=lead.token) }}" hx-target="#lead-card-{{ lead.id }}" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="lf-unlock-btn">{{ t.sd_leads_unlock }}</button> <button type="submit" class="lf-unlock-btn">{{ t.sd_leads_unlock }}</button>
</form> </form>

View File

@@ -25,7 +25,7 @@
<div class="lf-card__foot"> <div class="lf-card__foot">
<span class="lf-card__cost"><strong>{{ lead.credit_cost or '?' }}</strong> {{ t.sd_card_credits }}</span> <span class="lf-card__cost"><strong>{{ lead.credit_cost or '?' }}</strong> {{ t.sd_card_credits }}</span>
<form hx-post="{{ url_for('suppliers.unlock_lead', lead_id=lead.id) }}" <form hx-post="{{ url_for('suppliers.unlock_lead', token=lead.token) }}"
hx-target="#lead-card-{{ lead.id }}" hx-swap="innerHTML"> hx-target="#lead-card-{{ lead.id }}" hx-swap="innerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="lf-unlock-btn">{{ t.sd_card_unlock_btn }}</button> <button type="submit" class="lf-unlock-btn">{{ t.sd_card_unlock_btn }}</button>

View File

@@ -206,7 +206,7 @@ async def handle_send_quote_verification(payload: dict) -> None:
lang = payload.get("lang", "en") lang = payload.get("lang", "en")
link = ( link = (
f"{config.BASE_URL}/{lang}/leads/verify" 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: if config.DEBUG:
@@ -526,12 +526,16 @@ async def handle_generate_business_plan(payload: dict) -> None:
) )
# Notify user via email # 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,)) user = await fetch_one("SELECT email FROM users WHERE id = ?", (user_id,))
if user: if user:
body = ( body = (
f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Your Business Plan is Ready</h2>' f'<h2 style="margin:0 0 16px;color:#0F172A;font-size:20px;">Your Business Plan is Ready</h2>'
f"<p>Your padel business plan PDF has been generated and is ready for download.</p>" f"<p>Your padel business plan PDF has been generated and is ready for download.</p>"
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( await send_email(
to=user["email"], to=user["email"],

View File

@@ -514,7 +514,7 @@ class TestQuoteVerification:
} }
async def _submit_guest_quote(self, client, db, email="verify@example.com"): 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") await client.get("/en/leads/quote")
async with client.session_transaction() as sess: async with client.session_transaction() as sess:
csrf = sess.get("csrf_token", "") csrf = sess.get("csrf_token", "")
@@ -522,20 +522,21 @@ class TestQuoteVerification:
await client.post("/en/leads/quote", form=form) await client.post("/en/leads/quote", form=form)
async with db.execute( 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: ) as cur:
lead_id = (await cur.fetchone())[0] row = await cur.fetchone()
lead_id, lead_token = row[0], row[1]
async with db.execute( async with db.execute(
"SELECT token FROM auth_tokens ORDER BY id DESC LIMIT 1" "SELECT token FROM auth_tokens ORDER BY id DESC LIMIT 1"
) as cur: ) 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): async def test_guest_quote_creates_pending_lead(self, client, db):
"""Guest quote creates lead with status='pending_verification'.""" """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( async with db.execute(
"SELECT status FROM lead_requests WHERE id = ?", (lead_id,) "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): async def test_verify_link_activates_lead(self, client, db):
"""GET /leads/verify with valid token sets status='new' and verified_at.""" """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 assert resp.status_code == 200
async with db.execute( async with db.execute(
@@ -607,30 +608,30 @@ class TestQuoteVerification:
async def test_verify_sets_session(self, client, db): async def test_verify_sets_session(self, client, db):
"""Verification link logs the user in (sets session user_id).""" """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: async with client.session_transaction() as sess:
assert "user_id" in sess assert "user_id" in sess
async def test_verify_expired_token(self, client, db): async def test_verify_expired_token(self, client, db):
"""Expired/used token redirects with error.""" """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 # Expire the token
await db.execute("UPDATE auth_tokens SET expires_at = '2000-01-01T00:00:00'") await db.execute("UPDATE auth_tokens SET expires_at = '2000-01-01T00:00:00'")
await db.commit() await db.commit()
resp = await client.get( 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, follow_redirects=False,
) )
assert resp.status_code == 302 assert resp.status_code == 302
async def test_verify_already_verified_lead(self, client, db): async def test_verify_already_verified_lead(self, client, db):
"""Attempting to verify an already-activated lead shows error.""" """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 # Manually activate the lead
await db.execute( await db.execute(
@@ -639,7 +640,7 @@ class TestQuoteVerification:
await db.commit() await db.commit()
resp = await client.get( 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, follow_redirects=False,
) )
assert resp.status_code == 302 assert resp.status_code == 302