Files
UmbraTrans/umbratrans_web/config.py
umbra audio 3e0568b9d1 Generate backend endpoints for language-project workflow
Add REST endpoints and frontend wiring for per-language project actions:
- POST /api/workspace/{project}/translation/{lang}/queue — queue translation
- POST /api/workspace/{project}/audio/{lang}/queue — queue audio generation
- GET/POST /api/workspace/{project}/format/{lang} — format metadata (EPUB/PDF)
- GET /api/workspace/{project}/review/{lang}/chapters/{chapter} — utterance review
- GET/POST /api/workspace/{project}/front-matter/{lang} — front matter editor
- GET /api/workspace/{project}/chapters/{lang} — chapter list
- GET /api/workspace/{project}/chapter-content/{lang}/{chapter} — chapter text

Frontend fixes:
- Review page shows per-language text (English source, Chinese translation, etc.)
- Non-translated languages show 'Queue translation' button only
- Speaker data from English source matched by paragraph ID
- UmbraAudio column layout: Speaker, Variation, Text, Actions
- Chapter tree in left sidebar: Front matter, Back matter, Body
- Audio Format label at top of sidebar
- Pandoc formatting toolbar for front matter text editing
2026-10-25 11:04:53 -07:00

443 lines
19 KiB
Python

# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
"""Process configuration for the UmbraTrans web slice.
Configuration is fail-closed:
* Production requires ``Secure`` cookies, CSRF enforcement, and a repository
adapter other than the local development stores.
* The local JSON development repository is refused outright in production.
* Wildcard host allow-lists and short token peppers are rejected.
* The production PostgreSQL adapter is not part of this slice, so requesting
production mode raises instead of silently starting an insecure process.
The token pepper is excluded from ``repr`` and is never logged.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass, field, replace
from enum import StrEnum
from pathlib import Path
from .errors import ConfigurationError
from .security import (
ARGON2_HASH_LENGTH,
ARGON2_MEMORY_COST_KIB,
ARGON2_PARALLELISM,
ARGON2_SALT_LENGTH,
ARGON2_TIME_COST,
MIN_PEPPER_BYTES,
)
SERVICE_NAME = "umbratrans-web"
SERVICE_VERSION = "0.2.0"
DEFAULT_SESSION_COOKIE_NAME = "umbratrans_session"
DEFAULT_CSRF_COOKIE_NAME = "umbratrans_csrf"
DEFAULT_CSRF_HEADER_NAME = "x-csrf-token"
DEFAULT_SESSION_IDLE_SECONDS = 30 * 60
DEFAULT_SESSION_ABSOLUTE_SECONDS = 12 * 60 * 60
DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS = 5
DEFAULT_LOCKOUT_SECONDS = 15 * 60
DEVELOPMENT_DATA_ROOT = Path(".local/data/development")
DEVELOPMENT_REPOSITORY_RELATIVE_PATH = Path("umbratrans-web/dev-accounts.json")
PRODUCTION_REPOSITORY_KIND = "postgres"
REPOSITORY_KINDS = ("memory", "json", PRODUCTION_REPOSITORY_KIND)
MAILER_TRANSPORTS = ("log", "smtp")
class Environment(StrEnum):
DEVELOPMENT = "development"
TEST = "test"
#: A real deployment against the authoritative store, with the same cookie,
#: CSRF, and repository rules as production, but able to start before mail is
#: configured. It exists because the alternative is a deployment that cannot
#: be brought up at all: production requires SMTP, and SMTP needs credentials
#: that only the operator has, so a strict-only deployment blocks first-run
#: setup behind a chicken-and-egg.
#:
#: The waiver that allows an unconfigured mailer is explicit, is reported in
#: ``/api/health``, and is shown as a standing banner in the admin console,
#: which also refuses to create accounts while it is in force. It is never
#: silent: an account created here could not be activated by email, so the
#: refusal is the point rather than an inconvenience.
STAGING = "staging"
PRODUCTION = "production"
@property
def is_production(self) -> bool:
return self is Environment.PRODUCTION
@property
def is_public(self) -> bool:
"""Whether this environment is reachable by clients and must be hardened.
Staging and production both hold real data behind a real hostname, so the
rules that matter are the same; they differ only in whether mail must
already be configured.
"""
return self in (Environment.STAGING, Environment.PRODUCTION)
def _environment_from(value: str) -> Environment:
normalized = value.strip().lower()
aliases = {
"development": Environment.DEVELOPMENT,
"dev": Environment.DEVELOPMENT,
"local": Environment.DEVELOPMENT,
"test": Environment.TEST,
"testing": Environment.TEST,
"staging": Environment.STAGING,
"stage": Environment.STAGING,
"production": Environment.PRODUCTION,
"prod": Environment.PRODUCTION,
}
if normalized not in aliases:
raise ConfigurationError(
"invalid_environment",
f"UMBRA_ENV must be one of development, test, staging, production (got {value!r}).",
)
return aliases[normalized]
def _bool_from(value: str, *, name: str) -> bool:
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ConfigurationError("invalid_boolean", f"{name} must be a boolean value.")
def _int_from(value: str, *, name: str, minimum: int = 1) -> int:
try:
parsed = int(value.strip())
except ValueError as error:
raise ConfigurationError("invalid_integer", f"{name} must be an integer.") from error
if parsed < minimum:
raise ConfigurationError("invalid_integer", f"{name} must be >= {minimum}.")
return parsed
@dataclass(frozen=True, slots=True)
class Settings:
"""Immutable settings for one application process."""
environment: Environment = Environment.DEVELOPMENT
service_name: str = SERVICE_NAME
version: str = SERVICE_VERSION
session_cookie_name: str = DEFAULT_SESSION_COOKIE_NAME
csrf_cookie_name: str = DEFAULT_CSRF_COOKIE_NAME
csrf_header_name: str = DEFAULT_CSRF_HEADER_NAME
cookie_secure: bool = False
cookie_samesite: str = "lax"
cookie_path: str = "/"
require_csrf: bool = True
session_idle_seconds: int = DEFAULT_SESSION_IDLE_SECONDS
session_absolute_seconds: int = DEFAULT_SESSION_ABSOLUTE_SECONDS
max_failed_login_attempts: int = DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS
lockout_seconds: int = DEFAULT_LOCKOUT_SECONDS
argon2_memory_cost_kib: int = ARGON2_MEMORY_COST_KIB
argon2_time_cost: int = ARGON2_TIME_COST
argon2_parallelism: int = ARGON2_PARALLELISM
argon2_hash_length: int = ARGON2_HASH_LENGTH
argon2_salt_length: int = ARGON2_SALT_LENGTH
password_min_length: int = 14
password_max_length: int = 1024
repository_kind: str = "memory"
repository_path: Path | None = None
database_dsn: str | None = field(default=None, repr=False, compare=False)
database_pool_min: int = 1
database_pool_max: int = 10
static_root: Path | None = None
#: Where production directories live (``productions/<book>-<lang>/``). Read
#: by the project-status adapter so the workspace can report real translation
#: and output state before the master store holds those rows. Unset means
#: "no production data is reachable", which the adapter reports honestly
#: rather than inventing progress.
productions_root: Path | None = None
mailer_transport: str = "log"
smtp_host: str = ""
#: Set only to bring a staging deployment up before SMTP exists. It is
#: refused in production, reported by the health endpoint, and shown as a
#: standing banner in the admin console, which then refuses to create
#: accounts. It exists so first-run setup is not blocked behind credentials
#: the operator is the only one who can supply.
mail_delivery_waived: bool = False
smtp_port: int = 587
smtp_username: str | None = None
smtp_password: str | None = field(default=None, repr=False, compare=False)
smtp_sender: str = "umbratrans@localhost"
smtp_starttls: bool = True
verification_base_url: str = ""
allowed_hosts: tuple[str, ...] = ()
last_seen_write_interval_seconds: int = 30
token_pepper: bytes | None = field(default=None, repr=False, compare=False)
def __post_init__(self) -> None:
environment = Environment(self.environment)
object.__setattr__(self, "environment", environment)
if self.repository_kind not in REPOSITORY_KINDS:
raise ConfigurationError(
"invalid_repository_kind",
f"Repository kind must be one of {', '.join(REPOSITORY_KINDS)}.",
)
if self.session_idle_seconds > self.session_absolute_seconds:
raise ConfigurationError(
"invalid_session_lifetime", "Idle timeout must not exceed the absolute session lifetime."
)
if self.cookie_samesite not in {"lax", "strict"}:
raise ConfigurationError("invalid_samesite", "Cookie SameSite must be lax or strict.")
if self.repository_path is not None and not isinstance(self.repository_path, Path):
object.__setattr__(self, "repository_path", Path(self.repository_path))
if self.static_root is not None and not isinstance(self.static_root, Path):
object.__setattr__(self, "static_root", Path(self.static_root))
if self.token_pepper is not None and len(self.token_pepper) < MIN_PEPPER_BYTES:
raise ConfigurationError("weak_token_pepper", f"Token pepper must be at least {MIN_PEPPER_BYTES} bytes.")
if any(host.strip() in {"*", ""} for host in self.allowed_hosts):
raise ConfigurationError("wildcard_host_forbidden", "A wildcard or empty allowed host is not permitted.")
if self.mailer_transport not in MAILER_TRANSPORTS:
raise ConfigurationError(
"invalid_mailer_transport",
f"Mail transport must be one of {', '.join(MAILER_TRANSPORTS)}.",
)
if self.verification_base_url and not self.verification_base_url.startswith(("http://", "https://")):
raise ConfigurationError("invalid_verification_base_url", "The verification base URL must be absolute.")
if self.database_pool_min < 1 or self.database_pool_max < self.database_pool_min:
raise ConfigurationError("invalid_pool_bounds", "Pool sizes must satisfy 1 <= min <= max.")
if environment.is_public:
self._validate_public()
def _mail_is_deliverable(self) -> bool:
"""Whether a real transport is configured and would actually send."""
return self.mailer_transport == "smtp" and bool(self.smtp_host)
def _validate_public(self) -> None:
if not self.cookie_secure:
raise ConfigurationError("insecure_production_cookie", "This environment requires Secure session cookies.")
if not self.require_csrf:
raise ConfigurationError("insecure_production_csrf", "This environment requires CSRF enforcement.")
# The PostgreSQL repository has no usable default. Refusing here means a
# public process cannot start without real credentials rather than
# appearing healthy while holding a development store. The same
# requirement is re-checked at the repository boundary for any
# environment, so a non-public process that selects postgres is stopped
# there instead.
if not self.database_dsn:
raise ConfigurationError(
"production_dsn_missing",
"This environment requires a PostgreSQL connection string.",
)
# Email validation is a required part of account creation, so a process
# that cannot deliver a link would leave every new account permanently
# unverified. Production insists on it outright. Staging allows it only
# under an explicit waiver, which is reported and displayed rather than
# assumed.
if not self._mail_is_deliverable():
if self.environment.is_production:
raise ConfigurationError(
"production_mailer_missing",
"Production requires a real SMTP transport; the logging transport would "
"write verification links to the log and never deliver them.",
)
if not self.mail_delivery_waived:
raise ConfigurationError(
"staging_mailer_unconfigured",
"Staging has no SMTP transport configured. Set UMBRATRANS_MAIL_DELIVERY_WAIVED=1 to "
"start anyway, understanding that accounts cannot be activated by email until "
"SMTP is configured.",
)
if not self.verification_base_url:
raise ConfigurationError(
"production_verification_url_missing",
"This environment requires an absolute verification base URL for mailed links.",
)
if self.repository_kind != PRODUCTION_REPOSITORY_KIND:
raise ConfigurationError(
"production_repository_missing",
"This environment requires the PostgreSQL repository adapter.",
)
if self.repository_path is not None:
raise ConfigurationError(
"production_repository_path_forbidden",
"A file-backed repository path is not permitted in this environment.",
)
@property
def is_production(self) -> bool:
return self.environment.is_production
@property
def is_development(self) -> bool:
return self.environment is Environment.DEVELOPMENT
def with_development_path_defaults(self, *, root: Path | None = None) -> Settings:
"""Return settings with a development repository path filled in."""
if self.repository_path is not None:
return self
base = root or DEVELOPMENT_DATA_ROOT
return replace(self, repository_path=(base / DEVELOPMENT_REPOSITORY_RELATIVE_PATH).resolve())
@property
def mail_is_deliverable(self) -> bool:
"""Whether verification mail can actually leave this process."""
return self._mail_is_deliverable()
def public_fingerprint(self) -> dict[str, object]:
"""Return non-secret settings suitable for a health response."""
return {
"environment": str(self.environment),
"repository": self.repository_kind,
"cookie_secure": self.cookie_secure,
"csrf_required": self.require_csrf,
"mail_deliverable": self.mail_is_deliverable,
"mail_delivery_waived": self.mail_delivery_waived,
"session_idle_seconds": self.session_idle_seconds,
"session_absolute_seconds": self.session_absolute_seconds,
"argon2": "argon2id",
}
def default_settings() -> Settings:
"""Return the development defaults used by tests and local tooling."""
return Settings(environment=Environment.TEST, cookie_secure=False)
def suite_settings(*, secure_cookies: bool = False, require_csrf: bool = True) -> Settings:
"""Return fast, isolated settings for the test suite.
The Argon2 cost is reduced so the suite stays quick. Production parameters
are asserted separately by ``test_security.py``.
"""
return replace(
default_settings(),
cookie_secure=secure_cookies,
require_csrf=require_csrf,
argon2_memory_cost_kib=8 * 1024,
argon2_time_cost=1,
argon2_parallelism=1,
argon2_hash_length=32,
argon2_salt_length=16,
password_min_length=14,
)
def load_settings(environ: Mapping[str, str] | None = None) -> Settings:
"""Build settings from environment variables, failing closed."""
env = os.environ if environ is None else environ
environment = _environment_from(env.get("UMBRA_ENV", str(Environment.DEVELOPMENT)))
repository_kind = env.get(
"UMBRATRANS_REPOSITORY", "memory" if not environment.is_production else PRODUCTION_REPOSITORY_KIND
)
repository_path: Path | None = None
raw_path = env.get("UMBRATRANS_REPOSITORY_PATH")
if raw_path:
repository_path = Path(raw_path).expanduser()
static_root: Path | None = None
raw_static = env.get("UMBRATRANS_STATIC_ROOT")
if raw_static:
static_root = Path(raw_static).expanduser()
productions_root: Path | None = None
raw_productions = env.get("UMBRATRANS_PRODUCTIONS_ROOT")
if raw_productions:
productions_root = Path(raw_productions).expanduser()
# The DSN is read from the environment and is never logged, echoed by an
# endpoint, or written to a development data file. repr/compare are off on
# the field so it cannot leak through a settings dump.
database_dsn = env.get("UMBRATRANS_DATABASE_DSN") or None
pool_min = _int_from(env.get("UMBRATRANS_DATABASE_POOL_MIN", "1"), name="UMBRATRANS_DATABASE_POOL_MIN", minimum=1)
pool_max = _int_from(env.get("UMBRATRANS_DATABASE_POOL_MAX", "10"), name="UMBRATRANS_DATABASE_POOL_MAX", minimum=1)
allowed_hosts = tuple(host.strip() for host in env.get("UMBRATRANS_ALLOWED_HOSTS", "").split(",") if host.strip())
# Verification links must be absolute, or the mailed link is unusable.
verification_base_url = env.get("UMBRATRANS_VERIFICATION_BASE_URL", "").strip().rstrip("/")
raw_pepper = env.get("UMBRATRANS_TOKEN_PEPPER")
pepper = raw_pepper.encode("utf-8") if raw_pepper else None
if pepper is not None and len(pepper) < MIN_PEPPER_BYTES:
raise ConfigurationError("weak_token_pepper", f"Token pepper must be at least {MIN_PEPPER_BYTES} bytes.")
settings = Settings(
environment=environment,
session_cookie_name=env.get("UMBRATRANS_SESSION_COOKIE", DEFAULT_SESSION_COOKIE_NAME),
csrf_cookie_name=env.get("UMBRATRANS_CSRF_COOKIE", DEFAULT_CSRF_COOKIE_NAME),
csrf_header_name=env.get("UMBRATRANS_CSRF_HEADER", DEFAULT_CSRF_HEADER_NAME).lower(),
cookie_secure=_bool_from(env.get("UMBRATRANS_COOKIE_SECURE", "false"), name="UMBRATRANS_COOKIE_SECURE"),
require_csrf=_bool_from(env.get("UMBRATRANS_REQUIRE_CSRF", "true"), name="UMBRATRANS_REQUIRE_CSRF"),
mail_delivery_waived=_bool_from(
env.get("UMBRATRANS_MAIL_DELIVERY_WAIVED", "false"),
name="UMBRATRANS_MAIL_DELIVERY_WAIVED",
),
session_idle_seconds=_int_from(
env.get("UMBRATRANS_SESSION_IDLE_SECONDS", str(DEFAULT_SESSION_IDLE_SECONDS)),
name="UMBRATRANS_SESSION_IDLE_SECONDS",
minimum=60,
),
session_absolute_seconds=_int_from(
env.get("UMBRATRANS_SESSION_ABSOLUTE_SECONDS", str(DEFAULT_SESSION_ABSOLUTE_SECONDS)),
name="UMBRATRANS_SESSION_ABSOLUTE_SECONDS",
minimum=60,
),
max_failed_login_attempts=_int_from(
env.get("UMBRATRANS_MAX_FAILED_LOGINS", str(DEFAULT_MAX_FAILED_LOGIN_ATTEMPTS)),
name="UMBRATRANS_MAX_FAILED_LOGINS",
minimum=1,
),
lockout_seconds=_int_from(
env.get("UMBRATRANS_LOCKOUT_SECONDS", str(DEFAULT_LOCKOUT_SECONDS)),
name="UMBRATRANS_LOCKOUT_SECONDS",
minimum=1,
),
repository_kind=repository_kind,
repository_path=repository_path,
database_dsn=database_dsn,
database_pool_min=pool_min,
database_pool_max=pool_max,
static_root=static_root,
productions_root=productions_root,
mailer_transport=env.get("UMBRATRANS_MAILER", "log").strip().lower(),
smtp_host=env.get("UMBRATRANS_SMTP_HOST", "").strip(),
smtp_port=_int_from(env.get("UMBRATRANS_SMTP_PORT", "587"), name="UMBRATRANS_SMTP_PORT", minimum=1),
smtp_username=env.get("UMBRATRANS_SMTP_USERNAME") or None,
smtp_password=env.get("UMBRATRANS_SMTP_PASSWORD") or None,
smtp_sender=env.get("UMBRATRANS_SMTP_SENDER", "umbratrans@localhost").strip(),
smtp_starttls=_bool_from(env.get("UMBRATRANS_SMTP_STARTTLS", "true"), name="UMBRATRANS_SMTP_STARTTLS"),
verification_base_url=verification_base_url,
allowed_hosts=allowed_hosts,
token_pepper=pepper,
)
if settings.repository_kind == "json" and settings.repository_path is None:
return settings.with_development_path_defaults()
return settings
__all__ = [
"DEVELOPMENT_DATA_ROOT",
"PRODUCTION_REPOSITORY_KIND",
"REPOSITORY_KINDS",
"SERVICE_NAME",
"SERVICE_VERSION",
"Environment",
"Settings",
"default_settings",
"load_settings",
"suite_settings",
]