diff --git a/backoffice/app/app.py b/backoffice/app/app.py index a895c0a..5d75f0a 100644 --- a/backoffice/app/app.py +++ b/backoffice/app/app.py @@ -20,9 +20,11 @@ from waitress import serve import db from sheets import Sheets 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.register_blueprint(booking_bp) +app.register_blueprint(public_booking_bp) CRM_TOKEN = os.environ.get("CRM_API_TOKEN", "") # Separate read-only token for the public iCal feed (calendar apps can't send diff --git a/backoffice/app/booking_api.py b/backoffice/app/booking_api.py index 85a3fb5..e1c3de3 100644 --- a/backoffice/app/booking_api.py +++ b/backoffice/app/booking_api.py @@ -117,6 +117,19 @@ def slots(): @bp.post("") def create_booking(): 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") resource_id = body.get("resource_id") service_id = body.get("service_id") diff --git a/backoffice/app/booking_db.py b/backoffice/app/booking_db.py index c7db724..44aaad3 100644 --- a/backoffice/app/booking_db.py +++ b/backoffice/app/booking_db.py @@ -26,15 +26,23 @@ class UnknownResource(Exception): 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)}" +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 ---- def create_resource(client_id, name, active=True, min_notice_minutes=60, 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: cur.execute( "INSERT INTO resources (resource_id, client_id, name, active, " @@ -90,7 +98,7 @@ def get_resource_hours(client_id, resource_id): # ---- services ---- 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: cur.execute( "INSERT INTO services (service_id, client_id, name, duration_minutes, " @@ -109,6 +117,14 @@ def get_service(client_id, service_id): 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 ---- _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.""" if get_resource(client_id, resource_id) is None: raise UnknownResource(f"no resource {resource_id} for client {client_id}") - booking_id = _new_id("BK") + booking_id = new_id("BK") try: with db.connect() as conn, conn.cursor() as cur: cur.execute( @@ -224,10 +240,20 @@ def get_client(client_id): return cur.fetchone() +def get_client_by_slug(slug): + """Resolve a client for the public /book/ 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) ---- def create_user(client_id, email, password): - user_id = _new_id("U") + user_id = new_id("U") password_hash = generate_password_hash(password) with db.connect() as conn, conn.cursor() as cur: cur.execute( diff --git a/backoffice/app/public_booking.py b/backoffice/app/public_booking.py new file mode 100644 index 0000000..767fa07 --- /dev/null +++ b/backoffice/app/public_booking.py @@ -0,0 +1,41 @@ +"""Public booking page blueprint (#17): the customer-facing /book/ 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/") +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, + ) diff --git a/backoffice/app/templates/book.html b/backoffice/app/templates/book.html new file mode 100644 index 0000000..ad8752b --- /dev/null +++ b/backoffice/app/templates/book.html @@ -0,0 +1,315 @@ + + + + + + Termin buchen — {{ business_name }} + + + +
+

Termin buchen — {{ business_name }}

+ +
+ +
+

Leistung

+
+ {% for s in services %} + + {% endfor %} +
+
+ + {% if resources|length > 1 %} +
+

Mitarbeiter

+
+ {% for r in resources %} + + {% endfor %} +
+
+ {% endif %} + +
+

Datum

+
+ +
+
+
+ +
+

Ihre Daten

+
+ + +
+
+ + +
+ + + +
+ +
+

Termin bestätigt

+

+

+
+
+ + + + + + + diff --git a/backoffice/app/tests/test_public_booking.py b/backoffice/app/tests/test_public_booking.py new file mode 100644 index 0000000..92e3ed7 --- /dev/null +++ b/backoffice/app/tests/test_public_booking.py @@ -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 diff --git a/deploy/booking/RATE_LIMIT.md b/deploy/booking/RATE_LIMIT.md new file mode 100644 index 0000000..9d69894 --- /dev/null +++ b/deploy/booking/RATE_LIMIT.md @@ -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.