diff --git a/.env.production.example b/.env.production.example index f249ad4..327ea35 100644 --- a/.env.production.example +++ b/.env.production.example @@ -31,7 +31,14 @@ SQLITE_CHECK_SAME_THREAD=false # --- filesystem paths --- UPLOAD_DIR=/app/uploads PROMPT_DIR=/app/prompts -DATABASE_BACKUP_DIR=/app/data/backups +DATABASE_BACKUP_DIR=/backup +# --- backup workflow helpers (not Runtime Settings model fields) --- +BACKUP_DIR=/backup +# APP_DATA_BACKUP_DIR=/backup/data +# UPLOADS_BACKUP_DIR=/backup/uploads +BACKUP_RETENTION_DAYS=14 +# Optional mounted Synology destination path for replicated dumps +# SYNOLOGY_BACKUP_DIR=/mnt/synology/transcription-backups # --- worker reliability --- WORKER_MAX_RETRIES=0 @@ -53,9 +60,6 @@ POSTGRES_PASSWORD=replace-with-strong-password # Required for token-based tunnel startup. CLOUDFLARE_TUNNEL_TOKEN=replace-with-cloudflare-tunnel-token -# --- backup workflow helpers --- -# Used by deploy/backup/create_postgres_backup.sh -BACKUP_DIR=./data/backups -BACKUP_RETENTION_DAYS=14 -# Optional mounted Synology destination path for replicated dumps -# SYNOLOGY_BACKUP_DIR=/mnt/synology/transcription-backups +# --- deployment wiring helpers --- +# Runtime Settings writes target this file path inside the app container. +RUNTIME_SETTINGS_ENV_FILE=/app/.env.production diff --git a/deploy/backup/create_postgres_backup.sh b/deploy/backup/create_postgres_backup.sh index 81d38c2..aa3a73e 100644 --- a/deploy/backup/create_postgres_backup.sh +++ b/deploy/backup/create_postgres_backup.sh @@ -16,6 +16,8 @@ logs_file="logs-${timestamp}.tar.gz" manifest_file="backup-${timestamp}.manifest" mkdir -p "${BACKUP_DIR}" +mkdir -p "${APP_DATA_BACKUP_DIR}" +app_data_backup_abs="$(cd "${APP_DATA_BACKUP_DIR}" && pwd -P)" # If SYNOLOGY_BACKUP_DIR wasn't exported in the shell, read it from ENV_FILE. if [ -z "${SYNOLOGY_BACKUP_DIR}" ] && [ -f "${ENV_FILE}" ]; then @@ -68,10 +70,17 @@ find "${BACKUP_DIR}" -type f \( \ -name 'backup-*.manifest' \ \) -mtime +"${RETENTION_DAYS}" -delete -# Always keep a rolling incremental mirror of uploaded files in BACKUP_DIR. -mkdir -p "${APP_DATA_BACKUP_DIR}" +# Always keep a rolling mirror of app data in BACKUP_DIR, unless that target +# is nested under /app/data (which would recursively copy into itself). if [ -d /app/data ]; then - cp -a /app/data/. "${APP_DATA_BACKUP_DIR}/" + case "${app_data_backup_abs}" in + /app/data|/app/data/*) + echo "Skipping app data mirror because APP_DATA_BACKUP_DIR is nested under /app/data: ${APP_DATA_BACKUP_DIR}" + ;; + *) + cp -a /app/data/. "${APP_DATA_BACKUP_DIR}/" + ;; + esac fi mkdir -p "${UPLOADS_BACKUP_DIR}/documents" "${UPLOADS_BACKUP_DIR}/photos" diff --git a/docs/ui/pages/settings.md b/docs/ui/pages/settings.md index 66bdc62..3017565 100644 --- a/docs/ui/pages/settings.md +++ b/docs/ui/pages/settings.md @@ -30,7 +30,7 @@ Settings manages installation-local registries, safe runtime .env settings, and - Runtime Settings includes an explicit "Other settings not shown here" markdown table listing: - secrets (`OPENROUTER_API_KEY`, `DATABASE__PASSWORD`) - high-risk database connection settings (`DATABASE__DRIVER`, `DATABASE__PATH`, `DATABASE__HOST`, `DATABASE__PORT`, `DATABASE__DATABASE`, `DATABASE__USER`) - and directs edits for those keys to `.env`. + and deployment/helper keys (`POSTGRES_*`, `CLOUDFLARE_TUNNEL_TOKEN`, `BACKUP_DIR`, `APP_DATA_BACKUP_DIR`, `UPLOADS_BACKUP_DIR`, `BACKUP_RETENTION_DAYS`, `SYNOLOGY_BACKUP_DIR`, `RUNTIME_SETTINGS_ENV_FILE`, `ENV_FILE`, `COMPOSE_FILE`), and directs edits for those keys to the resolved runtime env file path. - Document Types, Person Roles, and Tags support Add/Edit/Delete with existing guardrails. - Prompts exposes only `transcribe_document.md` for editing and restore-from-backup. - Home Page Text edits the same Markdown content rendered on `/homepage`. diff --git a/src/transcription/ui/pages/settings_page.py b/src/transcription/ui/pages/settings_page.py index ded2f3b..7301df8 100644 --- a/src/transcription/ui/pages/settings_page.py +++ b/src/transcription/ui/pages/settings_page.py @@ -711,7 +711,7 @@ def register_page(*, settings: Settings) -> None: # noqa: PLR0915 with ui.column().classes("w-full max-w-3xl mx-auto gap-2 mt-3"): ui.label("Other settings not shown here").classes("text-sm font-semibold") - ui.label("Edit these directly in .env:").classes("text-xs ui-text-muted") + ui.label(f"Edit these directly in {snapshot.env_file_path}:").classes("text-xs ui-text-muted") table_rows = [ f"| {category.title} | {', '.join(f'`{key}`' for key in category.env_keys)} |" for category in HIDDEN_SETTINGS_CATEGORIES diff --git a/src/transcription/ui/runtime_settings_store.py b/src/transcription/ui/runtime_settings_store.py index 4c478f9..a8f33f8 100644 --- a/src/transcription/ui/runtime_settings_store.py +++ b/src/transcription/ui/runtime_settings_store.py @@ -84,6 +84,29 @@ HIDDEN_SETTINGS_CATEGORIES: tuple[HiddenSettingsCategory, ...] = ( "DATABASE__USER", ), ), + HiddenSettingsCategory( + title="Deployment and backup/tunnel helpers (not Runtime Settings model fields)", + reason=( + "These keys are consumed by docker-compose, backup scripts, or deployment adapters and are " + "intentionally edited outside the Runtime Settings UI." + ), + env_keys=( + "POSTGRES_DB", + "POSTGRES_USER", + "POSTGRES_PASSWORD", + "POSTGRES_HOST", + "POSTGRES_PORT", + "CLOUDFLARE_TUNNEL_TOKEN", + "RUNTIME_SETTINGS_ENV_FILE", + "BACKUP_DIR", + "APP_DATA_BACKUP_DIR", + "UPLOADS_BACKUP_DIR", + "BACKUP_RETENTION_DAYS", + "SYNOLOGY_BACKUP_DIR", + "ENV_FILE", + "COMPOSE_FILE", + ), + ), ) RUNTIME_SETTINGS_CATALOG: tuple[RuntimeSettingDescriptor, ...] = ( @@ -358,7 +381,15 @@ def save_runtime_settings( env_map[descriptor.env_key] = encoded _validate_candidate_env(env_map=env_map, env_file_path=resolved_env_path) - _write_env_lines_atomic(path=resolved_env_path, lines=mutable_lines) + try: + _write_env_lines_atomic(path=resolved_env_path, lines=mutable_lines) + except OSError as exc: + raise AppError( + "Runtime settings file is not writable.", + category=ErrorCategory.INFRA_PERSISTENT, + suggestion="Verify file path and write permissions, then retry.", + detail=f"Failed writing runtime env file {resolved_env_path}: {type(exc).__name__}: {exc}", + ) from exc refreshed = Settings(_env_file=resolved_env_path, _cli_parse_args=False) return read_runtime_settings_snapshot(settings=refreshed, env_file_path=resolved_env_path) @@ -438,7 +469,15 @@ def _validate_candidate_env(*, env_map: dict[str, str], env_file_path: Path) -> def _read_env_lines(path: Path) -> list[str]: if not path.exists(): return [] - return path.read_text(encoding="utf-8").splitlines() + try: + return path.read_text(encoding="utf-8").splitlines() + except OSError as exc: + raise AppError( + "Runtime settings file is unreadable.", + category=ErrorCategory.INFRA_PERSISTENT, + suggestion="Verify file path and read permissions, then retry.", + detail=f"Failed reading runtime env file {path}: {type(exc).__name__}: {exc}", + ) from exc def _parse_env_map(lines: list[str]) -> dict[str, str]: @@ -485,21 +524,29 @@ def _upsert_env_key(lines: list[str], key: str, value: str) -> None: def _write_env_lines_atomic(*, path: Path, lines: list[str]) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - content = "\n".join(lines).rstrip("\n") - if content: - content += "\n" - with tempfile.NamedTemporaryFile( - mode="w", - delete=False, - dir=path.parent, - encoding="utf-8", - newline="\n", - suffix=".env.tmp", - ) as handle: - temp_path = Path(handle.name) - handle.write(content) - temp_path.replace(path) + try: + path.parent.mkdir(parents=True, exist_ok=True) + content = "\n".join(lines).rstrip("\n") + if content: + content += "\n" + with tempfile.NamedTemporaryFile( + mode="w", + delete=False, + dir=path.parent, + encoding="utf-8", + newline="\n", + suffix=".env.tmp", + ) as handle: + temp_path = Path(handle.name) + handle.write(content) + temp_path.replace(path) + except OSError as exc: + raise AppError( + "Runtime settings file is not writable.", + category=ErrorCategory.INFRA_PERSISTENT, + suggestion="Verify file path and write permissions, then retry.", + detail=f"Failed writing runtime env file {path}: {type(exc).__name__}: {exc}", + ) from exc def _escape_json_string(value: str) -> str: diff --git a/tests/ui/test_runtime_settings_store.py b/tests/ui/test_runtime_settings_store.py index 72e7d59..e308e9a 100644 --- a/tests/ui/test_runtime_settings_store.py +++ b/tests/ui/test_runtime_settings_store.py @@ -8,6 +8,7 @@ import pytest from transcription.config import Settings from transcription.errors import AppError +from transcription.ui import runtime_settings_store from transcription.ui.runtime_settings_store import HIDDEN_SETTINGS_CATEGORIES from transcription.ui.runtime_settings_store import RUNTIME_SETTINGS_CATALOG from transcription.ui.runtime_settings_store import SETTINGS_UI_EXCLUDED_FIELDS @@ -35,6 +36,12 @@ def test_hidden_settings_categories_list_secret_and_high_risk_env_keys(): assert "DATABASE__PASSWORD" in hidden_keys assert "DATABASE__DRIVER" in hidden_keys assert "DATABASE__HOST" in hidden_keys + assert "POSTGRES_DB" in hidden_keys + assert "POSTGRES_USER" in hidden_keys + assert "POSTGRES_PASSWORD" in hidden_keys + assert "CLOUDFLARE_TUNNEL_TOKEN" in hidden_keys + assert "BACKUP_DIR" in hidden_keys + assert "SYNOLOGY_BACKUP_DIR" in hidden_keys def test_runtime_settings_snapshot_includes_catalog_fields(tmp_path: Path): @@ -98,3 +105,31 @@ def test_runtime_settings_uses_override_env_file(tmp_path: Path, monkeypatch): assert snapshot.env_file_path == target assert "PORT=9001" in target.read_text(encoding="utf-8") + + +def test_save_runtime_settings_surfaces_unreadable_target(tmp_path: Path, monkeypatch): + settings = _settings_for_runtime_editing(tmp_path) + env_path = tmp_path / ".env" + + def _raise_read_error(_self, encoding="utf-8"): + _ = encoding + raise OSError("denied") + + monkeypatch.setattr(Path, "read_text", _raise_read_error) + with pytest.raises(AppError) as exc: + save_runtime_settings(settings=settings, updates={"port": "9001"}, env_file_path=env_path) + assert exc.value.message == "Runtime settings file is unreadable." + + +def test_save_runtime_settings_surfaces_unwritable_target(tmp_path: Path, monkeypatch): + settings = _settings_for_runtime_editing(tmp_path) + env_path = tmp_path / ".env" + + def _raise_write_error(*args, **kwargs): + _ = args, kwargs + raise OSError("denied") + + monkeypatch.setattr(runtime_settings_store, "_write_env_lines_atomic", _raise_write_error) + with pytest.raises(AppError) as exc: + save_runtime_settings(settings=settings, updates={"port": "9001"}, env_file_path=env_path) + assert exc.value.message == "Runtime settings file is not writable."