Per-client ICS calendar feed, replacing the shared ICS_TOKEN (#22)
Test backoffice (smb-crm) / test (push) Has been cancelled

Each client now gets their own clients.ics_token (lazily generated on
first /owner/settings visit), which both authenticates and scopes
/api/bookings.ics -- closing the gap where any shared-token holder
could view another client's bookings by swapping the client_id query
param. The owner settings page now surfaces a copyable subscribe URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 11:55:35 +02:00
parent 319218ce21
commit 24d9aca812
9 changed files with 196 additions and 20 deletions
+40
View File
@@ -340,6 +340,46 @@ def get_client_by_slug(slug):
return cur.fetchone()
def get_client_by_ics_token(token):
"""Resolve a client for the per-client ICS feed (#22) -- like
get_client_by_slug, this is the one lookup that goes straight from
untrusted request input (the ?token= query param) to a client_id, so the
feed can be scoped to exactly one tenant without a separate client_id
param that could be swapped independently of the token."""
if not token:
return None
with db.connect() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM clients WHERE ics_token = %s", (token,))
return cur.fetchone()
def ensure_ics_token(client_id):
"""Return the client's ics_token, generating and persisting one on first
use (#22). Lazy rather than a one-off backfill migration, so clients
onboarded before this ticket still get a working subscribe URL the first
time their settings page loads. The UPDATE ... WHERE ics_token IS NULL
guard means a losing concurrent call re-reads the winner's token instead
of overwriting it -- same single-connection read-then-write shape as
consume_password_reset_token."""
with db.connect() as conn, conn.cursor() as cur:
cur.execute("SELECT ics_token FROM clients WHERE client_id = %s", (client_id,))
row = cur.fetchone()
if row is None:
return None
if row["ics_token"]:
return row["ics_token"]
cur.execute(
"UPDATE clients SET ics_token = %s WHERE client_id = %s AND ics_token IS NULL "
"RETURNING ics_token",
(secrets.token_urlsafe(24), client_id))
row = cur.fetchone()
if row is None:
cur.execute("SELECT ics_token FROM clients WHERE client_id = %s", (client_id,))
row = cur.fetchone()
conn.commit()
return row["ics_token"]
_CLIENT_UPDATABLE = {"auto_confirm", "notify_channel"}