From 04fb49ad4b8a91ef204455bbce735ad8661ac60a Mon Sep 17 00:00:00 2001 From: allebonvi Date: Fri, 21 Aug 2026 19:02:16 +0200 Subject: [PATCH] Disattiva temporaneamente form prenotazioni --- backend/app/api/booking.py | 67 +++++- backend/app/config.py | 17 ++ backend/app/schemas/booking.py | 9 + backend/app/services/booking_security.py | 169 ++++++++++++++ backend/app/services/mailer.py | 11 +- .../client/src/components/BookingSection.tsx | 218 ++++++++++++++---- 6 files changed, 444 insertions(+), 47 deletions(-) create mode 100644 backend/app/services/booking_security.py diff --git a/backend/app/api/booking.py b/backend/app/api/booking.py index c2d722b..42cbbb1 100644 --- a/backend/app/api/booking.py +++ b/backend/app/api/booking.py @@ -1,27 +1,88 @@ -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, HTTPException, Request, status -from app.schemas.booking import BookingRequestCreate, BookingRequestResponse +from app.config import settings +from app.schemas.booking import BookingCaptchaChallenge, BookingRequestCreate, BookingRequestResponse +from app.services.booking_security import ( + create_captcha_challenge, + get_client_ip, + is_rate_limited, + new_request_id, + verify_captcha, + write_booking_audit_event, +) from app.services.mailer import MailConfigurationError, send_booking_request_email router = APIRouter(prefix="/api", tags=["booking"]) +@router.get("/booking-captcha", response_model=BookingCaptchaChallenge) +def get_booking_captcha() -> BookingCaptchaChallenge: + return create_captcha_challenge() + + @router.post("/booking-request", response_model=BookingRequestResponse) -def create_booking_request(payload: BookingRequestCreate) -> BookingRequestResponse: +def create_booking_request(request: Request, payload: BookingRequestCreate) -> BookingRequestResponse: + request_id = new_request_id() + write_booking_audit_event("received", request_id=request_id, request=request, payload=payload) + + if not settings.booking_form_enabled: + write_booking_audit_event("blocked_form_disabled", request_id=request_id, request=request, payload=payload) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Form in aggiornamento.", + ) + + if payload.website: + write_booking_audit_event("blocked_honeypot", request_id=request_id, request=request, payload=payload) + return BookingRequestResponse( + message="La richiesta è stata inviata correttamente e sarà presa in carico al più presto dal team.", + ) + + client_ip = get_client_ip(request) + if is_rate_limited(client_ip): + write_booking_audit_event("blocked_rate_limit", request_id=request_id, request=request, payload=payload) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Sono state inviate troppe richieste. Riprova più tardi.", + ) + + if not verify_captcha(payload.captcha_id, payload.captcha_answer): + write_booking_audit_event("blocked_captcha", request_id=request_id, request=request, payload=payload) + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Verifica anti-spam non valida. Ricarica il codice e riprova.", + ) + try: send_booking_request_email(payload) except MailConfigurationError as exc: + write_booking_audit_event( + "failed_configuration", + request_id=request_id, + request=request, + payload=payload, + error=exc, + ) raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Il servizio email non è ancora configurato.", ) from exc except Exception as exc: # pragma: no cover - mail delivery path + write_booking_audit_event( + "failed_delivery", + request_id=request_id, + request=request, + payload=payload, + error=exc, + ) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Si è verificato un problema durante l'invio della richiesta.", ) from exc + write_booking_audit_event("sent", request_id=request_id, request=request, payload=payload) + return BookingRequestResponse( message="La richiesta è stata inviata correttamente e sarà presa in carico al più presto dal team.", ) diff --git a/backend/app/config.py b/backend/app/config.py index e131a89..ad92e6d 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -32,6 +32,23 @@ class Settings: "BOOKING_EMAIL_SUBJECT_PREFIX", "[Clinica Veterinaria Formiginese]", ) + self.booking_form_enabled = os.getenv("BOOKING_FORM_ENABLED", "false").lower() in { + "1", + "true", + "yes", + "on", + } + self.booking_audit_log_path = os.getenv( + "BOOKING_AUDIT_LOG_PATH", + str((BASE_DIR / "logs" / "booking_audit.log").resolve()), + ) + self.booking_rate_limit_window_seconds = int(os.getenv("BOOKING_RATE_LIMIT_WINDOW_SECONDS", "900")) + self.booking_rate_limit_max_requests = int(os.getenv("BOOKING_RATE_LIMIT_MAX_REQUESTS", "5")) + self.booking_captcha_ttl_seconds = int(os.getenv("BOOKING_CAPTCHA_TTL_SECONDS", "600")) + self.booking_captcha_secret = os.getenv( + "BOOKING_CAPTCHA_SECRET", + self.smtp_password or self.app_name, + ) @property def sqlite_file_path(self) -> str | None: diff --git a/backend/app/schemas/booking.py b/backend/app/schemas/booking.py index c77ad0b..d6f04ea 100644 --- a/backend/app/schemas/booking.py +++ b/backend/app/schemas/booking.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field class BookingRequestCreate(BaseModel): name: str = Field(min_length=2, max_length=120) + email: str = Field(min_length=5, max_length=255) phone: str = Field(min_length=5, max_length=40) pet_name: str = Field(default="", max_length=120) pet_type: str = Field(default="cane", max_length=40) @@ -11,8 +12,16 @@ class BookingRequestCreate(BaseModel): date: str = Field(min_length=8, max_length=20) time: str = Field(default="", max_length=20) notes: str = Field(default="", max_length=2000) + website: str = Field(default="", max_length=200) + captcha_id: str = Field(min_length=20, max_length=300) + captcha_answer: str = Field(min_length=1, max_length=10) class BookingRequestResponse(BaseModel): success: bool = True message: str + + +class BookingCaptchaChallenge(BaseModel): + captcha_id: str + question: str diff --git a/backend/app/services/booking_security.py b/backend/app/services/booking_security.py new file mode 100644 index 0000000..77dcbe0 --- /dev/null +++ b/backend/app/services/booking_security.py @@ -0,0 +1,169 @@ +from __future__ import annotations + +from collections import defaultdict, deque +from datetime import UTC, datetime +import base64 +import hashlib +import hmac +import json +from pathlib import Path +import random +import threading +import time +from typing import Any +from uuid import uuid4 + +from fastapi import Request + +from app.config import settings +from app.schemas.booking import BookingCaptchaChallenge, BookingRequestCreate + + +_audit_lock = threading.Lock() +_rate_limit_lock = threading.Lock() +_rate_limit_hits: dict[str, deque[float]] = defaultdict(deque) + + +def new_request_id() -> str: + return uuid4().hex + + +def get_client_ip(request: Request) -> str: + forwarded_for = request.headers.get("x-forwarded-for", "") + if forwarded_for: + return forwarded_for.split(",", 1)[0].strip() + + real_ip = request.headers.get("x-real-ip", "") + if real_ip: + return real_ip.strip() + + if request.client: + return request.client.host + + return "unknown" + + +def is_rate_limited(client_ip: str) -> bool: + now = time.time() + window = settings.booking_rate_limit_window_seconds + max_requests = settings.booking_rate_limit_max_requests + + with _rate_limit_lock: + hits = _rate_limit_hits[client_ip] + while hits and now - hits[0] > window: + hits.popleft() + + if len(hits) >= max_requests: + return True + + hits.append(now) + return False + + +def create_captcha_challenge() -> BookingCaptchaChallenge: + left = random.randint(2, 9) + right = random.randint(2, 9) + expires_at = int(time.time()) + settings.booking_captcha_ttl_seconds + nonce = uuid4().hex + answer = str(left + right) + payload = f"{answer}:{expires_at}:{nonce}" + signature = _sign(payload) + token = _encode(f"{payload}:{signature}") + + return BookingCaptchaChallenge( + captcha_id=token, + question=f"Quanto fa {left} + {right}?", + ) + + +def verify_captcha(captcha_id: str, captcha_answer: str) -> bool: + try: + decoded = _decode(captcha_id) + answer, expires_at_value, nonce, signature = decoded.split(":", 3) + payload = f"{answer}:{expires_at_value}:{nonce}" + expected_signature = _sign(payload) + expires_at = int(expires_at_value) + except (ValueError, TypeError): + return False + + if not hmac.compare_digest(signature, expected_signature): + return False + + if expires_at < int(time.time()): + return False + + return hmac.compare_digest(captcha_answer.strip(), answer) + + +def write_booking_audit_event( + event: str, + *, + request_id: str, + request: Request, + payload: BookingRequestCreate | None = None, + error: Exception | str | None = None, +) -> None: + client_ip = get_client_ip(request) + record: dict[str, Any] = { + "timestamp": datetime.now(UTC).isoformat(), + "event": event, + "request_id": request_id, + "client_ip": client_ip, + "user_agent": request.headers.get("user-agent", "")[:300], + "referer": request.headers.get("referer", "")[:300], + } + + if payload is not None: + record.update( + { + "email_hash": _hash_value(payload.email.lower().strip()), + "email_domain": _email_domain(payload.email), + "phone_hash": _hash_value(payload.phone.strip()), + "name_length": len(payload.name), + "pet_type": payload.pet_type, + "doctor": payload.doctor, + "service": payload.service, + "date": payload.date, + "time": payload.time, + "notes_length": len(payload.notes or ""), + "honeypot_filled": bool(payload.website), + } + ) + + if error is not None: + record["error_type"] = error.__class__.__name__ if isinstance(error, Exception) else "Error" + record["error"] = str(error)[:500] + + log_path = Path(settings.booking_audit_log_path) + log_path.parent.mkdir(parents=True, exist_ok=True) + + with _audit_lock: + with log_path.open("a", encoding="utf-8") as log_file: + log_file.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def _sign(payload: str) -> str: + return hmac.new( + settings.booking_captcha_secret.encode("utf-8"), + payload.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + +def _encode(value: str) -> str: + return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii").rstrip("=") + + +def _decode(value: str) -> str: + padding = "=" * (-len(value) % 4) + return base64.urlsafe_b64decode(f"{value}{padding}".encode("ascii")).decode("utf-8") + + +def _hash_value(value: str) -> str: + return hashlib.sha256(value.encode("utf-8")).hexdigest() + + +def _email_domain(email: str) -> str: + if "@" not in email: + return "" + return email.rsplit("@", 1)[1].lower()[:120] diff --git a/backend/app/services/mailer.py b/backend/app/services/mailer.py index 36c7708..286d36e 100644 --- a/backend/app/services/mailer.py +++ b/backend/app/services/mailer.py @@ -25,13 +25,14 @@ def _build_booking_html(payload: BookingRequestCreate) -> str:

È stata inviata una nuova richiesta di prenotazione non vincolante dal sito web.

+ - - + +
Nome e cognome{payload.name}
Email{payload.email}
Telefono{payload.phone}
Nome animale{payload.pet_name or "-"}
Tipo animale{payload.pet_type}
Medico richiesto{payload.doctor}
Tipo di visita{payload.service}
Data preferita{payload.date}
Orario preferito{payload.time or "Qualsiasi orario"}
Periodo preferito{payload.date}
Fascia oraria preferita{payload.time or "Qualsiasi orario"}
Note{payload.notes or "-"}
Inviata il{submitted_at}
@@ -49,13 +50,14 @@ def _build_booking_text(payload: BookingRequestCreate) -> str: return ( "Nuova richiesta di prenotazione visita\n\n" f"Nome e cognome: {payload.name}\n" + f"Email: {payload.email}\n" f"Telefono: {payload.phone}\n" f"Nome animale: {payload.pet_name or '-'}\n" f"Tipo animale: {payload.pet_type}\n" f"Medico richiesto: {payload.doctor}\n" f"Tipo di visita: {payload.service}\n" - f"Data preferita: {payload.date}\n" - f"Orario preferito: {payload.time or 'Qualsiasi orario'}\n" + f"Periodo preferito: {payload.date}\n" + f"Fascia oraria preferita: {payload.time or 'Qualsiasi orario'}\n" f"Note: {payload.notes or '-'}\n" f"Inviata il: {submitted_at}\n\n" "La richiesta non costituisce conferma automatica dell'appuntamento." @@ -70,6 +72,7 @@ def send_booking_request_email(payload: BookingRequestCreate) -> None: message["Subject"] = _build_booking_subject(payload) message["From"] = settings.booking_email_from message["To"] = settings.booking_email_to + message["Reply-To"] = payload.email message.set_content(_build_booking_text(payload)) message.add_alternative(_build_booking_html(payload), subtype="html") diff --git a/clinica-app/client/src/components/BookingSection.tsx b/clinica-app/client/src/components/BookingSection.tsx index 23f7d32..099e441 100644 --- a/clinica-app/client/src/components/BookingSection.tsx +++ b/clinica-app/client/src/components/BookingSection.tsx @@ -2,12 +2,13 @@ * DESIGN: "Clinical Warmth" * Sezione prenotazione: form to mail con conferma non vincolante. */ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { motion, useInView } from "framer-motion"; import { Calendar, CheckCircle2, Clock, + Mail, PawPrint, Phone, ShieldCheck, @@ -17,6 +18,8 @@ import { toast } from "sonner"; import { Button } from "@/components/ui/button"; +const bookingFormPaused = true; + const services = [ "Visita clinica generale", "Ecografia", @@ -45,29 +48,26 @@ const doctors = [ ]; const openingHours = [ - { days: "Visite: Lunedi - Venerdi", hours: "09:00 - 19:30" }, + { days: "Visite: Lunedì - Venerdì", hours: "09:00 - 19:30" }, { days: "Visite: Sabato", hours: "09:00 - 17:00" }, - { days: "Urgenze: Lunedi - Venerdi", hours: "08:00 - 22:30" }, + { days: "Urgenze: Lunedì - Venerdì", hours: "08:00 - 22:30" }, { days: "Urgenze: Sabato e Festivi", hours: "09:00 - 20:00" }, ]; -const timeSlots = [ - "09:00", - "09:30", - "10:00", - "10:30", - "11:00", - "11:30", - "14:30", - "15:00", - "15:30", - "16:00", - "16:30", - "17:00", +const preferredPeriods = [ + "Prossima settimana", + "Tra 2 settimane", + "Il mese prossimo", +]; + +const preferredTimeWindows = [ + "Mattino", + "Pomeriggio", ]; type BookingFormState = { name: string; + email: string; phone: string; petName: string; petType: string; @@ -76,10 +76,13 @@ type BookingFormState = { date: string; time: string; notes: string; + website: string; + captchaAnswer: string; }; const initialForm: BookingFormState = { name: "", + email: "", phone: "", petName: "", petType: "cane", @@ -88,6 +91,13 @@ const initialForm: BookingFormState = { date: "", time: "", notes: "", + website: "", + captchaAnswer: "", +}; + +type CaptchaChallenge = { + captcha_id: string; + question: string; }; export default function BookingSection() { @@ -97,15 +107,51 @@ export default function BookingSection() { const [submitting, setSubmitting] = useState(false); const [submittedName, setSubmittedName] = useState(""); const [form, setForm] = useState(initialForm); + const [captcha, setCaptcha] = useState(null); + const [captchaLoading, setCaptchaLoading] = useState(false); + + const loadCaptcha = async () => { + setCaptchaLoading(true); + + try { + const response = await fetch("/api/booking-captcha"); + + if (!response.ok) { + throw new Error("Verifica anti-spam non disponibile"); + } + + const payload = await response.json(); + setCaptcha(payload); + setForm((currentForm) => ({ ...currentForm, captchaAnswer: "" })); + } catch { + setCaptcha(null); + toast.error("Verifica anti-spam non disponibile", { + description: "Riprova tra qualche istante prima di inviare il modulo.", + }); + } finally { + setCaptchaLoading(false); + } + }; + + useEffect(() => { + if (!bookingFormPaused) { + loadCaptcha(); + } + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!form.name || !form.phone || !form.doctor || !form.service || !form.date) { + if (!form.name || !form.email || !form.phone || !form.doctor || !form.service || !form.date) { toast.error("Compila tutti i campi obbligatori"); return; } + if (!captcha || !form.captchaAnswer) { + toast.error("Completa la verifica anti-spam"); + return; + } + try { setSubmitting(true); @@ -116,6 +162,7 @@ export default function BookingSection() { }, body: JSON.stringify({ name: form.name, + email: form.email, phone: form.phone, pet_name: form.petName, pet_type: form.petType, @@ -124,6 +171,9 @@ export default function BookingSection() { date: form.date, time: form.time, notes: form.notes, + website: form.website, + captcha_id: captcha.captcha_id, + captcha_answer: form.captchaAnswer, }), }); @@ -137,10 +187,13 @@ export default function BookingSection() { setSubmittedName(form.name); setSubmitted(true); setForm(initialForm); + loadCaptcha(); toast.success("Richiesta inviata", { description: payload.message, }); } catch (error) { + loadCaptcha(); + const message = error instanceof Error ? error.message @@ -189,13 +242,13 @@ export default function BookingSection() {

Compila il modulo per inviare una richiesta di appuntamento. La richiesta - non e vincolante e dovra essere confermata dallo staff della clinica, che ti - ricontattera il prima possibile. + non è vincolante e dovrà essere confermata dallo staff della clinica, che ti + ricontatterà il prima possibile.

- Urgenze 24h + Urgenze

320 532.24.39 -

Disponibile 7 giorni su 7

@@ -216,8 +268,8 @@ export default function BookingSection() {

Richiesta non vincolante

L'invio del modulo non costituisce conferma automatica dell'appuntamento. - Il team verifichera disponibilita, tipologia di visita e urgenza del caso prima - di confermare data e orario. + Il team verificherà disponibilità, tipologia di visita e urgenza del caso prima + di confermare periodo e fascia oraria.

@@ -263,7 +315,29 @@ export default function BookingSection() { animate={isInView ? { opacity: 1, x: 0 } : {}} transition={{ duration: 0.8, delay: 0.2 }} > - {submitted ? ( + {bookingFormPaused ? ( +
+
+ +
+

+ Form in aggiornamento +

+

+ Il modulo di richiesta appuntamento è temporaneamente sospeso per manutenzione. + Per prenotazioni o informazioni contatta direttamente la clinica. +

+ + Chiama 320 532.24.39 + +
+ ) : submitted ? (
@@ -275,11 +349,11 @@ export default function BookingSection() { Grazie {submittedName || "per la tua richiesta"}

- La tua richiesta di prenotazione e stata inviata correttamente. + La tua richiesta di prenotazione è stata inviata correttamente.

- Il team della Clinica Veterinaria Formiginese la prendera in carico il prima - possibile e ti ricontattera per confermare disponibilita, data e orario. + Il team della Clinica Veterinaria Formiginese la prenderà in carico il prima + possibile e ti ricontatterà per confermare disponibilità, data e orario.

Nota importante: la richiesta inviata @@ -309,8 +383,8 @@ export default function BookingSection() { Richiedi un appuntamento

- Compila i campi richiesti e inviaci una proposta di data: sara lo staff a - confermare la visita. + Compila i campi richiesti e inviaci una preferenza di periodo: sarà lo staff a + ricontattarti per confermare la visita.

@@ -332,6 +406,23 @@ export default function BookingSection() {
+
+ +
+ + setForm({ ...form, email: e.target.value })} + className="w-full rounded-lg border border-gray-200 py-2.5 pl-9 pr-3 text-sm transition-all focus:border-transparent focus:outline-none focus:ring-2 focus:ring-[#4ECDC4]" + /> +
+
+