"""API JSON pour les événements de présence Home Assistant.""" from __future__ import annotations import hmac import os from collections import defaultdict from threading import Lock from time import monotonic from typing import Any from flask import Blueprint, Response, current_app, jsonify, request from app import db from app.business.presence_service import ( ArrivalAlreadyOpenError, DepartureWithoutArrivalError, IdempotencyConflictError, InvalidPresenceEventError, record_presence_event, ) bp = Blueprint("api", __name__, url_prefix="/api/v1") _MAX_BODY_BYTES = 4096 _RATE_LIMIT = 30 _RATE_WINDOW_SECONDS = 60.0 _rate_lock = Lock() _rate_requests: defaultdict[str, list[float]] = defaultdict(list) def reset_rate_limiter() -> None: """Réinitialise la protection mémoire, notamment pour les tests.""" with _rate_lock: _rate_requests.clear() def _response(payload: dict[str, Any], status: int) -> Response: response = jsonify(payload) response.status_code = status response.headers["Cache-Control"] = "no-store" return response def _error(status: int, message: str) -> Response: return _response({"error": message}, status) def _authenticated() -> tuple[str | None, Response | None]: configured_token = os.environ.get("WORKLOG_API_TOKEN") if not configured_token: return None, _error(503, "API indisponible") authorization = request.headers.get("Authorization", "") scheme, separator, supplied_token = authorization.partition(" ") if scheme.lower() != "bearer" or not separator or not supplied_token: return None, _error(401, "Authentification requise") if not hmac.compare_digest(supplied_token, configured_token): return None, _error(401, "Authentification requise") return supplied_token, None def _rate_allowed(token: str) -> bool: # Cette limite est par processus ; HAProxy ou une solution partagée est nécessaire # pour garantir la limite à l'échelle de plusieurs workers. now = monotonic() with _rate_lock: requests_for_token = _rate_requests[token] requests_for_token[:] = [ timestamp for timestamp in requests_for_token if now - timestamp < _RATE_WINDOW_SECONDS ] if len(requests_for_token) >= _RATE_LIMIT: return False requests_for_token.append(now) return True @bp.route("/workplace-presence", methods=["POST"]) def workplace_presence() -> Response: """Réceptionne un événement d'arrivée ou de départ authentifié.""" if current_app.config.get("HOME_ASSISTANT") is None: return _error(404, "API indisponible") token, authentication_error = _authenticated() if authentication_error is not None: return authentication_error assert token is not None if not _rate_allowed(token): return _error(429, "Trop de requêtes") if request.content_length is not None and request.content_length > _MAX_BODY_BYTES: return _error(413, "Requête trop volumineuse") raw_body = request.get_data(cache=True) if len(raw_body) > _MAX_BODY_BYTES: return _error(413, "Requête trop volumineuse") if request.mimetype != "application/json": return _error(415, "Content-Type invalide") try: payload = request.get_json(silent=False) except Exception: return _error(400, "JSON invalide") if not isinstance(payload, dict): return _error(422, "Schéma invalide") allowed_keys = {"event", "occurred_at", "idempotency_key"} if ( not set(payload).issubset(allowed_keys) or "event" not in payload or "occurred_at" not in payload ): return _error(422, "Schéma invalide") event = payload["event"] occurred_at = payload["occurred_at"] body_key = payload.get("idempotency_key") header_key = request.headers.get("X-Idempotency-Key") if body_key is not None and (not isinstance(body_key, str) or not body_key.strip()): return _error(422, "Schéma invalide") if header_key is not None and not header_key.strip(): return _error(422, "Schéma invalide") if body_key is not None and header_key is not None and body_key != header_key: return _error(422, "Schéma invalide") idempotency_key = body_key if body_key is not None else header_key if not all( isinstance(value, str) and value.strip() for value in (event, occurred_at, idempotency_key) ): return _error(422, "Schéma invalide") try: result = record_presence_event( db.session, current_app.config["HOME_ASSISTANT"], event, occurred_at, idempotency_key, ) db.session.commit() except InvalidPresenceEventError: db.session.rollback() return _error(422, "Événement invalide") except (ArrivalAlreadyOpenError, DepartureWithoutArrivalError, IdempotencyConflictError): db.session.rollback() return _error(409, "Conflit métier") except Exception: db.session.rollback() return _error(500, "Erreur interne") status = "replayed" if result.replayed else "created" http_status = 200 if result.replayed or event == "departure" else 201 return _response( { "status": status, "event_id": result.event_id, "entry_id": result.entry_id, "time_slot_id": result.time_slot_id, "event_type": event, }, http_status, ) @bp.app_errorhandler(405) def method_not_allowed(error): if request.path.startswith("/api/v1/"): return _error(405, "Méthode non autorisée") return error