Files
TieMeasureFlow/src/backend/config.py
T
Adriano a2f357b3d3 fix(config): rifiuta i segreti di default fuori da dev/test (#33, parte A2)
Nuova impostazione app_env (env APP_ENV, default "dev"). All'istanza di
Settings, se app_env non è "dev"/"test" e SERVER_SECRET_KEY o
DB_PASSWORD sono ancora il valore committato, RuntimeError che nomina
la variabile. Dev e test continuano a partire senza .env: il default
"dev" non fa mai scattare la guardia.

Le parti frontend (flask_app/config.py) e deploy (docker-compose.yml)
di questa issue sono degli altri lotti (B2, C2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 22:41:28 +02:00

128 lines
4.8 KiB
Python

"""TieMeasureFlow Server Configuration."""
from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings
# Committed defaults. Fine for dev/test (SQLite in-memory, nothing exposed); a
# real deployment that still has these is running on secrets anyone can read
# in this file's git history.
_DEFAULT_SECRET_KEY = "change-this-to-a-random-secret-key"
_DEFAULT_DB_PASSWORD = "change_me_in_production"
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
# Deployment environment. "dev" and "test" skip the production-only guard
# below; anything else (e.g. "production") is treated as a real
# deployment. Local dev and the test suite must keep starting with no
# .env at all, which is why this defaults to "dev" rather than requiring
# the variable.
app_env: str = "dev"
# Database
db_host: str = "localhost"
db_port: int = 3306
db_name: str = "tiemeasureflow"
db_user: str = "tmflow"
db_password: str = _DEFAULT_DB_PASSWORD
# Server
server_host: str = "0.0.0.0"
server_port: int = 8000
server_secret_key: str = _DEFAULT_SECRET_KEY
server_cors_origins: str = "http://localhost:5000"
# Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives
# in process memory, so this is how RateLimitMiddleware divides the
# configured limits to keep the cluster-wide effective limit close to what
# was configured instead of multiplying it by N.
web_concurrency: int = 1
# How many reverse proxies between the client and this API are trusted to
# have appended their own hop to X-Forwarded-For (Nginx, in the default
# deploy). Used by RateLimitMiddleware._client_ip. 0 disables proxy header
# parsing entirely (only request.client.host is trusted).
trusted_proxy_count: int = 1
# File Storage
upload_dir: str = "uploads"
max_upload_size_mb: int = 50
# Rate Limiting (requests per minute, per real client IP)
rate_limit_login: int = 5
rate_limit_general: int = 300
# SSL (Production)
ssl_certfile: str | None = None
ssl_keyfile: str | None = None
# Setup page (empty = disabled)
setup_password: str | None = None
# By default, once an admin user exists, setup's privileged endpoints
# (reset-db, seed, manage-user, change-user-password, init-db) refuse to
# run again. Set true for scripted re-provisioning of a demo/test deploy.
setup_allow_after_init: bool = False
# AI / OpenRouter (for technical sheet parsing)
openrouter_api_key: str | None = None
openrouter_model: str = "anthropic/claude-sonnet-4"
# Vision worker (internal network only, reachable as `vision` in Compose)
vision_worker_url: str = "http://vision:8100"
@property
def database_url(self) -> str:
"""Async MySQL connection string."""
return (
f"mysql+asyncmy://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
)
@property
def cors_origins(self) -> list[str]:
"""Parse CORS origins from comma-separated string."""
return [origin.strip() for origin in self.server_cors_origins.split(",")]
@property
def upload_path(self) -> Path:
"""Absolute path to upload directory.
After the V2.0.0 restructure, uploads live at the project root
(mounted as a Docker volume), not inside the backend tree.
"""
# Path(__file__) = src/backend/config.py → parents[2] = project root
return Path(__file__).resolve().parents[2] / self.upload_dir
# Always resolve .env against the project root regardless of cwd
# (pydantic-settings would otherwise treat the path as cwd-relative).
model_config = {
"env_file": str(Path(__file__).resolve().parents[2] / ".env"),
"env_file_encoding": "utf-8",
"extra": "ignore",
}
@model_validator(mode="after")
def _refuse_default_secrets_outside_dev(self) -> "Settings":
"""Refuse to boot with a committed default secret in a real deployment.
Only fires when APP_ENV names an actual deployment (not "dev"/"test"),
so local development and the test suite keep starting with no .env at
all.
"""
if self.app_env in ("dev", "test"):
return self
if self.server_secret_key == _DEFAULT_SECRET_KEY:
raise RuntimeError(
"SERVER_SECRET_KEY is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
if self.db_password == _DEFAULT_DB_PASSWORD:
raise RuntimeError(
"DB_PASSWORD is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
return self
settings = Settings()