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
443 lines
19 KiB
Python
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",
|
|
]
|