Files
2026-10-05 11:21:39 +05:30

375 lines
17 KiB
Python

"""
Centralized configuration for the Electronics Catalog backend.
Every credential is read ONLY from the environment (backend/.env via
python-dotenv, or real OS variables). Non-secret values keep safe local
defaults.
LOCAL-ONLY GUARD
----------------
This project is a copy of the grocery catalogue, whose .env files pointed at a
remote production database. To make it impossible to write electronics data
there by accident, settings refuse to load unless DB_HOST is a local host and
DB_NAME is the dedicated electronics database. See _guard_local_database().
The one exception is an explicit production opt-in (ELEC_ALLOW_REMOTE_DB plus
an exact host/database allowlist), used only by the production deployment.
"""
from __future__ import annotations
import os
from pathlib import Path
# Snapshotted BEFORE load_dotenv, and that ordering is the entire point.
# load_dotenv() is called without override=True, so a variable already in the
# process environment silently beats the .env file and keeps beating it no
# matter how many times the file is corrected. That is not hypothetical here:
# the deployment platform injects its Environment tab into the container, so a
# stale value left in that tab overrides the credentials baked into the image
# (backend/Dockerfile copies .env.production to /app/.env) and the only symptom
# is a 401 that nothing explains. Comparing a name against this set answers
# "which of the two won?" - see config_source() below.
_PREEXISTING_ENV = frozenset(os.environ)
try:
from dotenv import load_dotenv
# backend/.env (one level up from this file: app/infrastructure/settings.py)
_env_path = Path(__file__).resolve().parents[2] / ".env"
load_dotenv(_env_path)
except ImportError:
# python-dotenv not installed - fall back to whatever is already in the
# process environment (e.g. set by the shell, Docker, systemd, CI, etc.)
pass
# Names whose raw value arrived wrapped in quotes or padded with whitespace.
# Recorded rather than merely fixed: stripping keeps the login working, but the
# only place the original shape is still visible is right here, before the value
# is normalised. A quoted hash is the signature of a value pasted into a web
# form, so surfacing it at startup is what stops the next person rediscovering
# it from a 401. See DB_PASSWORD in .env.production for the counter-case where
# the quotes ARE part of the secret - which is why this warns, and does not fail.
_ENV_NEEDED_CLEANUP = set()
def _clean(name: str, raw: str) -> str:
"""Strip surrounding quotes/whitespace off an env value, remembering if it mattered."""
cleaned = raw.strip().strip("'\"")
if cleaned != raw:
_ENV_NEEDED_CLEANUP.add(name)
return cleaned
def cleaned_env_names() -> list:
"""Which settings needed quote/whitespace stripping. Reported at startup."""
return sorted(_ENV_NEEDED_CLEANUP)
def config_source(name: str) -> str:
"""
Where a setting's value actually came from: the process environment, the
.env file, or this module's own default.
Reported at startup for the AUTH_* values (see app/main.py) so that an
override arriving from outside the image is visible in the logs instead of
being inferred from a failing login.
"""
if name in _PREEXISTING_ENV:
return "process-env"
if name in os.environ:
return "env-file"
return "default"
def _bool(name: str, default: str) -> bool:
return os.getenv(name, default).strip().lower() in {"1", "true", "yes"}
def _require(name: str, *, feature_flag: str) -> str:
"""Read a required secret. Raises if missing and the owning feature is enabled."""
value = os.getenv(name)
if not value:
raise RuntimeError(
f"Missing required environment variable '{name}'. It is required because "
f"'{feature_flag}' is enabled. Set it in backend/.env (copy from "
f".env.example) or disable the feature by setting {feature_flag}=false."
)
return value
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
_BACKEND_ROOT = Path(__file__).resolve().parents[2]
DATA_DIR = Path(os.getenv("DATA_DIR", "").strip() or _BACKEND_ROOT / "data")
# ---------------------------------------------------------------------------
# Ollama (local LLM) - only ever used to read text we fetched, never to invent
# ---------------------------------------------------------------------------
USE_OLLAMA = _bool("USE_OLLAMA", "true")
OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434")
OLLAMA_MODEL_NAME = os.getenv("OLLAMA_MODEL_NAME", "qwen2.5:1.5b")
OLLAMA_TIMEOUT_SECONDS = int(os.getenv("OLLAMA_TIMEOUT_SECONDS", "120"))
# ---------------------------------------------------------------------------
# Embeddings (sentence-transformers, CPU-friendly)
# ---------------------------------------------------------------------------
USE_EMBEDDINGS = _bool("USE_EMBEDDINGS", "true")
EMBEDDINGS_MODEL = os.getenv("EMBEDDINGS_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
EMBEDDINGS_DIM = int(os.getenv("EMBEDDINGS_DIM", "384"))
# ---------------------------------------------------------------------------
# Postgres / pgvector - the LOCAL electronics database only
# ---------------------------------------------------------------------------
ELECTRONICS_DB_NAME = "electronics_catalog"
# The test suite uses its own database on the same local server.
ALLOWED_DB_NAMES = frozenset({ELECTRONICS_DB_NAME, ELECTRONICS_DB_NAME + "_test"})
LOCAL_DB_HOSTS = frozenset({"localhost", "127.0.0.1", "::1", "host.docker.internal", "postgres"})
DB_HOST = os.getenv("DB_HOST", "127.0.0.1").strip()
DB_PORT = os.getenv("DB_PORT", "5433").strip()
DB_NAME = os.getenv("DB_NAME", ELECTRONICS_DB_NAME).strip()
DB_USER = os.getenv("DB_USER", "postgres").strip()
DB_PASSWORD = _require("DB_PASSWORD", feature_flag="the electronics database")
DB_CONNECT_TIMEOUT_SECONDS = int(os.getenv("DB_CONNECT_TIMEOUT_SECONDS", "5"))
def _csv_set(name: str) -> frozenset:
return frozenset(v.strip() for v in os.getenv(name, "").split(",") if v.strip())
# Production opt-in. Off by default: without ELEC_ALLOW_REMOTE_DB=true the
# guard below behaves exactly as it always has. With it on, only the host(s)
# and database name(s) listed here are accepted - never "any remote host".
ELEC_ALLOW_REMOTE_DB = _bool("ELEC_ALLOW_REMOTE_DB", "false")
ELEC_REMOTE_DB_HOSTS = _csv_set("ELEC_REMOTE_DB_HOSTS")
ELEC_REMOTE_DB_NAMES = _csv_set("ELEC_REMOTE_DB_NAMES")
def _guard_local_database(host: str, name: str, *, allow_remote: bool = False,
remote_hosts: frozenset = frozenset(), remote_names: frozenset = frozenset()) -> None:
"""Refuse to run against anything but the local electronics database,
unless the production opt-in names this exact host and database."""
if allow_remote and host in remote_hosts:
if name not in remote_names:
raise RuntimeError(
f"DB_NAME={name!r} is not in ELEC_REMOTE_DB_NAMES for remote host {host!r}."
)
return
if host not in LOCAL_DB_HOSTS:
raise RuntimeError(
f"DB_HOST={host!r} is not a local host. This project only runs against the "
f"local Docker database (see docker-compose.yml); it must never touch the "
f"remote catalogue database."
)
if name not in ALLOWED_DB_NAMES:
raise RuntimeError(
f"DB_NAME={name!r}; expected {ELECTRONICS_DB_NAME!r}. The electronics data "
f"lives in its own database so existing databases are never modified."
)
_guard_local_database(DB_HOST, DB_NAME, allow_remote=ELEC_ALLOW_REMOTE_DB,
remote_hosts=ELEC_REMOTE_DB_HOSTS, remote_names=ELEC_REMOTE_DB_NAMES)
# ---------------------------------------------------------------------------
# Web search (discovery, prices, images)
# ---------------------------------------------------------------------------
# DuckDuckGo needs no key. Google Programmable Search is used in addition when
# both GOOGLE_API_KEY and GOOGLE_CSE_ID are set (100 free queries/day).
USE_DDG_SEARCH = _bool("USE_DDG_SEARCH", "true")
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY", "").strip()
GOOGLE_CSE_ID = os.getenv("GOOGLE_CSE_ID", "").strip()
USE_GOOGLE_CSE = bool(GOOGLE_API_KEY and GOOGLE_CSE_ID) and _bool("USE_GOOGLE_CSE", "true")
GOOGLE_CSE_DAILY_QUOTA = int(os.getenv("GOOGLE_CSE_DAILY_QUOTA", "100"))
SEARCH_REGION = os.getenv("SEARCH_REGION", "in-en")
# Minimum pause between two search queries to the same provider.
SEARCH_MIN_INTERVAL_SECONDS = float(os.getenv("SEARCH_MIN_INTERVAL_SECONDS", "2.5"))
SEARCH_CACHE_TTL_HOURS = int(os.getenv("SEARCH_CACHE_TTL_HOURS", "24"))
# ---------------------------------------------------------------------------
# Polite fetching of retailer / brand pages
# ---------------------------------------------------------------------------
# An honest User-Agent with a contact address. Set ELEC_CONTACT to a real
# address before running a crawl.
ELEC_CONTACT = os.getenv("ELEC_CONTACT", "admin@example.com").strip()
USER_AGENT = os.getenv(
"USER_AGENT", f"ElectronicsCatalogBot/0.1 (+mailto:{ELEC_CONTACT}; local research)"
)
REQUEST_TIMEOUT_SECONDS = int(os.getenv("REQUEST_TIMEOUT_SECONDS", "20"))
ELEC_SITE_MIN_INTERVAL_SECONDS = float(os.getenv("ELEC_SITE_MIN_INTERVAL_SECONDS", "3"))
ELEC_BREAKER_COOLDOWN_HOURS = float(os.getenv("ELEC_BREAKER_COOLDOWN_HOURS", "24"))
# Brand stores embed their reviews in the page: samsung.com/in pages run ~3.1 MB.
ELEC_MAX_PAGE_BYTES = int(os.getenv("ELEC_MAX_PAGE_BYTES", str(6 * 1024 * 1024)))
ELEC_PROBE_TTL_DAYS = int(os.getenv("ELEC_PROBE_TTL_DAYS", "7"))
MIN_IMAGE_BYTES = int(os.getenv("MIN_IMAGE_BYTES", "3000"))
# Reference pincodes (Tamil Nadu). "pincode:City" pairs, comma-separated. The
# first one is the default. A pincode is stored against a price only when the
# site actually accepted it.
ELEC_REFERENCE_PINCODES = [
tuple(p.split(":", 1)) if ":" in p else (p, "")
for p in (x.strip() for x in os.getenv("ELEC_REFERENCE_PINCODES", "641001:Coimbatore,600001:Chennai").split(","))
if p
]
# The LLM may only fill spec gaps from text we fetched; set false to run fully
# deterministic.
ELEC_USE_LLM = _bool("ELEC_USE_LLM", "true")
# ---------------------------------------------------------------------------
# FastAPI / web server
# ---------------------------------------------------------------------------
API_CORS_ORIGINS = [
origin.strip()
for origin in os.getenv("API_CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173").split(",")
if origin.strip()
]
# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
# CORS above is not access control - browsers enforce it, and curl ignores it
# entirely. These settings are what actually guards the write/compute endpoints
# (catalog generation, ML training, uploads, chat).
#
# AUTH_ENABLED=false turns every guard off, restoring the old behaviour where
# any caller could reach any endpoint. It exists so a fresh checkout still runs
# without generating secrets first; app/infrastructure/security.py logs a
# warning at import when it is off. Never deploy with it off.
AUTH_ENABLED = _bool("AUTH_ENABLED", "true")
# Signs and verifies access tokens. Changing it invalidates every issued token,
# which is the intended way to force everyone to sign in again. Generate with:
# python scripts/make_auth_secrets.py
AUTH_SECRET_KEY = (
_require("AUTH_SECRET_KEY", feature_flag="AUTH_ENABLED")
if AUTH_ENABLED
else os.getenv("AUTH_SECRET_KEY", "")
)
# How long an issued token stays valid. 12h by default: long enough that a
# working day needs one sign-in, short enough that a leaked token expires.
AUTH_TOKEN_TTL_MINUTES = int(os.getenv("AUTH_TOKEN_TTL_MINUTES", "720"))
# The interactive accounts. Only PBKDF2 digests are stored - never a password.
# `make_auth_secrets.py` prints the lines ready to paste.
#
# `admin` is required whenever auth is on: without it nobody could sign in.
AUTH_ADMIN_USERNAME = _clean(
"AUTH_ADMIN_USERNAME", os.getenv("AUTH_ADMIN_USERNAME", "admin")
)
AUTH_ADMIN_PASSWORD_HASH = _clean(
"AUTH_ADMIN_PASSWORD_HASH",
(
_require("AUTH_ADMIN_PASSWORD_HASH", feature_flag="AUTH_ENABLED")
if AUTH_ENABLED
else os.getenv("AUTH_ADMIN_PASSWORD_HASH", "")
),
)
# The second `user` account is OPTIONAL, and left unset in this deployment.
# An empty hash is how the account is switched off: auth.py builds its account
# table from these values and omits any entry whose hash is blank, so there is
# nothing to sign in to. Setting the hash again re-enables it with no code
# change - which is exactly what the test suite does in tests/conftest.py.
AUTH_USER_USERNAME = _clean(
"AUTH_USER_USERNAME", os.getenv("AUTH_USER_USERNAME", "user")
)
AUTH_USER_PASSWORD_HASH = _clean(
"AUTH_USER_PASSWORD_HASH", os.getenv("AUTH_USER_PASSWORD_HASH", "")
)
# Failed-login throttle, applied per username+client-IP. Prevents an exposed
# login endpoint from being a free password oracle.
AUTH_MAX_LOGIN_ATTEMPTS = int(os.getenv("AUTH_MAX_LOGIN_ATTEMPTS", "10"))
AUTH_LOCKOUT_SECONDS = int(os.getenv("AUTH_LOCKOUT_SECONDS", "300"))
# Local-development escape hatch: accept ANY password at /api/auth/login, so a
# developer who does not have the configured passwords to hand can still reach
# the admin and user pages. The username still selects the role, and the token
# issued is a normal signed one - so every downstream guard, /api/auth/me, and
# the React route gating all behave exactly as they do in production. What is
# skipped is only the password check.
#
# This is NOT the same as AUTH_ENABLED=false. That disables every guard *and*
# makes /api/auth/login return 503, which breaks the login page outright. This
# flag keeps the whole auth machinery running and unlocks just the front door.
#
# Anyone who can reach the API can sign in as admin while it is on. Keep it
# false anywhere the port is reachable by someone you would not hand the admin
# password to.
AUTH_ALLOW_ANY_LOGIN = _bool("AUTH_ALLOW_ANY_LOGIN", "false")
# Shortest acceptable API key secret. token_urlsafe(32) yields 43 characters, so
# this rejects hand-typed values without rejecting anything the documented
# generator produces.
API_KEY_MIN_LENGTH = 32
def _parse_api_keys(raw: str) -> dict:
"""
Parse ``API_KEYS`` - ``name:role:secret`` triples, comma-separated.
Keyed by secret because that is what an inbound request presents. One entry
per consumer is the point: a shared key cannot be revoked for one caller
without breaking all of them.
Secrets must be at least API_KEY_MIN_LENGTH characters. That is not about
guessing the key over the network - the lockout and the network itself make
online brute force impractical - but about what /api/health publishes. It
reports a truncated digest of every configured key so a deployment can be
checked against the config it was built from, and a digest of a *raw* secret
is only safe when the secret is unguessable offline. An admin password hash
embeds a random salt, so its fingerprint discloses nothing; an API key has no
salt, and a hand-picked "changeme" would fall to a wordlist in seconds.
Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"
"""
parsed: dict = {}
for entry in raw.split(","):
entry = entry.strip()
if not entry:
continue
parts = entry.split(":")
if len(parts) != 3:
raise RuntimeError(
f"Malformed API_KEYS entry {entry!r}. Expected 'name:role:secret', "
f"comma-separated between entries."
)
name, role, secret = (p.strip() for p in parts)
# MUST stay in step with ROLE_PERMISSIONS in app/infrastructure/security.py,
# which is the source of truth. It is duplicated rather than imported
# because security.py imports THIS module, so importing it back here
# would be a cycle. A role added there but not here is rejected at boot
# with the message below - loud, and before any request is served.
if role not in {"admin", "user", "uploader"}:
raise RuntimeError(
f"API_KEYS entry {name!r} has role {role!r}; expected 'admin', 'user' "
f"or 'uploader'."
)
if not secret:
raise RuntimeError(f"API_KEYS entry {name!r} has an empty secret.")
if len(secret) < API_KEY_MIN_LENGTH:
raise RuntimeError(
f"API_KEYS entry {name!r} has a {len(secret)}-character secret; at least "
f"{API_KEY_MIN_LENGTH} are required, because /api/health publishes a digest "
f"of it. Generate one with: "
f"python -c \"import secrets; print(secrets.token_urlsafe(32))\""
)
parsed[secret] = (name, role)
return parsed
# Machine consumers of api.<domain>. Empty by default - browser sessions go
# through /api/auth/login instead, and a key that nobody needs is only risk.
#
# NAME THE KEY FOR ITS FUNCTION, NOT THE PERSON HOLDING IT.
# /api/health is public and reports {name, role, fingerprint} for every
# configured key (describe_api_keys in security.py). The secret is never
# exposed, but the NAME is - so `catalog-drop:uploader:...` is right and
# `priya-laptop:uploader:...` publishes a colleague's name to anyone who
# curls the health endpoint.
API_KEYS = _parse_api_keys(os.getenv("API_KEYS", ""))