mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 13:40:21 +01:00
Supersedes #1353, per the discussion in #1352: one `API_KEY` environment variable; when set, a request carrying it is authenticated as the first admin, and cookie sessions keep working exactly as before (cookie **or** key). Nothing else changes. No table, no UI, no settings-tab switch, no per-user keys. ## What - `API_KEY` (env). Unset → the feature is off and none of the new code runs. - `Authorization: Bearer <key>` or `X-Api-Key: <key>` on any existing `/api/*` route authenticates that request as the first admin in `users.db` (`ORDER BY id`), or as a bare admin identity (`user_id="api"`, `is_admin=True`, no local user row) if the install has no admin yet. Per request only; nothing is persisted; the admin's role is read live, so deleting or demoting that user takes effect on the next request. - Both headers are checked and either may match. That is what makes the key usable behind a reverse proxy that injects its own `Authorization` header (oauth2-proxy, Authelia, forwardAuth): send the key in `X-Api-Key`. - A credential that is **not** the key is ignored and the request continues on the normal session path, so proxy-forwarded tokens are unaffected. Without a valid session such a request gets the usual `401 {"error": "Unauthorized"}`, identical to a request with no credential, so there is nothing to probe. ## How - `shelfmark/config/env.py`: `API_KEY = os.getenv("API_KEY", "").strip()`. - `shelfmark/core/api_key.py`: `extract_api_key_candidates()` (Bearer token if the scheme is Bearer, then `X-Api-Key`) and `matches_api_key()` using `hmac.compare_digest` on bytes. - `shelfmark/core/user_db.py`: `UserDB.get_first_admin()`. - `shelfmark/main.py`: `api_key_auth_middleware` (`before_request`, registered before `proxy_auth_middleware`, which early-returns for keyed requests). Only `/api/` paths; `/api/health` and `/api/auth/*` exempt; no-op when `API_KEY` is unset or the auth mode is `none`. On a match it mirrors the proxy-auth pattern: `session.clear()` then populate `user_id` / `is_admin` / `db_user_id` for this request, `permanent = False`, `modified = False`, `g.api_key_auth = True`. An `after_request` hook guarantees no `Set-Cookie` is written for a keyed request even if a handler dirties the session. - `docs/api-access.md` (new), the `API_KEY` entry in `docs/environment-variables.md`, and a README link. ## Security - Constant-time compare; the key is never logged or echoed. - Keyed requests never mint or refresh a session cookie and ignore any cookie sent with them (a non-admin cookie plus the key yields admin for that request; the browser's own session is left untouched and usable). - The mismatch path touches neither the session nor `g`, so a stray bearer on a browser request can neither log the user out nor change how their cookie is refreshed. - Store errors during the admin lookup fail closed (`500 {"error": "Authentication error"}`), never to anonymous. - Verified against Flask's `save_session` / `should_set_cookie` ordering, and under auth modes `none`, `builtin`, `proxy`. ## Tests `tests/core/test_api_key_env.py` (36): extraction and matching; first-admin lookup; middleware behaviour on a guarded route and an admin route, with and without a user_db, `X-Api-Key`, both-headers combinations, no `Set-Cookie` when a handler dirties the session, incoming non-admin cookie ignored, browser cookie still usable after a keyed request, security headers, store error → 500, mismatch → guard's 401 / cookie path / permanent cookie untouched, unset → off, exempt paths and path probes, `none` and `proxy` modes, deleted and demoted first admin, a keyed write passing the guard. Existing auth suites unchanged. All CI gates green on the fork: https://github.com/gavinmcfall/shelfmark/pull/2 (CI-only draft). Also exercised against a running instance: 47 scripted checks including 150 concurrent requests, proxy-mode switching through the key, an unset-key restart, and a log scan for the key. ## Naming `API_KEY` as discussed. If you'd rather namespace it (`SHELFMARK_API_KEY`) to avoid clashing with other tools' env vars in shared compose files, it is a one-line change; say the word. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
233 lines
8.7 KiB
Python
233 lines
8.7 KiB
Python
"""Bootstrap environment variables. No local dependencies - import first."""
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL")
|
|
|
|
|
|
def string_to_bool(s: str) -> bool:
|
|
"""Convert string to boolean."""
|
|
return s.lower() in ["true", "yes", "1", "y"]
|
|
|
|
|
|
def _read_advanced_config(key: str) -> object | None:
|
|
"""Read a key from the advanced settings file (import-time safe)."""
|
|
config_dir = Path(os.getenv("CONFIG_DIR", "/config"))
|
|
config_file = config_dir / "plugins" / "advanced.json"
|
|
|
|
if config_file.exists():
|
|
try:
|
|
with config_file.open() as f:
|
|
config = json.load(f)
|
|
if key in config:
|
|
return config[key]
|
|
except json.JSONDecodeError, OSError:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def _read_debug_from_config() -> bool:
|
|
"""Read DEBUG from env var or config file (import-time safe)."""
|
|
env_debug = os.environ.get("DEBUG")
|
|
if env_debug is not None:
|
|
return string_to_bool(env_debug)
|
|
|
|
value = _read_advanced_config("DEBUG")
|
|
if value is not None:
|
|
return bool(value)
|
|
|
|
return False
|
|
|
|
|
|
def normalize_log_level(raw: str | None) -> str:
|
|
"""Normalize a log level name, falling back to INFO when unrecognized."""
|
|
if raw is None:
|
|
return "INFO"
|
|
|
|
normalized = raw.strip().upper()
|
|
# "WARN" is a logging alias, but gunicorn only accepts "warning".
|
|
if normalized == "WARN":
|
|
normalized = "WARNING"
|
|
|
|
if normalized not in LOG_LEVELS:
|
|
return "INFO"
|
|
|
|
return normalized
|
|
|
|
|
|
def _read_log_level_from_config(debug: bool) -> str:
|
|
"""Resolve the app log level from DEBUG, env var, or config file.
|
|
|
|
DEBUG wins when enabled, mirroring how entrypoint.sh picks gunicorn's level.
|
|
Otherwise LOG_LEVEL is read from the env var, then the settings file, and
|
|
falls back to INFO when unset or unrecognized.
|
|
"""
|
|
if debug:
|
|
return "DEBUG"
|
|
|
|
raw = os.environ.get("LOG_LEVEL")
|
|
if raw is None:
|
|
value = _read_advanced_config("LOG_LEVEL")
|
|
raw = value if isinstance(value, str) else None
|
|
|
|
return normalize_log_level(raw)
|
|
|
|
|
|
def _is_sqlite_file(path: Path) -> bool:
|
|
"""Check if a file is a valid SQLite database by reading magic bytes."""
|
|
try:
|
|
with path.open("rb") as f:
|
|
header = f.read(16)
|
|
return header[:16] == b"SQLite format 3\x00"
|
|
except OSError, PermissionError:
|
|
return False
|
|
|
|
|
|
def _resolve_cwa_db_path() -> Path | None:
|
|
"""Resolve CWA database path from env var or default location."""
|
|
env_path = os.getenv("CWA_DB_PATH")
|
|
if env_path:
|
|
path = Path(env_path)
|
|
if path.exists() and path.is_file() and _is_sqlite_file(path):
|
|
return path
|
|
|
|
# Check default mount path
|
|
default_path = Path("/auth/app.db")
|
|
if default_path.exists() and default_path.is_file() and _is_sqlite_file(default_path):
|
|
return default_path
|
|
|
|
return None
|
|
|
|
|
|
def _is_config_dir_writable() -> bool:
|
|
"""Check if the config directory exists and is writable."""
|
|
try:
|
|
if not CONFIG_DIR.exists() or not CONFIG_DIR.is_dir():
|
|
return False
|
|
test_file = CONFIG_DIR / ".write_test"
|
|
test_file.touch()
|
|
test_file.unlink()
|
|
except OSError, PermissionError:
|
|
return False
|
|
else:
|
|
return True
|
|
|
|
|
|
def is_covers_cache_enabled() -> bool:
|
|
"""Check if cover caching is enabled (requires setting + writable config dir)."""
|
|
from shelfmark.core.config import config
|
|
|
|
setting_enabled = config.get("COVERS_CACHE_ENABLED", True)
|
|
if isinstance(setting_enabled, str):
|
|
return string_to_bool(setting_enabled) and _is_config_dir_writable()
|
|
return bool(setting_enabled) and _is_config_dir_writable()
|
|
|
|
|
|
# =============================================================================
|
|
# Bootstrap paths - needed before settings registry is available
|
|
# =============================================================================
|
|
|
|
CONFIG_DIR = Path(os.getenv("CONFIG_DIR", "/config"))
|
|
LOG_ROOT = Path(os.getenv("LOG_ROOT", "/var/log/"))
|
|
LOG_DIR = LOG_ROOT / "shelfmark"
|
|
LOG_FILE = LOG_DIR / "shelfmark.log"
|
|
TMP_DIR = Path(os.getenv("TMP_DIR", (Path(tempfile.gettempdir()) / "shelfmark").as_posix()))
|
|
INGEST_DIR = Path(os.getenv("INGEST_DIR", "/books"))
|
|
|
|
|
|
# =============================================================================
|
|
# Logger configuration - needed before settings registry is available
|
|
# =============================================================================
|
|
|
|
DEBUG = _read_debug_from_config()
|
|
LOG_LEVEL = _read_log_level_from_config(DEBUG)
|
|
ENABLE_LOGGING = string_to_bool(os.getenv("ENABLE_LOGGING", "true"))
|
|
|
|
|
|
# =============================================================================
|
|
# Flask configuration - needed before app starts
|
|
# =============================================================================
|
|
|
|
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
|
|
FLASK_PORT = int(os.getenv("FLASK_PORT", "8084"))
|
|
|
|
|
|
# =============================================================================
|
|
# Authentication
|
|
# =============================================================================
|
|
|
|
SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
|
|
SESSION_COOKIE_NAME = "shelfmark_session"
|
|
CWA_DB_PATH = _resolve_cwa_db_path()
|
|
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
|
|
DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_LOCAL_AUTH", "false"))
|
|
# Optional static API key. When set, requests carrying it as a Bearer token
|
|
# (or X-Api-Key) are authenticated as an admin for that request only.
|
|
API_KEY = os.getenv("API_KEY", "").strip()
|
|
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
|
|
|
|
|
|
# =============================================================================
|
|
# Version information from Docker build
|
|
# =============================================================================
|
|
|
|
BUILD_VERSION = os.getenv("BUILD_VERSION", "N/A")
|
|
RELEASE_VERSION = os.getenv("RELEASE_VERSION", "N/A")
|
|
|
|
|
|
# =============================================================================
|
|
# Capability detection - runtime checks, not user-configurable
|
|
# =============================================================================
|
|
|
|
DOCKERMODE = string_to_bool(os.getenv("DOCKERMODE", "false"))
|
|
TOR_VARIANT_AVAILABLE = shutil.which("tor") is not None
|
|
USING_TOR = string_to_bool(os.getenv("USING_TOR", "false"))
|
|
|
|
|
|
# =============================================================================
|
|
# Onboarding
|
|
# =============================================================================
|
|
|
|
# Set to false to skip the onboarding wizard entirely (useful for ephemeral storage)
|
|
ONBOARDING = string_to_bool(os.getenv("ONBOARDING", "true"))
|
|
|
|
|
|
# =============================================================================
|
|
# Debug/development settings
|
|
# =============================================================================
|
|
|
|
# Debug: skip specific download sources for testing fallback chains
|
|
# Comma-separated values: aa-fast, aa-slow-nowait, aa-slow-wait, libgen, zlib, welib
|
|
_DEBUG_SKIP_SOURCES_RAW = os.getenv("DEBUG_SKIP_SOURCES", "").strip().lower()
|
|
DEBUG_SKIP_SOURCES = {s.strip() for s in _DEBUG_SKIP_SOURCES_RAW.split(",") if s.strip()}
|
|
|
|
# Debug: keep DDoS-Guard's __ddg8_/__ddg9_/__ddg10_ in the clearance store instead of
|
|
# dropping them after a solve.
|
|
#
|
|
# Which of DDoS-Guard's cookies actually *are* clearance is not settled. The store treats
|
|
# the trio as describing one check (client IP, timestamp, token) and drops them, on the
|
|
# reasoning that replaying a stale IP/timestamp is what re-arms the ?check=1 loop - see
|
|
# shelfmark.bypass.cookie_store. Field reports on issue #1276 point the other way: every
|
|
# request after a successful solve was challenged again, which is only consistent with
|
|
# what the store keeps not being sufficient clearance on its own.
|
|
#
|
|
# Deliberately env-only and off by default: this is a knob for reproducing the question
|
|
# against a live host, not a setting to offer users. Set it to true, solve once, and watch
|
|
# whether the next search still logs "Redirect loop detected".
|
|
DDG_REPLAY_PER_CHECK_COOKIES = string_to_bool(os.getenv("DDG_REPLAY_PER_CHECK_COOKIES", "false"))
|
|
|
|
|
|
# =============================================================================
|
|
# Legacy migration support - will be removed in future version
|
|
# =============================================================================
|
|
|
|
# Legacy welib settings - replaced by SOURCE_PRIORITY OrderableListField
|
|
# Kept for migration: if set, used to build initial SOURCE_PRIORITY config
|
|
_LEGACY_PRIORITIZE_WELIB = string_to_bool(os.getenv("PRIORITIZE_WELIB", "false"))
|
|
_LEGACY_ALLOW_USE_WELIB = string_to_bool(os.getenv("ALLOW_USE_WELIB", "true"))
|