72f0cb1f1b
1. api_client.py: ConnectionError/Timeout restituisce status_code=502 invece
di 0 (nessuna route confrontava == 0, verificato); i proxy che inoltrano
resp.get("status_code", 500) ereditano il fix senza altre modifiche.
4. measure.py lookup_barcode: code non stringa -> 400 (prima 500 su
code.strip()); urllib.parse.quote(code, safe="") prima di comporre l'URL
verso il backend - un barcode con '/' o '../' non spezza più il path né
raggiunge altri GET con i permessi dell'utente.
5. Ogni "'{{ _(...) }}'" dentro un attributo Alpine (x-text, :title,
:placeholder, @click) sostituito con "{{ _(...)|tojson_attr }}": la
traduzione EN di admin/stations.html:105 ("Clear the station's recipes")
ha un apostrofo che l'IT non ha, e rompeva quel binding e basta - non è
un XSS, ma stessa classe di difetto di #10. File toccati: admin/stations,
admin/users, maker/recipe_list, maker/recipe_editor, maker/task_editor,
maker/task_drawing, components/caliper_status, measure/select_recipe.
Le occorrenze dentro <script>...</script> (non attributi) sono fuori
perimetro per questo punto.
6. statistics.py dashboard(): ciclo sulle pagine (per_page=100, il massimo
del backend) invece di un'unica chiamata che troncava la tendina ricette
alla centesima.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
198 lines
7.3 KiB
Python
198 lines
7.3 KiB
Python
"""API Client - wrapper for HTTP requests to FastAPI server."""
|
|
from typing import Any
|
|
|
|
import requests
|
|
from flask import g, has_request_context, request, session
|
|
|
|
from config import Config
|
|
|
|
|
|
class APIClient:
|
|
"""HTTP client for TieMeasureFlow API server."""
|
|
|
|
def __init__(self):
|
|
self.base_url = Config.API_SERVER_URL.rstrip("/")
|
|
self.timeout = 30
|
|
|
|
@staticmethod
|
|
def _real_client_ip() -> str | None:
|
|
"""Best-effort tablet IP for downstream rate limiting.
|
|
|
|
Behind Nginx + ProxyFix, ``request.remote_addr`` already resolves to
|
|
the tablet IP. Outside a request context (background tasks, tests),
|
|
return None so we don't forge a bogus header.
|
|
"""
|
|
if not has_request_context():
|
|
return None
|
|
return request.remote_addr
|
|
|
|
@property
|
|
def _headers(self) -> dict[str, str]:
|
|
"""Build request headers with API key + real client IP from session."""
|
|
headers = {"Content-Type": "application/json"}
|
|
api_key = session.get("api_key")
|
|
if api_key:
|
|
headers["X-API-Key"] = api_key
|
|
client_ip = self._real_client_ip()
|
|
if client_ip:
|
|
headers["X-Forwarded-For"] = client_ip
|
|
headers["X-Real-IP"] = client_ip
|
|
return headers
|
|
|
|
@staticmethod
|
|
def _handle_session_expired() -> None:
|
|
"""The backend says our api_key is no longer valid (#25).
|
|
|
|
One point for the whole app: whichever route made the call, the
|
|
Flask session is emptied here so nothing downstream keeps acting as
|
|
that user. A flag is left on ``g`` for the single after_request
|
|
hook in app.py to turn into a redirect-to-login on HTML pages; JSON
|
|
callers already get ``session_expired`` in the body returned below.
|
|
"""
|
|
if not has_request_context():
|
|
return
|
|
session.clear()
|
|
g.session_expired = True
|
|
|
|
def _handle_response(self, response: requests.Response) -> dict[str, Any]:
|
|
"""Parse response and return normalized dict.
|
|
|
|
Returns:
|
|
- If 2xx and not 204: response JSON
|
|
- If 204 No Content: empty dict
|
|
- If 4xx/5xx: {"error": True, "status_code": ..., "detail": "..."}
|
|
(401 also carries "session_expired": True)
|
|
"""
|
|
if response.ok:
|
|
if response.status_code == 204:
|
|
return {}
|
|
return response.json()
|
|
|
|
if response.status_code == 401:
|
|
self._handle_session_expired()
|
|
|
|
# Non-OK response (4xx/5xx)
|
|
try:
|
|
error_body = response.json()
|
|
detail = error_body.get("detail", str(error_body))
|
|
# FastAPI 422 returns detail as a list of validation errors:
|
|
# [{"type": ..., "loc": ["body", "password"], "msg": "...", ...}, ...]
|
|
if isinstance(detail, list):
|
|
parts = []
|
|
for e in detail:
|
|
if not isinstance(e, dict):
|
|
parts.append(str(e))
|
|
continue
|
|
msg = e.get("msg", str(e))
|
|
loc = e.get("loc") or []
|
|
# Strip leading "body"/"query"/"path" segments; keep field path.
|
|
field_path = [str(p) for p in loc if p not in ("body", "query", "path")]
|
|
field = ".".join(field_path) if field_path else None
|
|
parts.append(f"{field}: {msg}" if field else msg)
|
|
detail = "; ".join(parts) or str(detail)
|
|
except Exception:
|
|
detail = response.text or f"HTTP {response.status_code}"
|
|
|
|
result = {
|
|
"error": True,
|
|
"status_code": response.status_code,
|
|
"detail": detail
|
|
}
|
|
if response.status_code == 401:
|
|
result["session_expired"] = True
|
|
return result
|
|
|
|
def get(self, endpoint: str, params: dict | None = None) -> dict[str, Any]:
|
|
"""GET request to API server."""
|
|
try:
|
|
response = requests.get(
|
|
f"{self.base_url}{endpoint}",
|
|
headers=self._headers,
|
|
params=params,
|
|
timeout=self.timeout,
|
|
)
|
|
return self._handle_response(response)
|
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
# 502, not 0 (#37.1): a route that forwards status_code straight to
|
|
# jsonify(...), status_code was answering with a made-up HTTP status
|
|
# that Werkzeug doesn't know ("0 UNKNOWN"), instead of a real one a
|
|
# fetch caller can branch on.
|
|
return {
|
|
"error": True,
|
|
"status_code": 502,
|
|
"detail": f"Errore di connessione al server: {str(e)}"
|
|
}
|
|
|
|
def post(self, endpoint: str, data: dict | None = None, files: dict | None = None) -> dict[str, Any]:
|
|
"""POST request to API server."""
|
|
try:
|
|
if files:
|
|
headers = {"X-API-Key": session.get("api_key", "")}
|
|
client_ip = self._real_client_ip()
|
|
if client_ip:
|
|
headers["X-Forwarded-For"] = client_ip
|
|
headers["X-Real-IP"] = client_ip
|
|
response = requests.post(
|
|
f"{self.base_url}{endpoint}",
|
|
headers=headers,
|
|
data=data,
|
|
files=files,
|
|
timeout=self.timeout,
|
|
)
|
|
else:
|
|
response = requests.post(
|
|
f"{self.base_url}{endpoint}",
|
|
headers=self._headers,
|
|
json=data,
|
|
timeout=self.timeout,
|
|
)
|
|
return self._handle_response(response)
|
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
return {
|
|
"error": True,
|
|
"status_code": 502,
|
|
"detail": f"Errore di connessione al server: {str(e)}"
|
|
}
|
|
|
|
def put(self, endpoint: str, data: dict | None = None) -> dict[str, Any]:
|
|
"""PUT request to API server."""
|
|
try:
|
|
response = requests.put(
|
|
f"{self.base_url}{endpoint}",
|
|
headers=self._headers,
|
|
json=data,
|
|
timeout=self.timeout,
|
|
)
|
|
return self._handle_response(response)
|
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
return {
|
|
"error": True,
|
|
"status_code": 502,
|
|
"detail": f"Errore di connessione al server: {str(e)}"
|
|
}
|
|
|
|
def delete(self, endpoint: str) -> dict[str, Any]:
|
|
"""DELETE request to API server."""
|
|
try:
|
|
response = requests.delete(
|
|
f"{self.base_url}{endpoint}",
|
|
headers=self._headers,
|
|
timeout=self.timeout,
|
|
)
|
|
return self._handle_response(response)
|
|
except (requests.ConnectionError, requests.Timeout) as e:
|
|
return {
|
|
"error": True,
|
|
"status_code": 502,
|
|
"detail": f"Errore di connessione al server: {str(e)}"
|
|
}
|
|
|
|
# --- Domain helpers ---
|
|
|
|
def get_station_recipes(self, station_code: str) -> dict[str, Any]:
|
|
"""Return the list of active recipes assigned to the given station."""
|
|
return self.get(f"/api/stations/by-code/{station_code}/recipes")
|
|
|
|
|
|
api_client = APIClient()
|