2026-04-25 20:43:37 +12:00
|
|
|
import os
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
|
|
|
|
|
2026-04-27 21:53:36 +12:00
|
|
|
DEFAULT_CORS_ALLOW_ORIGIN_REGEX = (
|
|
|
|
|
r"^https?://("
|
|
|
|
|
r"localhost|127\.0\.0\.1|"
|
|
|
|
|
r"10\.\d{1,3}\.\d{1,3}\.\d{1,3}|"
|
|
|
|
|
r"192\.168\.\d{1,3}\.\d{1,3}|"
|
|
|
|
|
r"172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}"
|
|
|
|
|
r")(:\d+)?$"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _parse_csv_env(value: str) -> tuple[str, ...]:
|
|
|
|
|
return tuple(part.strip() for part in value.split(",") if part.strip())
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 09:46:07 +12:00
|
|
|
def _env_flag(name: str, default: bool = False) -> bool:
|
|
|
|
|
value = os.getenv(name)
|
|
|
|
|
if value is None:
|
|
|
|
|
return default
|
|
|
|
|
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
|
|
|
|
|
|
|
|
|
2026-04-25 20:43:37 +12:00
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
class Settings:
|
|
|
|
|
app_name: str
|
2026-05-10 09:46:07 +12:00
|
|
|
app_env: str
|
|
|
|
|
host: str
|
|
|
|
|
port: int
|
|
|
|
|
log_level: str
|
|
|
|
|
log_verbose: bool
|
2026-04-25 20:43:37 +12:00
|
|
|
database_url: str
|
2026-04-25 22:51:36 +12:00
|
|
|
client_name: str
|
|
|
|
|
client_email: str
|
|
|
|
|
client_password: str
|
|
|
|
|
client_tenant_id: str
|
|
|
|
|
admin_name: str
|
|
|
|
|
admin_email: str
|
|
|
|
|
admin_password: str
|
|
|
|
|
auth_secret: str
|
2026-04-27 21:53:36 +12:00
|
|
|
cors_allow_origins: tuple[str, ...]
|
|
|
|
|
cors_allow_origin_regex: str
|
2026-05-10 09:46:07 +12:00
|
|
|
session_ttl_seconds: int
|
|
|
|
|
session_cookie_name: str
|
|
|
|
|
admin_session_cookie_name: str
|
|
|
|
|
session_cookie_secure: bool
|
|
|
|
|
session_cookie_samesite: str
|
|
|
|
|
session_cookie_domain: str | None
|
|
|
|
|
request_body_max_bytes: int
|
|
|
|
|
login_rate_limit_attempts: int
|
|
|
|
|
login_rate_limit_window_seconds: int
|
|
|
|
|
trusted_hosts: tuple[str, ...]
|
|
|
|
|
docs_enabled: bool
|
2026-04-25 20:43:37 +12:00
|
|
|
|
|
|
|
|
@classmethod
|
|
|
|
|
def from_env(cls) -> "Settings":
|
2026-05-10 09:46:07 +12:00
|
|
|
settings = cls(
|
2026-04-25 20:43:37 +12:00
|
|
|
app_name=os.getenv("APP_NAME", "Data Entry App API"),
|
2026-05-10 09:46:07 +12:00
|
|
|
app_env=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")),
|
|
|
|
|
host=os.getenv("HOST", "0.0.0.0"),
|
|
|
|
|
port=int(os.getenv("PORT", "8000")),
|
|
|
|
|
log_level=os.getenv("LOG_LEVEL", "DEBUG" if os.getenv("LOG_VERBOSE") in {"1", "true", "TRUE", "yes", "on"} else "INFO"),
|
|
|
|
|
log_verbose=_env_flag("LOG_VERBOSE"),
|
2026-04-25 20:43:37 +12:00
|
|
|
database_url=os.getenv("DATABASE_URL", "sqlite:///./data_entry_app.db"),
|
2026-04-25 22:51:36 +12:00
|
|
|
client_name=os.getenv("CLIENT_NAME", "Hunter Premium Produce"),
|
|
|
|
|
client_email=os.getenv("CLIENT_EMAIL", "operator@example.com"),
|
|
|
|
|
client_password=os.getenv("CLIENT_PASSWORD", "changeme"),
|
|
|
|
|
client_tenant_id=os.getenv("CLIENT_TENANT_ID", "hunter-premium-produce"),
|
|
|
|
|
admin_name=os.getenv("ADMIN_NAME", "Lean 101"),
|
|
|
|
|
admin_email=os.getenv("ADMIN_EMAIL", "admin@lean101.local"),
|
|
|
|
|
admin_password=os.getenv("ADMIN_PASSWORD", "lean101-admin"),
|
|
|
|
|
auth_secret=os.getenv("AUTH_SECRET", "lean-101-local-dev-secret"),
|
2026-04-27 21:53:36 +12:00
|
|
|
cors_allow_origins=_parse_csv_env(
|
|
|
|
|
os.getenv(
|
|
|
|
|
"CORS_ALLOW_ORIGINS",
|
|
|
|
|
"http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174",
|
|
|
|
|
)
|
|
|
|
|
),
|
|
|
|
|
cors_allow_origin_regex=os.getenv("CORS_ALLOW_ORIGIN_REGEX", DEFAULT_CORS_ALLOW_ORIGIN_REGEX),
|
2026-05-10 09:46:07 +12:00
|
|
|
session_ttl_seconds=int(os.getenv("SESSION_TTL_SECONDS", str(60 * 60 * 12))),
|
|
|
|
|
session_cookie_name=os.getenv("SESSION_COOKIE_NAME", "client_session"),
|
|
|
|
|
admin_session_cookie_name=os.getenv("ADMIN_SESSION_COOKIE_NAME", "admin_session"),
|
|
|
|
|
session_cookie_secure=_env_flag("SESSION_COOKIE_SECURE"),
|
|
|
|
|
session_cookie_samesite=os.getenv("SESSION_COOKIE_SAMESITE", "lax").lower(),
|
|
|
|
|
session_cookie_domain=os.getenv("SESSION_COOKIE_DOMAIN", "").strip() or None,
|
|
|
|
|
request_body_max_bytes=int(os.getenv("REQUEST_BODY_MAX_BYTES", str(1024 * 1024))),
|
|
|
|
|
login_rate_limit_attempts=int(os.getenv("LOGIN_RATE_LIMIT_ATTEMPTS", "8")),
|
|
|
|
|
login_rate_limit_window_seconds=int(os.getenv("LOGIN_RATE_LIMIT_WINDOW_SECONDS", "300")),
|
|
|
|
|
trusted_hosts=_parse_csv_env(os.getenv("TRUSTED_HOSTS", "localhost,127.0.0.1,testserver")),
|
|
|
|
|
docs_enabled=_env_flag("DOCS_ENABLED", default=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() != "production"),
|
2026-04-25 20:43:37 +12:00
|
|
|
)
|
2026-05-10 09:46:07 +12:00
|
|
|
settings._validate()
|
|
|
|
|
return settings
|
|
|
|
|
|
|
|
|
|
def _validate(self) -> None:
|
|
|
|
|
if self.session_cookie_samesite not in {"lax", "strict", "none"}:
|
|
|
|
|
raise ValueError("SESSION_COOKIE_SAMESITE must be one of: lax, strict, none")
|
|
|
|
|
|
|
|
|
|
is_production = self.app_env.lower() == "production"
|
|
|
|
|
if not is_production:
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
if self.client_password in {"changeme", "", "replace-with-strong-password"}:
|
|
|
|
|
raise ValueError("CLIENT_PASSWORD must be set to a non-default value in production")
|
|
|
|
|
if self.admin_password in {"lean101-admin", "", "replace-with-strong-password"}:
|
|
|
|
|
raise ValueError("ADMIN_PASSWORD must be set to a non-default value in production")
|
|
|
|
|
if self.auth_secret in {"lean-101-local-dev-secret", "change-me-in-production", "", "replace-with-a-long-random-secret"}:
|
|
|
|
|
raise ValueError("AUTH_SECRET must be set to a strong production secret")
|
|
|
|
|
if len(self.auth_secret) < 32:
|
|
|
|
|
raise ValueError("AUTH_SECRET must be at least 32 characters in production")
|
|
|
|
|
if not self.session_cookie_secure:
|
|
|
|
|
raise ValueError("SESSION_COOKIE_SECURE must be enabled in production")
|
|
|
|
|
if not self.cors_allow_origins:
|
|
|
|
|
raise ValueError("CORS_ALLOW_ORIGINS must explicitly list production origins")
|
|
|
|
|
if "localhost" in ",".join(self.cors_allow_origins).lower():
|
|
|
|
|
raise ValueError("CORS_ALLOW_ORIGINS cannot include localhost in production")
|
|
|
|
|
if self.cors_allow_origin_regex == DEFAULT_CORS_ALLOW_ORIGIN_REGEX:
|
|
|
|
|
raise ValueError("CORS_ALLOW_ORIGIN_REGEX must be overridden or blank in production")
|
|
|
|
|
if self.docs_enabled:
|
|
|
|
|
raise ValueError("DOCS_ENABLED must be false in production")
|
2026-04-25 20:43:37 +12:00
|
|
|
|
|
|
|
|
|
|
|
|
|
settings = Settings.from_env()
|