Public booking page + iframe embed (#17)

Adds the customer-facing /book/<slug> page: service/slot picker, booking
form, and confirmation screen, built on #16's existing booking JSON API.
Includes iframe auto-fit height reporting (mirroring
deploy/booking/booking_layout.js's eaBookingHeight message), brand-color
theming via a ?color= query param, a honeypot field with a fake-success
response indistinguishable from a real booking, and a clear "just taken"
message on slot-conflict. Caddy per-IP rate limiting is documented in
deploy/booking/RATE_LIMIT.md for manual application (no Caddyfile is
tracked in this repo).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 15:15:29 +02:00
parent 2f6e0c1459
commit b895663c3a
7 changed files with 556 additions and 5 deletions
+2
View File
@@ -20,9 +20,11 @@ from waitress import serve
import db import db
from sheets import Sheets from sheets import Sheets
from booking_api import bp as booking_bp from booking_api import bp as booking_bp
from public_booking import bp as public_booking_bp
app = Flask(__name__, static_folder="static", static_url_path="") app = Flask(__name__, static_folder="static", static_url_path="")
app.register_blueprint(booking_bp) app.register_blueprint(booking_bp)
app.register_blueprint(public_booking_bp)
CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "") CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "")
# Separate read-only token for the public iCal feed (calendar apps can't send # Separate read-only token for the public iCal feed (calendar apps can't send
+13
View File
@@ -117,6 +117,19 @@ def slots():
@bp.post("") @bp.post("")
def create_booking(): def create_booking():
body = request.get_json(force=True, silent=True) or {} body = request.get_json(force=True, silent=True) or {}
if (body.get("website") or "").strip():
# Honeypot field: real customers never see or fill it (hidden from
# sighted users and screen readers alike), so a filled value means a
# scripted bot filled every field it could find. Fake a normal-looking
# success instead of a 4xx so a scripted client has no signal it was
# caught -- no booking is created, but the id/token are shaped exactly
# like a real create_booking() response (same id format, same client_id
# in the token's claims) so nothing about this response is
# distinguishable from a genuine one by a client inspecting it.
fake_booking_id = bdb.new_id("BK")
return jsonify({"booking_id": fake_booking_id, "status": "confirmed",
"token": _mint_manage_token(body.get("client_id") or "",
fake_booking_id)}), 201
client_id = body.get("client_id") client_id = body.get("client_id")
resource_id = body.get("resource_id") resource_id = body.get("resource_id")
service_id = body.get("service_id") service_id = body.get("service_id")
+31 -5
View File
@@ -26,15 +26,23 @@ class UnknownResource(Exception):
guards against a booking write smuggling in another tenant's resource.""" guards against a booking write smuggling in another tenant's resource."""
def _new_id(prefix): def new_id(prefix):
return f"{prefix}-{int(time.time() * 1000)}-{secrets.token_hex(3)}" return f"{prefix}-{int(time.time() * 1000)}-{secrets.token_hex(3)}"
def _list_active(table, client_id):
with db.connect() as conn, conn.cursor() as cur:
cur.execute(
f"SELECT * FROM {table} WHERE client_id = %s AND active ORDER BY name",
(client_id,))
return cur.fetchall()
# ---- resources ---- # ---- resources ----
def create_resource(client_id, name, active=True, min_notice_minutes=60, def create_resource(client_id, name, active=True, min_notice_minutes=60,
max_advance_days=30, buffer_minutes=0): max_advance_days=30, buffer_minutes=0):
resource_id = _new_id("RS") resource_id = new_id("RS")
with db.connect() as conn, conn.cursor() as cur: with db.connect() as conn, conn.cursor() as cur:
cur.execute( cur.execute(
"INSERT INTO resources (resource_id, client_id, name, active, " "INSERT INTO resources (resource_id, client_id, name, active, "
@@ -90,7 +98,7 @@ def get_resource_hours(client_id, resource_id):
# ---- services ---- # ---- services ----
def create_service(client_id, name, duration_minutes, price=None, active=True): def create_service(client_id, name, duration_minutes, price=None, active=True):
service_id = _new_id("SV") service_id = new_id("SV")
with db.connect() as conn, conn.cursor() as cur: with db.connect() as conn, conn.cursor() as cur:
cur.execute( cur.execute(
"INSERT INTO services (service_id, client_id, name, duration_minutes, " "INSERT INTO services (service_id, client_id, name, duration_minutes, "
@@ -109,6 +117,14 @@ def get_service(client_id, service_id):
return cur.fetchone() return cur.fetchone()
def list_active_services(client_id):
return _list_active("services", client_id)
def list_active_resources(client_id):
return _list_active("resources", client_id)
# ---- bookings ---- # ---- bookings ----
_BOOKING_UPDATABLE = {"resource_id", "customer_name", "customer_contact", _BOOKING_UPDATABLE = {"resource_id", "customer_name", "customer_contact",
@@ -126,7 +142,7 @@ def create_booking(client_id, resource_id, customer_name, customer_contact,
into a clean error.""" into a clean error."""
if get_resource(client_id, resource_id) is None: if get_resource(client_id, resource_id) is None:
raise UnknownResource(f"no resource {resource_id} for client {client_id}") raise UnknownResource(f"no resource {resource_id} for client {client_id}")
booking_id = _new_id("BK") booking_id = new_id("BK")
try: try:
with db.connect() as conn, conn.cursor() as cur: with db.connect() as conn, conn.cursor() as cur:
cur.execute( cur.execute(
@@ -224,10 +240,20 @@ def get_client(client_id):
return cur.fetchone() return cur.fetchone()
def get_client_by_slug(slug):
"""Resolve a client for the public /book/<slug> page. slug is
unauthenticated user input, so this is the one lookup that goes straight
from an untrusted string to a client_id -- every other public-booking
call still requires the resolved client_id explicitly."""
with db.connect() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM clients WHERE slug = %s", (slug,))
return cur.fetchone()
# ---- users (owner login) ---- # ---- users (owner login) ----
def create_user(client_id, email, password): def create_user(client_id, email, password):
user_id = _new_id("U") user_id = new_id("U")
password_hash = generate_password_hash(password) password_hash = generate_password_hash(password)
with db.connect() as conn, conn.cursor() as cur: with db.connect() as conn, conn.cursor() as cur:
cur.execute( cur.execute(
+41
View File
@@ -0,0 +1,41 @@
"""Public booking page blueprint (#17): the customer-facing /book/<slug> page,
embedded via iframe into a client's landing page. This route only resolves a
slug to a client and renders the slot-grid/booking-form page around it -- all
booking mutations still go through booking_api.py's JSON API (#16), which the
page's own JS calls via fetch.
IP rate limiting on these public routes is enforced at the Caddy layer (per
#17's acceptance criteria), not in Flask -- see deploy/booking/RATE_LIMIT.md
for the Caddy config to apply by hand on the homelab.
"""
from flask import Blueprint, abort, render_template, request
import booking_db as bdb
bp = Blueprint("public_booking", __name__)
DEFAULT_BRAND_COLOR = "#0f8a7e"
@bp.get("/book/<slug>")
def book_page(slug):
client = bdb.get_client_by_slug(slug)
if client is None:
abort(404)
resources = bdb.list_active_resources(client["client_id"])
services = bdb.list_active_services(client["client_id"])
if not resources or not services:
# No bookable services/resources configured yet -- nothing to show a
# customer rather than a broken/empty booking form.
abort(404)
return render_template(
"book.html",
client_id=client["client_id"],
business_name=client.get("business_name") or slug,
resources=[{"resource_id": r["resource_id"], "name": r["name"]} for r in resources],
services=[{"service_id": s["service_id"], "name": s["name"],
"duration_minutes": s["duration_minutes"],
"price": float(s["price"]) if s["price"] is not None else None}
for s in services],
brand_color=request.args.get("color") or DEFAULT_BRAND_COLOR,
)
+315
View File
@@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Termin buchen — {{ business_name }}</title>
<style>
:root {
--brand: {{ brand_color }};
--bg: #fff; --ink: #16302f; --muted: #5d716f; --line: #e3eae9;
--danger: #b3261e; --danger-bg: #fdecea;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background: var(--bg); color: var(--ink); padding: 20px; }
h1 { font-size: 1.2rem; margin: 0 0 18px; }
h2 { font-size: .95rem; margin: 0 0 10px; color: var(--muted);
text-transform: uppercase; letter-spacing: .5px; }
section { margin-bottom: 22px; }
.options { display: flex; flex-wrap: wrap; gap: 8px; }
.opt { border: 1px solid var(--line); border-radius: 9px; padding: 10px 14px;
background: #fff; cursor: pointer; font: inherit; font-size: .88rem; }
.opt.selected { background: var(--brand); border-color: var(--brand); color: #fff; }
.opt:disabled { opacity: .4; cursor: not-allowed; }
label { display: block; font-size: .82rem; color: var(--muted); margin-bottom: 5px; }
input[type=date], input[type=text], input[type=email] {
width: 100%; max-width: 320px; padding: 9px 12px; border: 1px solid var(--line);
border-radius: 9px; font: inherit; font-size: .9rem; }
.field { margin-bottom: 14px; }
.btn { background: var(--brand); color: #fff; border: 0; border-radius: 9px;
padding: 11px 20px; font: inherit; font-size: .92rem; font-weight: 600; cursor: pointer; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.error { background: var(--danger-bg); color: var(--danger); border-radius: 9px;
padding: 10px 14px; font-size: .88rem; margin-bottom: 14px; display: none; }
.confirmation { display: none; border: 1px solid var(--line); border-radius: 12px;
padding: 20px; background: #f6faf9; }
.confirmation h2 { color: var(--ink); text-transform: none; letter-spacing: normal; font-size: 1.05rem; }
.muted { color: var(--muted); font-size: .85rem; }
/* Honeypot: hidden from sighted users and from screen readers, but present
in the DOM/markup and tab order so a scripted client filling every
visible-looking field still trips it. */
.hp-field { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
</style>
</head>
<body>
<div id="booking-root">
<h1>Termin buchen — {{ business_name }}</h1>
<div class="error" id="error-banner"></div>
<section>
<h2>Leistung</h2>
<div class="options" id="service-options">
{% for s in services %}
<button type="button" class="opt" data-service-id="{{ s.service_id }}"
data-duration="{{ s.duration_minutes }}">
{{ s.name }} ({{ s.duration_minutes }} min{% if s.price is not none %}, {{ "%.2f"|format(s.price) }} €{% endif %})
</button>
{% endfor %}
</div>
</section>
{% if resources|length > 1 %}
<section>
<h2>Mitarbeiter</h2>
<div class="options" id="resource-options">
{% for r in resources %}
<button type="button" class="opt" data-resource-id="{{ r.resource_id }}">{{ r.name }}</button>
{% endfor %}
</div>
</section>
{% endif %}
<section>
<h2>Datum</h2>
<div class="field">
<input type="date" id="date-input" />
</div>
<div class="options" id="slot-options"></div>
</section>
<section id="details-section">
<h2>Ihre Daten</h2>
<div class="field">
<label for="customer-name">Name</label>
<input type="text" id="customer-name" autocomplete="name" />
</div>
<div class="field">
<label for="customer-contact">E-Mail oder Telefon</label>
<input type="text" id="customer-contact" autocomplete="email" />
</div>
<!-- Honeypot: real customers never see this field; bots that fill every
field in the DOM do, and get silently rejected server-side. -->
<div class="field hp-field" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
</div>
<button type="button" class="btn" id="submit-btn" disabled>Termin buchen</button>
</section>
<div class="confirmation" id="confirmation">
<h2>Termin bestätigt</h2>
<p id="confirmation-details"></p>
<p class="muted" id="confirmation-status"></p>
</div>
</div>
<script>
(function () {
var CLIENT_ID = {{ client_id|tojson }};
var RESOURCES = {{ resources|tojson }};
var services = {{ services|tojson }};
var selectedService = null;
var selectedResource = RESOURCES.length === 1 ? RESOURCES[0].resource_id : null;
var selectedSlot = null;
var errorBanner = document.getElementById('error-banner');
var dateInput = document.getElementById('date-input');
var slotOptions = document.getElementById('slot-options');
var submitBtn = document.getElementById('submit-btn');
var confirmation = document.getElementById('confirmation');
var today = new Date();
dateInput.value = today.toISOString().slice(0, 10);
dateInput.min = today.toISOString().slice(0, 10);
function showError(msg) {
errorBanner.textContent = msg;
errorBanner.style.display = msg ? 'block' : 'none';
}
function updateSubmitState() {
var name = document.getElementById('customer-name').value.trim();
var contact = document.getElementById('customer-contact').value.trim();
submitBtn.disabled = !(selectedService && selectedResource && selectedSlot
&& name && contact);
}
// Wires an "options" button group (service/resource pickers) so
// clicking a button marks it selected, clears its siblings, and hands
// the button to onPick -- both groups share this exact select-one shape.
function wireOptionGroup(container, onPick) {
if (!container) {
return;
}
container.querySelectorAll('.opt').forEach(function (btn) {
btn.addEventListener('click', function () {
container.querySelectorAll('.opt').forEach(function (b) {
b.classList.remove('selected');
});
btn.classList.add('selected');
onPick(btn);
});
});
}
wireOptionGroup(document.getElementById('service-options'), function (btn) {
selectedService = btn.getAttribute('data-service-id');
selectedSlot = null;
loadSlots();
});
wireOptionGroup(document.getElementById('resource-options'), function (btn) {
selectedResource = btn.getAttribute('data-resource-id');
selectedSlot = null;
loadSlots();
});
dateInput.addEventListener('change', function () {
selectedSlot = null;
loadSlots();
});
function loadSlots() {
slotOptions.innerHTML = '';
updateSubmitState();
if (!selectedService || !selectedResource || !dateInput.value) {
return;
}
var day = dateInput.value;
var params = new URLSearchParams({
client_id: CLIENT_ID, resource_id: selectedResource,
service_id: selectedService, date_from: day, date_to: day,
});
fetch('/api/booking/slots?' + params.toString())
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.slots || data.slots.length === 0) {
slotOptions.innerHTML = '<p class="muted">Keine freien Termine an diesem Tag.</p>';
return;
}
data.slots.forEach(function (iso) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'opt';
var d = new Date(iso);
btn.textContent = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
btn.addEventListener('click', function () {
slotOptions.querySelectorAll('.opt').forEach(function (b) {
b.classList.remove('selected');
});
btn.classList.add('selected');
selectedSlot = iso;
updateSubmitState();
});
slotOptions.appendChild(btn);
});
})
.catch(function () {
showError('Termine konnten nicht geladen werden. Bitte versuchen Sie es erneut.');
});
}
document.getElementById('customer-name').addEventListener('input', updateSubmitState);
document.getElementById('customer-contact').addEventListener('input', updateSubmitState);
submitBtn.addEventListener('click', function () {
showError('');
submitBtn.disabled = true;
fetch('/api/booking', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: CLIENT_ID, resource_id: selectedResource,
service_id: selectedService, start_time: selectedSlot,
customer_name: document.getElementById('customer-name').value.trim(),
customer_contact: document.getElementById('customer-contact').value.trim(),
website: document.getElementById('website').value,
}),
})
.then(function (r) { return r.json().then(function (body) { return { ok: r.ok, status: r.status, body: body }; }); })
.then(function (res) {
if (!res.ok) {
if (res.status === 409) {
showError('Dieser Termin wurde gerade eben vergeben. Bitte wählen Sie einen anderen.');
selectedSlot = null;
loadSlots();
} else {
showError(res.body.error || 'Die Buchung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
}
updateSubmitState();
return;
}
var service = services.find(function (s) { return s.service_id === selectedService; });
document.getElementById('booking-root').querySelectorAll('section').forEach(function (s) {
s.style.display = 'none';
});
errorBanner.style.display = 'none';
document.getElementById('confirmation-details').textContent =
(service ? service.name : 'Termin') + ' am '
+ new Date(selectedSlot).toLocaleString('de-DE', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit',
});
document.getElementById('confirmation-status').textContent =
res.body.status === 'pending'
? 'Ihre Buchung wartet noch auf Bestätigung.'
: 'Ihre Buchung ist bestätigt.';
confirmation.style.display = 'block';
})
.catch(function () {
showError('Die Buchung ist fehlgeschlagen. Bitte versuchen Sie es erneut.');
updateSubmitState();
});
});
loadSlots();
})();
</script>
<!-- Iframe auto-fit: report rendered height to the parent frame so an
embedding landing page can size the iframe without an inner scrollbar.
Mirrors deploy/booking/booking_layout.js's approach (same message key,
eaBookingHeight, so an existing embedding page's listener needs no
change when its src is repointed at this page). -->
<script>
(function () {
function reportHeight() {
var el = document.getElementById('booking-root');
var h = el ? Math.ceil(el.getBoundingClientRect().height) + 40 : document.body.scrollHeight;
if (h && h > 0) {
try { window.parent.postMessage({ eaBookingHeight: h }, '*'); } catch (e) { /* not embedded */ }
}
}
function start() {
if (window.parent === window) {
return;
}
reportHeight();
var target = document.getElementById('booking-root') || document.body;
if (window.ResizeObserver) {
new ResizeObserver(reportHeight).observe(target);
}
if (window.MutationObserver) {
new MutationObserver(reportHeight).observe(target, { subtree: true, childList: true, attributes: true });
}
window.addEventListener('resize', reportHeight);
var ticks = 0;
var iv = setInterval(function () {
reportHeight();
if (++ticks > 25) { clearInterval(iv); }
}, 250);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
})();
</script>
</body>
</html>
+120
View File
@@ -0,0 +1,120 @@
"""Flask test client / real-DB integration tests for the public booking page
and its honeypot abuse-protection (#17), per #14's testing decision: assert on
HTTP response + resulting DB state.
"""
from datetime import date, time, timedelta
import pytest
import booking_db as bdb
from app import app as flask_app
CLIENT_A = "C-TEST-PUBLIC-A"
CLIENT_B = "C-TEST-PUBLIC-B"
@pytest.fixture
def client():
flask_app.config["TESTING"] = True
return flask_app.test_client()
def _next_monday(after):
d = after + timedelta(days=1)
while d.weekday() != 0:
d += timedelta(days=1)
return d
def _make_client(client_id=CLIENT_A, slug="happynails", business_name="Happy Nails"):
with bdb.db.connect() as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO clients (client_id, business_name, slug, timezone, auto_confirm) "
"VALUES (%s, %s, %s, %s, %s) "
"ON CONFLICT (client_id) DO UPDATE SET business_name = EXCLUDED.business_name, "
"slug = EXCLUDED.slug, timezone = EXCLUDED.timezone, "
"auto_confirm = EXCLUDED.auto_confirm",
(client_id, business_name, slug, "Europe/Berlin", True))
conn.commit()
def _setup_bookable_client(client_id=CLIENT_A, slug="happynails"):
_make_client(client_id, slug)
resource = bdb.create_resource(client_id, "Chair 1", min_notice_minutes=0,
max_advance_days=365)
bdb.set_resource_hours(client_id, resource["resource_id"], 0, time(9, 0), time(17, 0))
service = bdb.create_service(client_id, "Haircut", 60, price=25)
return resource, service
def test_book_page_renders_for_known_slug_with_services_and_resources(client):
resource, service = _setup_bookable_client()
resp = client.get("/book/happynails")
assert resp.status_code == 200
body = resp.get_data(as_text=True)
assert "Happy Nails" in body
assert "Haircut" in body
assert resource["resource_id"] in body
def test_book_page_404s_for_unknown_slug(client):
resp = client.get("/book/does-not-exist")
assert resp.status_code == 404
def test_book_page_404s_when_client_has_no_active_service(client):
_make_client(CLIENT_B, slug="no-services-client")
bdb.create_resource(CLIENT_B, "Chair 1")
# No services created for this client.
resp = client.get("/book/no-services-client")
assert resp.status_code == 404
def test_book_page_404s_when_client_has_only_inactive_service(client):
_make_client(CLIENT_B, slug="inactive-service-client")
bdb.create_resource(CLIENT_B, "Chair 1")
bdb.create_service(CLIENT_B, "Haircut", 60, active=False)
resp = client.get("/book/inactive-service-client")
assert resp.status_code == 404
def test_honeypot_filled_silently_rejects_booking(client):
resource, service = _setup_bookable_client()
day = _next_monday(date.today())
slot = client.get("/api/booking/slots", query_string={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"],
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
assert slot
resp = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot[0],
"customer_name": "Bot", "customer_contact": "bot@example.com",
"website": "https://spam.example"})
# Looks like an ordinary success to the caller...
assert resp.status_code == 201
body = resp.get_json()
assert body["status"] == "confirmed"
assert "token" in body
# ...but no booking was actually created.
assert bdb.list_bookings(CLIENT_A) == []
def test_honeypot_empty_creates_a_real_booking(client):
resource, service = _setup_bookable_client()
day = _next_monday(date.today())
slot = client.get("/api/booking/slots", query_string={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"],
"date_from": day.isoformat(), "date_to": day.isoformat()}).get_json()["slots"]
resp = client.post("/api/booking", json={
"client_id": CLIENT_A, "resource_id": resource["resource_id"],
"service_id": service["service_id"], "start_time": slot[0],
"customer_name": "Real Customer", "customer_contact": "real@example.com",
"website": ""})
assert resp.status_code == 201
assert len(bdb.list_bookings(CLIENT_A)) == 1
+34
View File
@@ -0,0 +1,34 @@
# Caddy rate limiting for `/book/*` and `/api/booking/*` (#17)
Like every other homelab Caddy change (see `deploy/clients/new-client.sh`), there's no
Caddyfile tracked in this repo -- apply this by hand at `/etc/caddy/Caddyfile` on the host
and reload with `docker exec caddy caddy reload --config /etc/caddy/Caddyfile`.
Add, on the site block that proxies to `smb-crm` (e.g. `onboard.mivanchenko.de`, or wherever
`/book/` and `/api/booking/` are routed):
```
handle /book/* {
rate_limit {
zone book_public {
key {remote_host}
events 20
window 1m
}
}
reverse_proxy smb-crm:8080
}
handle /api/booking/* {
rate_limit {
zone book_api {
key {remote_host}
events 30
window 1m
}
}
reverse_proxy smb-crm:8080
}
```
Requires Caddy built with the `caddy-ratelimit` plugin, as used for other per-IP
protections on this homelab.