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
+11 -12
View File
@@ -38,9 +38,6 @@ app.register_blueprint(owner_settings_bp)
app.secret_key = os.environ.get("SESSION_SECRET_KEY", "")
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
# Separate read-only token for the public iCal feed (calendar apps can't send
# the basic-auth header, so the feed is gated by this query token instead).
ICS_TOKEN = os.environ.get("ICS_TOKEN", "")
# Static dashboard, with the CRM token injected so the (basic-auth-gated)
# operator page can call the token-protected mutation endpoints.
@@ -256,21 +253,23 @@ def _ics_esc(t):
@app.get("/api/bookings.ics")
def bookings_ics():
"""Read-only iCal feed for Apple/Google Calendar subscription. Gated by the
ICS_TOKEN query param (no header auth, so calendar apps can fetch it)."""
if not ICS_TOKEN or request.args.get("token") != ICS_TOKEN:
"""Read-only iCal feed for Apple/Google Calendar subscription, scoped to
one client via their own clients.ics_token (#22). No header auth, since
calendar apps can't send one -- gated by the query token instead, but
unlike the old shared ICS_TOKEN + client_id pair, the token itself
resolves the client, so there's no separate client_id param that could
be swapped to view another tenant's bookings."""
client = bdb.get_client_by_ics_token(request.args.get("token"))
if client is None:
return Response("forbidden\n", status=403, mimetype="text/plain")
cid = request.args.get("client_id")
cid = client["client_id"]
with db.connect() as conn, conn.cursor() as cur:
if cid:
cur.execute("SELECT * FROM bookings WHERE client_id = %s ORDER BY start_time", (cid,))
else:
cur.execute("SELECT * FROM bookings ORDER BY start_time")
cur.execute("SELECT * FROM bookings WHERE client_id = %s ORDER BY start_time", (cid,))
rows = cur.fetchall()
now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out = ["BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//smb-crm//bookings//DE",
"CALSCALE:GREGORIAN", "METHOD:PUBLISH",
"X-WR-CALNAME:" + _ics_esc("Buchungen " + cid if cid else "Buchungen")]
"X-WR-CALNAME:" + _ics_esc("Buchungen " + cid)]
for r in rows:
st = _ics_dt(r.get("start_time"))
if not st: