Disattiva temporaneamente form prenotazioni

This commit is contained in:
2026-08-21 19:02:16 +02:00
parent d9b617e706
commit 04fb49ad4b
6 changed files with 444 additions and 47 deletions

View File

@@ -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.",
)

View File

@@ -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:

View File

@@ -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

View File

@@ -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]

View File

@@ -25,13 +25,14 @@ def _build_booking_html(payload: BookingRequestCreate) -> str:
<p>È stata inviata una nuova richiesta di prenotazione non vincolante dal sito web.</p>
<table cellpadding="8" cellspacing="0" border="0" style="border-collapse: collapse;">
<tr><td><strong>Nome e cognome</strong></td><td>{payload.name}</td></tr>
<tr><td><strong>Email</strong></td><td>{payload.email}</td></tr>
<tr><td><strong>Telefono</strong></td><td>{payload.phone}</td></tr>
<tr><td><strong>Nome animale</strong></td><td>{payload.pet_name or "-"}</td></tr>
<tr><td><strong>Tipo animale</strong></td><td>{payload.pet_type}</td></tr>
<tr><td><strong>Medico richiesto</strong></td><td>{payload.doctor}</td></tr>
<tr><td><strong>Tipo di visita</strong></td><td>{payload.service}</td></tr>
<tr><td><strong>Data preferita</strong></td><td>{payload.date}</td></tr>
<tr><td><strong>Orario preferito</strong></td><td>{payload.time or "Qualsiasi orario"}</td></tr>
<tr><td><strong>Periodo preferito</strong></td><td>{payload.date}</td></tr>
<tr><td><strong>Fascia oraria preferita</strong></td><td>{payload.time or "Qualsiasi orario"}</td></tr>
<tr><td><strong>Note</strong></td><td>{payload.notes or "-"}</td></tr>
<tr><td><strong>Inviata il</strong></td><td>{submitted_at}</td></tr>
</table>
@@ -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")