Files
shelfmark/shelfmark/main.py
T
splitsec2 fe99d4bb5b feat(search): add a configurable default content type (#1371)
Closes #1018.

Worth correcting the issue first: the search tab is not hardcoded.
`useContentTypePreferences` has persisted the user's choice to
localStorage since #564, so a browser that has picked a tab already
keeps it. What is missing is the other half the issue asks for, a
default for a browser that has stored nothing, and a per-user override.

`DEFAULT_CONTENT_TYPE` is a user overridable select next to
`BOOK_LANGUAGE`, so it follows the same path: global value in Settings,
per-user value in Search Preferences, resolved with `user_id` in
`/api/config`. The frontend uses it only when this browser has no stored
choice, which is captured before the existing effect writes one, so
nothing changes for anyone who has already picked a tab.

The resolution is a pure function in `utils/contentTypePreference.ts`
rather than logic inside the hook, since vitest here has no jsdom and
the existing tests cover resolvers like `resolveDefaultLanguageCodes`
the same way.

One small move in `App.tsx`: the `config` state was declared below the
hook that now reads it, so it moved above it.

## Verification

- `tests/core/test_config_api.py`: the payload carries
`default_content_type` and reads it with the user's id.
- `tests/core/test_admin_users_api.py`: the key appears in the per-user
search preferences list.
- `src/frontend/src/tests/contentTypePreference.test.ts`: a stored tab
wins, combined mode survives, the server default applies when nothing is
stored, and an unrecognised value falls back to ebook.
- Python suite (3163) and frontend suite (201) green, plus ruff, ruff
format, basedpyright, vulture, tsc, oxlint, oxfmt and the production
build.
2026-09-25 18:14:42 -04:00

3542 lines
130 KiB
Python

"""Flask app - routes, WebSocket handlers, and middleware."""
import binascii
import logging
import os
import re
import sqlite3
import time
from contextlib import suppress
from datetime import UTC, datetime, timedelta
from functools import wraps
from importlib import import_module
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast
from flask import Flask, g, jsonify, request, send_file, send_from_directory, session
from flask_cors import CORS
from flask_socketio import SocketIO, emit
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import check_password_hash
from werkzeug.wrappers import Response
from shelfmark.api.websocket import ws_manager
from shelfmark.config.env import (
BUILD_VERSION,
CONFIG_DIR,
CWA_DB_PATH,
DISABLE_LOCAL_AUTH,
FLASK_HOST,
FLASK_PORT,
HIDE_LOCAL_AUTH,
OIDC_AUTO_REDIRECT,
RELEASE_VERSION,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SECURE_ENV,
_is_config_dir_writable,
string_to_bool,
)
from shelfmark.config.security import _migrate_security_settings
from shelfmark.config.settings import (
_SUPPORTED_BOOK_LANGUAGE,
migrate_audiobook_format_settings,
)
from shelfmark.core import api_key as api_key_module # module access lets tests monkeypatch the key
from shelfmark.core import search_deadline
from shelfmark.core.activity_view_state_service import ActivityViewStateService
from shelfmark.core.auth_modes import (
get_auth_check_admin_status,
is_settings_or_onboarding_path,
load_active_auth_mode,
requires_admin_for_settings_access,
)
from shelfmark.core.config import config as app_config
from shelfmark.core.cwa_user_sync import upsert_cwa_user
from shelfmark.core.download_history_service import DownloadHistoryService
from shelfmark.core.external_user_linking import upsert_external_user
from shelfmark.core.logger import setup_logger
from shelfmark.core.models import TERMINAL_QUEUE_STATUSES, QueueStatus, SearchFilters
from shelfmark.core.notifications import (
NotificationContext,
NotificationEvent,
notify_admin,
notify_user,
)
from shelfmark.core.prefix_middleware import PrefixMiddleware
from shelfmark.core.release_inspect_routes import register_release_inspect_routes
from shelfmark.core.request_helpers import (
coerce_bool,
emit_ws_event,
get_session_db_user_id,
load_users_request_policy_settings,
normalize_optional_text,
normalize_positive_int,
)
from shelfmark.core.request_policy import (
PolicyMode,
get_source_content_type_capabilities,
merge_request_policy_settings,
normalize_content_type,
normalize_source,
resolve_policy_mode,
)
from shelfmark.core.requests_service import (
reopen_failed_request,
sync_delivery_states_from_queue_status,
)
from shelfmark.core.user_db import UserDB
from shelfmark.core.utils import AUDIOBOOK_FORMATS, normalize_base_path
from shelfmark.download import orchestrator as backend
from shelfmark.download import warmup
from shelfmark.release_sources import (
BrowseRecord,
Release,
SourceUnavailableError,
get_source_display_name,
)
if TYPE_CHECKING:
from shelfmark.metadata_providers import BookMetadata, MetadataProvider
logger = setup_logger(__name__)
FLASK_SECRET_KEY_MIN_BYTES = 32
_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
def _is_debug_enabled() -> bool:
debug_value = app_config.get("DEBUG", False)
if isinstance(debug_value, str):
return string_to_bool(debug_value)
return bool(debug_value)
def _raise_runtime_error(message: str) -> NoReturn:
raise RuntimeError(message)
# Project root is the repository root above the package directory.
PROJECT_ROOT = Path(__file__).resolve().parent.parent
FRONTEND_DIST = PROJECT_ROOT / "frontend-dist"
BASE_PATH = normalize_base_path(normalize_optional_text(app_config.get("URL_BASE", "")))
app = Flask(__name__)
app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 # Disable caching
app.config["APPLICATION_ROOT"] = BASE_PATH or "/"
wsgi_app = cast(Any, ProxyFix(app.wsgi_app, x_host=1, x_port=1))
if BASE_PATH:
wsgi_app = cast(Any, PrefixMiddleware(wsgi_app, BASE_PATH, bypass_paths={"/api/health"}))
app.wsgi_app = wsgi_app
# Socket.IO async mode.
# We run this app under Gunicorn with a gevent websocket worker (even when DEBUG=true),
# so Socket.IO should always use gevent here.
async_mode = "gevent"
socketio_cors_allowed_origins = "*"
# Initialize Flask-SocketIO with reverse proxy support
socketio_path = f"{BASE_PATH}/socket.io" if BASE_PATH else "/socket.io"
socketio_init_kwargs: dict[str, Any] = {
"cors_allowed_origins": socketio_cors_allowed_origins,
"async_mode": async_mode,
"logger": False,
"engineio_logger": False,
# Reverse proxy / Traefik compatibility settings
"path": socketio_path,
"ping_timeout": 60,
"ping_interval": 25,
# Allow both websocket and polling for better compatibility
"transports": ["websocket", "polling"],
# Enable CORS for all origins (you can restrict this in production)
"allow_upgrades": True,
# Important for proxies that buffer
"http_compression": True,
}
socketio = SocketIO(app, **socketio_init_kwargs)
# Initialize WebSocket manager
ws_manager.init_app(app, socketio)
ws_manager.set_queue_status_fn(backend.queue_status)
logger.info("Flask-SocketIO initialized with async_mode='%s'", async_mode)
logger.info("Socket.IO CORS allowed origins: %s", socketio_cors_allowed_origins)
# Ensure all plugins are loaded before starting the download coordinator.
# This prevents a race condition where the download loop could try to process
# a queued task before its handler (e.g., prowlarr) is registered.
try:
import_module("shelfmark.metadata_providers")
import_module("shelfmark.release_sources")
logger.debug("Plugin modules loaded successfully")
except ImportError as e:
logger.warning("Failed to import plugin modules: %s", e)
# Migrate legacy security settings if needed
_migrate_security_settings()
# Widen audiobook formats for installs that still carry the old m4b/mp3-only default
migrate_audiobook_format_settings()
# Initialize user database and register multi-user routes
# If CONFIG_DIR doesn't exist or is read-only, multi-user features will be disabled
_user_db_path = str(Path(os.environ.get("CONFIG_DIR", "/config")) / "users.db")
user_db: UserDB | None = None
download_history_service: DownloadHistoryService | None = None
activity_view_state_service: ActivityViewStateService | None = None
try:
user_db = UserDB(_user_db_path)
user_db.initialize()
download_history_service = DownloadHistoryService(_user_db_path)
activity_view_state_service = ActivityViewStateService(_user_db_path)
import_module("shelfmark.config.users_settings")
from shelfmark.core.admin_routes import register_admin_routes
from shelfmark.core.oidc_routes import register_oidc_routes
from shelfmark.core.self_user_routes import register_self_user_routes
register_oidc_routes(app, user_db)
register_admin_routes(app, user_db)
register_self_user_routes(app, user_db)
except (sqlite3.OperationalError, OSError) as e:
logger.warning(
"User database initialization failed: %s. Multi-user authentication features will be disabled. Ensure CONFIG_DIR (%s) exists and is writable.",
e,
os.environ.get("CONFIG_DIR", "/config"),
)
user_db = None
download_history_service = None
activity_view_state_service = None
# Start download coordinator
backend.start()
# Pre-solve the direct-download source's protection challenge in the background so the
# first user search does not pay for a cold Chrome bypass. Never blocks startup.
warmup.start()
# Rate limiting for login attempts
# Map usernames to their failed-attempt counters and lockout timestamps.
failed_login_attempts: dict[str, dict[str, Any]] = {}
MAX_LOGIN_ATTEMPTS = 10
LOCKOUT_DURATION_MINUTES = 30
LOGIN_ATTEMPT_WARNING_THRESHOLD = 5
def cleanup_old_lockouts() -> None:
"""Remove expired lockout entries to prevent memory buildup."""
current_time = datetime.now(UTC)
expired_users = []
for username in list(failed_login_attempts):
lockout_until = _get_lockout_until(username, repair_if_locked=True)
if lockout_until is not None and lockout_until < current_time:
expired_users.append(username)
for username in expired_users:
logger.info("Lockout expired for user: %s", username)
del failed_login_attempts[username]
def _get_lockout_until(username: str, *, repair_if_locked: bool = False) -> datetime | None:
"""Return a valid lockout timestamp for the user when one exists.
When a user has already crossed the lockout threshold but the timestamp is
missing or malformed, optionally repair the state to keep the lockout in
force rather than silently letting the user through.
"""
lockout_state = failed_login_attempts.get(username)
if lockout_state is None:
return None
lockout_until = lockout_state.get("lockout_until")
if isinstance(lockout_until, datetime):
return lockout_until
attempt_count = lockout_state.get("count")
if repair_if_locked and isinstance(attempt_count, int) and attempt_count >= MAX_LOGIN_ATTEMPTS:
repaired_lockout_until = datetime.now(UTC) + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
lockout_state["lockout_until"] = repaired_lockout_until
logger.warning("Repaired missing lockout timestamp for locked account '%s'", username)
return repaired_lockout_until
if lockout_until is not None:
logger.warning("Ignoring invalid lockout timestamp for user '%s'", username)
return None
def is_account_locked(username: str) -> bool:
"""Check if an account is currently locked due to failed login attempts."""
cleanup_old_lockouts()
if username not in failed_login_attempts:
return False
lockout_until = _get_lockout_until(username, repair_if_locked=True)
return lockout_until is not None and datetime.now(UTC) < lockout_until
def record_failed_login(username: str, ip_address: str) -> bool:
"""Record a failed login attempt and lock account if threshold is reached.
Returns True if account is now locked, False otherwise.
"""
if username not in failed_login_attempts:
failed_login_attempts[username] = {"count": 0}
failed_login_attempts[username]["count"] += 1
count = failed_login_attempts[username]["count"]
logger.warning(
"Failed login attempt %s/%s for user '%s' from IP %s",
count,
MAX_LOGIN_ATTEMPTS,
username,
ip_address,
)
if count >= MAX_LOGIN_ATTEMPTS:
lockout_until = datetime.now(UTC) + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
failed_login_attempts[username]["lockout_until"] = lockout_until
logger.warning(
"Account locked for user '%s' until %s due to %s failed login attempts",
username,
lockout_until.strftime("%Y-%m-%d %H:%M:%S"),
count,
)
return True
return False
def clear_failed_logins(username: str) -> None:
"""Clear failed login attempts for a user after successful login."""
if username in failed_login_attempts:
del failed_login_attempts[username]
logger.debug("Cleared failed login attempts for user: %s", username)
def get_client_ip() -> str:
"""Extract client IP address from request, handling reverse proxy forwarding."""
ip_address = request.headers.get("X-Forwarded-For", request.remote_addr) or "unknown"
# X-Forwarded-For can contain multiple IPs, take the first one
if "," in ip_address:
ip_address = ip_address.split(",")[0].strip()
return ip_address
def get_auth_mode() -> str:
"""Determine which authentication mode is active.
Uses configured AUTH_METHOD plus runtime prerequisites.
Returns "none" when config is invalid or unavailable.
"""
return load_active_auth_mode(CWA_DB_PATH, user_db=user_db)
_AUDIOBOOK_CATEGORY_RANGE = (3030, 3049)
_AUDIOBOOK_FORMAT_HINTS = frozenset(AUDIOBOOK_FORMATS)
def _contains_audiobook_format_hint(value: Any) -> bool:
if not isinstance(value, str):
return False
normalized = value.strip().lower()
if not normalized:
return False
tokens = [token for token in re.split(r"[^a-z0-9]+", normalized) if token]
return any(token in _AUDIOBOOK_FORMAT_HINTS for token in tokens)
def _resolve_release_content_type(data: dict[str, Any], source: Any) -> tuple[str, bool]:
"""Resolve release content type for policy checks and queue payload normalization."""
extra = data.get("extra")
if not isinstance(extra, dict):
extra = {}
explicit_content_type = data.get("content_type")
if explicit_content_type is None:
explicit_content_type = extra.get("content_type")
if explicit_content_type is not None:
return normalize_content_type(explicit_content_type), False
categories = extra.get("categories")
if isinstance(categories, list):
min_cat, max_cat = _AUDIOBOOK_CATEGORY_RANGE
for raw_category in categories:
try:
category_id = int(raw_category)
except TypeError, ValueError:
continue
if min_cat <= category_id <= max_cat:
return "audiobook", True
candidates: list[Any] = [
data.get("format"),
extra.get("format"),
extra.get("formats_display"),
data.get("title"),
]
formats = extra.get("formats")
if isinstance(formats, list):
candidates.extend(formats)
else:
candidates.append(formats)
if any(_contains_audiobook_format_hint(candidate) for candidate in candidates):
return "audiobook", True
capabilities = get_source_content_type_capabilities()
supported = capabilities.get(normalize_source(source))
if supported and len(supported) == 1:
return normalize_content_type(next(iter(supported))), True
return "ebook", False
def _resolve_policy_mode_for_current_user(*, source: Any, content_type: Any) -> PolicyMode | None:
"""Resolve policy mode for current session, or None when policy guard is bypassed."""
auth_mode = get_auth_mode()
if auth_mode == "none":
return None
if session.get("is_admin", False):
return None
if user_db is None:
return None
global_settings = load_users_request_policy_settings()
db_user_id = session.get("db_user_id")
user_settings: dict[str, Any] | None = None
if db_user_id is not None:
try:
user_settings = user_db.get_user_settings(int(db_user_id))
except TypeError, ValueError:
user_settings = None
effective = merge_request_policy_settings(global_settings, user_settings)
if not coerce_bool(effective.get("REQUESTS_ENABLED"), default=False):
return None
resolved_mode = resolve_policy_mode(
source=source,
content_type=content_type,
global_settings=global_settings,
user_settings=user_settings,
)
logger.debug(
"download policy resolve user=%s db_user_id=%s is_admin=%s source=%s content_type=%s mode=%s",
session.get("user_id"),
db_user_id,
bool(session.get("is_admin", False)),
source,
content_type,
resolved_mode.value,
)
return resolved_mode
def _policy_block_response(mode: PolicyMode) -> tuple[Response, int]:
logger.debug(
"download policy guard user=%s db_user_id=%s mode=%s",
session.get("user_id"),
session.get("db_user_id"),
mode.value,
)
if mode == PolicyMode.BLOCKED:
return (
jsonify(
{
"error": "Download not allowed by policy",
"code": "policy_blocked",
"required_mode": PolicyMode.BLOCKED.value,
}
),
403,
)
return (
jsonify(
{
"error": "Download not allowed by policy",
"code": "policy_requires_request",
"required_mode": mode.value,
}
),
403,
)
def _resolve_download_user_context(
db_user_id: Any,
username: Any,
on_behalf_of_user_id: Any,
) -> tuple[Any, Any, tuple[Response, int] | None]:
"""Resolve download queue user context, including optional admin on-behalf overrides."""
if on_behalf_of_user_id in (None, ""):
return db_user_id, username, None
if not session.get("is_admin", False):
return db_user_id, username, (jsonify({"error": "Admin required"}), 403)
if user_db is None:
return db_user_id, username, (jsonify({"error": "User database unavailable"}), 503)
try:
target_user_id = int(on_behalf_of_user_id)
except TypeError, ValueError:
return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400)
if target_user_id <= 0:
return db_user_id, username, (jsonify({"error": "Invalid on_behalf_of_user_id"}), 400)
target_user = user_db.get_user(user_id=target_user_id)
if not target_user:
return db_user_id, username, (jsonify({"error": "User not found"}), 404)
return target_user["id"], target_user["username"], None
def _emit_request_updates(rows: list[dict[str, Any]]) -> None:
"""Defer request update emission until the runtime hook is available."""
_emit_request_update_events(rows)
def _resolve_auth_mode_for_routes() -> str:
"""Resolve auth mode lazily so tests and runtime patches still take effect."""
return get_auth_mode()
def _queue_release_for_routes(*args: Any, **kwargs: Any) -> Any:
"""Queue a release via the current backend instance."""
return backend.queue_release(*args, **kwargs)
def _queue_status_for_routes(user_id: int | None = None) -> dict[str, dict[str, Any]]:
"""Read queue status via the current backend instance."""
return backend.queue_status(user_id=user_id)
if user_db is not None:
try:
from shelfmark.core.activity_routes import register_activity_routes
from shelfmark.core.request_routes import register_request_routes
register_request_routes(
app,
user_db,
resolve_auth_mode=_resolve_auth_mode_for_routes,
queue_release=_queue_release_for_routes,
ws_manager=ws_manager,
)
if download_history_service is not None and activity_view_state_service is not None:
register_activity_routes(
app,
user_db,
activity_view_state_service=activity_view_state_service,
download_history_service=download_history_service,
resolve_auth_mode=_resolve_auth_mode_for_routes,
queue_status=_queue_status_for_routes,
sync_request_delivery_states=sync_delivery_states_from_queue_status,
emit_request_updates=_emit_request_updates,
ws_manager=ws_manager,
)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.warning("Failed to register request routes: %s", e)
# Enable CORS in development mode for local frontend development
if _is_debug_enabled():
CORS(
app,
resources={
r"/*": {
"origins": ["http://localhost:5173", "http://127.0.0.1:5173"],
"supports_credentials": True,
"allow_headers": ["Content-Type", "Authorization", "X-Api-Key"],
"methods": ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
}
},
)
# Custom log filter to exclude routine status endpoint polling and WebSocket noise
class LogNoiseFilter(logging.Filter):
"""Filter out routine status endpoint requests and WebSocket upgrade errors to reduce log noise.
WebSocket upgrade errors are benign - Flask-SocketIO automatically falls back to polling transport.
The error occurs because Werkzeug's built-in server doesn't fully support WebSocket upgrades.
"""
def filter(self, record: logging.LogRecord) -> bool:
"""Return whether a log record should be emitted."""
message = record.getMessage() if hasattr(record, "getMessage") else str(record.msg)
# Exclude GET /api/status requests (polling noise)
if "GET /api/status" in message:
return False
# Exclude WebSocket upgrade errors (benign - falls back to polling)
if "write() before start_response" in message:
return False
# Exclude the Error on request line that precedes WebSocket errors
if record.levelno == logging.ERROR:
if "Error on request:" in message:
return False
# Filter WebSocket-related AssertionError tracebacks
if hasattr(record, "exc_info") and record.exc_info:
exc_type, exc_value = record.exc_info[0], record.exc_info[1]
if (
exc_type
and exc_type.__name__ == "AssertionError"
and exc_value
and "write() before start_response" in str(exc_value)
):
return False
return True
# Flask logger
app.logger.handlers = logger.handlers
app.logger.setLevel(logger.level)
# Also handle Werkzeug's logger
werkzeug_logger = logging.getLogger("werkzeug")
werkzeug_logger.handlers = logger.handlers
werkzeug_logger.setLevel(logger.level)
# Add filter to suppress routine status endpoint polling logs and WebSocket upgrade errors
werkzeug_logger.addFilter(LogNoiseFilter())
# Set up authentication defaults
SESSION_COOKIE_SECURE = string_to_bool(SESSION_COOKIE_SECURE_ENV)
def _load_or_create_secret_key() -> bytes:
"""Load a persisted Flask secret key from config, or create one."""
secret_path = CONFIG_DIR / ".flask_secret"
try:
if secret_path.exists():
secret_key = secret_path.read_bytes()
if len(secret_key) >= FLASK_SECRET_KEY_MIN_BYTES:
return secret_key
logger.warning(
"Invalid persisted Flask secret key at %s (length=%s). Regenerating.",
secret_path,
len(secret_key),
)
except OSError as exc:
logger.warning("Failed to read Flask secret key at %s: %s", secret_path, exc)
secret_key = os.urandom(64)
try:
secret_path.parent.mkdir(parents=True, exist_ok=True)
secret_path.write_bytes(secret_key)
secret_path.chmod(0o600)
except OSError as exc:
logger.warning(
"Failed to persist Flask secret key at %s. Sessions may reset on restart: %s",
secret_path,
exc,
)
return secret_key
app.config.update(
SECRET_KEY=_load_or_create_secret_key(),
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=SESSION_COOKIE_SECURE,
SESSION_COOKIE_NAME=SESSION_COOKIE_NAME,
PERMANENT_SESSION_LIFETIME=604800, # 7 days in seconds
)
logger.info(
"Session cookie secure setting: %s (from env: %s)",
SESSION_COOKIE_SECURE,
SESSION_COOKIE_SECURE_ENV,
)
logger.info("Session cookie name: %s", SESSION_COOKIE_NAME)
def _proxy_default_is_admin(db: UserDB) -> bool:
"""Role for a proxy user seen for the first time when no admin group is configured.
The first account ever provisioned is an admin so the instance is never left without
one; later accounts follow PROXY_AUTH_DEFAULT_ROLE (default: user).
"""
if not db.has_admin():
return True
role = str(app_config.get("PROXY_AUTH_DEFAULT_ROLE", "user") or "user").strip().lower()
return role == "admin"
_API_KEY_EXEMPT_PREFIXES = ("/api/auth/",)
_API_KEY_EXEMPT_PATHS = frozenset({"/api/health"})
@app.before_request
def api_key_auth_middleware() -> Response | tuple[Response, int] | None:
"""Authenticate requests that present the configured SHELFMARK_API_KEY.
Both Authorization: Bearer and X-Api-Key are checked, and either
matching authenticates the request as an admin for this request only:
any session cookie is ignored and none is written back. Checking both
means a reverse proxy's own Authorization header never shadows an
operator-supplied X-Api-Key. No matching candidate is ignored so bearer
tokens forwarded by reverse proxies keep working; the request then
continues on the normal session path.
"""
if not request.path.startswith("/api/"):
return None
if request.path in _API_KEY_EXEMPT_PATHS or request.path.startswith(_API_KEY_EXEMPT_PREFIXES):
return None
if not api_key_module.SHELFMARK_API_KEY:
return None
candidates = api_key_module.extract_api_key_candidates(
request.headers.get("Authorization"), request.headers.get("X-Api-Key")
)
if not candidates:
return None
if not any(api_key_module.matches_api_key(candidate) for candidate in candidates):
return None
if get_auth_mode() == "none":
return None
# Mark the request as keyed before the lookup so the after-request cookie
# reset also covers the error path below.
g.api_key_auth = True
try:
admin = user_db.get_first_admin() if user_db is not None else None
except _OPERATIONAL_ERRORS:
logger.exception("API key auth middleware error")
return jsonify({"error": "Authentication error"}), 500
session.clear()
session["user_id"] = admin["username"] if admin else "api"
session["is_admin"] = True
if admin:
session["db_user_id"] = admin["id"]
session.permanent = False
# Identity is per request; never persist it as a cookie.
session.modified = False
return None
@app.before_request
def proxy_auth_middleware() -> Response | tuple[Response, int] | None:
"""Middleware to handle proxy authentication.
When AUTH_METHOD is set to "proxy", this middleware automatically
authenticates users based on headers set by the reverse proxy.
"""
auth_mode = get_auth_mode()
# Only run for proxy auth mode
if auth_mode != "proxy":
return None
# A request already authenticated by SHELFMARK_API_KEY needs no proxy headers.
if g.get("api_key_auth"):
return None
# Skip for public endpoints that don't need auth
if request.path == "/api/health":
return None
def get_proxy_header(header_name: str) -> str | None:
"""Resolve proxy auth values from headers with WSGI env fallbacks."""
value = request.headers.get(header_name)
if value:
return value
env_key = f"HTTP_{header_name.upper().replace('-', '_')}"
value = request.environ.get(env_key)
if value:
return value
# Some proxies set authenticated username in REMOTE_USER (not as a header).
if header_name.lower().replace("_", "-") == "remote-user":
return request.environ.get("REMOTE_USER")
return None
try:
user_header = (
normalize_optional_text(app_config.get("PROXY_AUTH_USER_HEADER", "X-Auth-User"))
or "X-Auth-User"
)
# Extract username from proxy header
username = get_proxy_header(user_header)
if not username:
if request.path.startswith("/api/auth/"):
return None
logger.warning("Proxy auth enabled but no username found in header '%s'", user_header)
return jsonify({"error": "Authentication required. Proxy header not set."}), 401
# Resolve admin role for proxy sessions.
# If an admin group is configured, derive from groups header.
# Otherwise preserve the existing DB role for known users; a first-time user
# is an admin only while the instance has none (so nobody is locked out),
# after that PROXY_AUTH_DEFAULT_ROLE decides (default: user).
admin_group_header = (
normalize_optional_text(
app_config.get("PROXY_AUTH_ADMIN_GROUP_HEADER", "X-Auth-Groups")
)
or "X-Auth-Groups"
)
admin_group_name = (
normalize_optional_text(app_config.get("PROXY_AUTH_ADMIN_GROUP_NAME", "")) or ""
)
is_admin = True
if admin_group_name:
groups_header = get_proxy_header(admin_group_header) or ""
user_groups_delimiter = "," if "," in groups_header else "|"
user_groups = [
g.strip() for g in groups_header.split(user_groups_delimiter) if g.strip()
]
is_admin = admin_group_name in user_groups
elif user_db is not None:
existing_db_user = user_db.get_user(username=username)
if existing_db_user:
is_admin = existing_db_user.get("role") == "admin"
else:
is_admin = _proxy_default_is_admin(user_db)
# Create or update session
previous_username = session.get("user_id")
if previous_username and previous_username != username:
# Header identity changed mid-session; force reprovision for the new user.
session.pop("db_user_id", None)
session["user_id"] = username
session["is_admin"] = is_admin
# Provision proxy-authenticated users into users.db for multi-user features.
# Re-provision when db_user_id is missing/stale/mismatched to avoid broken
# sessions after DB resets or auth-mode transitions.
if user_db is not None:
raw_db_user_id = session.get("db_user_id")
session_db_user = None
if raw_db_user_id is not None:
try:
session_db_user = user_db.get_user(user_id=int(raw_db_user_id))
except TypeError, ValueError:
session_db_user = None
session_db_username = (
str(session_db_user.get("username") or "").strip() if session_db_user else ""
)
needs_db_user_sync = (
raw_db_user_id is None or session_db_user is None or session_db_username != username
)
if needs_db_user_sync:
role = "admin" if is_admin else "user"
db_user, _ = upsert_external_user(
user_db,
auth_source="proxy",
username=username,
role=role,
collision_strategy="takeover",
context="proxy_request",
)
if db_user is None:
_raise_runtime_error("Unexpected proxy user sync result: no user returned")
session["db_user_id"] = db_user["id"]
session.permanent = False
except _OPERATIONAL_ERRORS:
logger.exception("Proxy auth middleware error")
return jsonify({"error": "Authentication error"}), 500
else:
return None
@app.after_request
def set_security_headers(response: Response) -> Response:
"""Add baseline security headers to every response."""
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:",
)
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
return response
@app.after_request
def strip_cookie_for_api_key_requests(response: Response) -> Response:
"""Keyed requests never mint or refresh a session cookie, even if a handler dirties the session."""
if g.get("api_key_auth"):
# Setting `permanent` mutates the session dict (re-marking it
# modified), so it must be reset before `modified`, not after.
session.permanent = False
session.modified = False
return response
def login_required(
f: Callable[..., Response | tuple[Response, int]],
) -> Callable[..., Response | tuple[Response, int]]:
"""Require authentication for a Flask route."""
@wraps(f)
def decorated_function(*args: object, **kwargs: object) -> Response | tuple[Response, int]:
auth_mode = get_auth_mode()
# If no authentication is configured, allow access
if auth_mode == "none":
return f(*args, **kwargs)
# If CWA mode and database disappeared after startup, return error
if auth_mode == "cwa" and CWA_DB_PATH and not CWA_DB_PATH.exists():
logger.error("CWA database at %s is no longer accessible", CWA_DB_PATH)
return jsonify({"error": "Internal Server Error"}), 500
# Check if user has a valid session
if "user_id" not in session:
return jsonify({"error": "Unauthorized"}), 401
# Check admin access for settings/onboarding endpoints.
if is_settings_or_onboarding_path(request.path):
try:
if requires_admin_for_settings_access(request.path, {}) and not session.get(
"is_admin", False
):
return jsonify({"error": "Admin access required"}), 403
except RuntimeError, TypeError, ValueError:
logger.exception("Admin access check error")
return jsonify({"error": "Internal Server Error"}), 500
return f(*args, **kwargs)
return decorated_function
_BASE_TAG = '<base href="/" data-shelfmark-base />'
def _base_href() -> str:
if not BASE_PATH:
return "/"
return f"{BASE_PATH}/"
def _serve_index_html() -> Response:
"""Serve index.html with an adjusted base tag for subpath deployments."""
index_path = FRONTEND_DIST / "index.html"
try:
with index_path.open(encoding="utf-8") as handle:
html = handle.read()
except OSError:
return send_from_directory(FRONTEND_DIST, "index.html")
if BASE_PATH and _BASE_TAG in html:
html = html.replace(_BASE_TAG, f'<base href="{_base_href()}" data-shelfmark-base />', 1)
return Response(html, mimetype="text/html")
# Serve frontend static files
@app.route("/assets/<path:filename>")
def serve_frontend_assets(filename: str) -> Response:
"""Serve static assets from the built frontend."""
return send_from_directory(FRONTEND_DIST / "assets", filename)
@app.route("/")
def index() -> Response:
"""Serve the React frontend application.
Authentication is handled by the React app itself.
"""
return _serve_index_html()
@app.route("/theme-init.js")
def theme_init_js() -> Response:
"""Serve the blocking theme-init script."""
return send_from_directory(FRONTEND_DIST, "theme-init.js", mimetype="application/javascript")
@app.route("/logo.png")
def logo() -> Response:
"""Serve logo from built frontend assets."""
return send_from_directory(FRONTEND_DIST, "logo.png", mimetype="image/png")
@app.route("/favicon.ico")
@app.route("/favico<path:_>")
def favicon(_: Any = None) -> Response:
"""Serve favicon from built frontend assets."""
return send_from_directory(FRONTEND_DIST, "favicon.ico", mimetype="image/vnd.microsoft.icon")
if _is_debug_enabled():
import subprocess
def _stop_gui() -> None:
return None
if app_config.get("USING_EXTERNAL_BYPASSER", False):
pass
else:
from shelfmark.bypass.internal_bypasser import _cleanup_orphan_processes
def _stop_gui() -> None:
_cleanup_orphan_processes()
@app.route("/api/debug", methods=["GET"])
@login_required
def debug() -> Response | tuple[Response, int]:
"""Run `/app/genDebug.sh`, generate a debug zip, and return it.
The file is written to `/tmp/shelfmark-debug.zip` before being returned.
"""
try:
logger.info("Debug endpoint called, stopping GUI and generating debug info...")
_stop_gui()
time.sleep(1)
result = subprocess.run(
["/app/genDebug.sh"], capture_output=True, text=True, check=True
)
if result.returncode != 0:
_raise_runtime_error(f"Debug script failed: {result.stderr}")
logger.info("Debug script executed: %s", result.stdout)
debug_file_path = result.stdout.strip().split("\n")[-1]
if not Path(debug_file_path).exists():
logger.error("Debug zip file not found at: %s", debug_file_path)
return jsonify({"error": "Failed to generate debug information"}), 500
logger.info("Sending debug file: %s", debug_file_path)
return send_file(
debug_file_path,
mimetype="application/zip",
download_name=Path(debug_file_path).name,
as_attachment=True,
)
except subprocess.CalledProcessError as e:
logger.error_trace(f"Debug script error: {e}, stdout: {e.stdout}, stderr: {e.stderr}")
return jsonify({"error": f"Debug script failed: {e.stderr}"}), 500
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Debug endpoint error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/restart", methods=["GET"])
@login_required
def restart() -> Response | tuple[Response, int]:
"""Restart the application."""
os._exit(0)
def _parse_search_filters_from_request() -> SearchFilters:
"""Parse direct/source browse filters from query parameters."""
return SearchFilters(
isbn=request.args.getlist("isbn"),
author=request.args.getlist("author"),
title=request.args.getlist("title"),
lang=request.args.getlist("lang"),
sort=request.args.get("sort"),
content=request.args.getlist("content"),
format=request.args.getlist("format"),
)
def _build_source_query_book(query_text: str, filters: SearchFilters) -> BookMetadata:
"""Build a synthetic book context for source-native browse searches."""
from shelfmark.metadata_providers import BookMetadata
author_values = [value.strip() for value in (filters.author or []) if str(value).strip()]
title_values = [value.strip() for value in (filters.title or []) if str(value).strip()]
isbn_values = [value.strip() for value in (filters.isbn or []) if str(value).strip()]
title = (
title_values[0]
if title_values
else query_text
or (isbn_values[0] if isbn_values else "")
or (author_values[0] if author_values else "Direct Search")
)
author = author_values[0] if author_values else ""
return BookMetadata(
provider="manual",
provider_id=query_text or title,
provider_display_name="Manual Search",
title=title,
search_title=title,
search_author=author or None,
authors=author_values,
)
def _serialize_browse_record(record: BrowseRecord) -> dict:
"""Serialize a source-native browse record for the frontend."""
result = {key: value for key, value in record.__dict__.items() if value is not None}
preview = result.get("preview")
if isinstance(preview, str) and preview:
from shelfmark.core.utils import transform_cover_url
result["preview"] = transform_cover_url(preview, record.id)
return result
def _serialize_release(release: Release) -> dict:
"""Serialize a release for the frontend, normalizing preview URLs."""
from dataclasses import asdict
from shelfmark.core.utils import transform_cover_url
result = asdict(release)
extra = result.get("extra")
if isinstance(extra, dict):
preview = extra.get("preview")
if isinstance(preview, str) and preview:
extra = dict(extra)
extra["preview"] = transform_cover_url(preview, release.source_id)
result["extra"] = extra
return result
register_release_inspect_routes(app, login_required)
@app.route("/api/releases/download", methods=["POST"])
@login_required
def api_download_release() -> Response | tuple[Response, int]:
"""Queue a release for download.
This endpoint is used when downloading from the ReleaseModal where the
frontend already has all the release data from the search results.
Request Body (JSON):
source (str): Release source (e.g., "direct_download")
source_id (str): ID within the source (e.g., AA MD5 hash)
title (str): Book title
format (str, optional): File format
size (str, optional): Human-readable size
extra (dict, optional): Additional metadata
Returns:
flask.Response: JSON status object indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "No data provided"}), 400
if "source_id" not in data:
return jsonify({"error": "source_id is required"}), 400
if "source" not in data:
return jsonify({"error": "source is required"}), 400
source = data["source"]
resolved_content_type, inferred_content_type = _resolve_release_content_type(data, source)
policy_mode = _resolve_policy_mode_for_current_user(
source=source,
content_type=resolved_content_type,
)
if policy_mode is not None and policy_mode != PolicyMode.DOWNLOAD:
return _policy_block_response(policy_mode)
release_payload = data
if inferred_content_type and data.get("content_type") is None:
release_payload = dict(data)
release_payload["content_type"] = resolved_content_type
logger.info(
"Download request received. keys=%s downloads=%s extra.downloads=%s",
list(data.keys()),
data.get("downloads"),
data.get("extra", {}).get("downloads") if isinstance(data.get("extra"), dict) else None,
)
priority = data.get("priority", 0)
# Per-user download overrides
db_user_id = session.get("db_user_id")
_username = session.get("user_id")
db_user_id, _username, on_behalf_error = _resolve_download_user_context(
db_user_id,
_username,
data.get("on_behalf_of_user_id"),
)
if on_behalf_error:
return on_behalf_error
success, error_msg = backend.queue_release(
release_payload,
priority,
user_id=db_user_id,
username=_username,
)
if success:
return jsonify({"status": "queued", "priority": priority})
return jsonify({"error": error_msg or "Failed to queue release"}), 500
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Release download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/config", methods=["GET"])
@login_required
def api_config() -> Response | tuple[Response, int]:
"""Get application configuration for frontend.
Uses the dynamic config singleton to ensure settings changes
are reflected without requiring a container restart.
"""
try:
from shelfmark.config.env import _is_config_dir_writable
from shelfmark.core.onboarding import is_onboarding_complete as _get_onboarding_complete
from shelfmark.metadata_providers import (
get_provider_default_sort,
get_provider_search_fields,
get_provider_sort_options,
)
db_user_id = get_session_db_user_id(session)
search_mode = app_config.get("SEARCH_MODE", "universal", user_id=db_user_id)
default_release_source = app_config.get(
"DEFAULT_RELEASE_SOURCE",
"",
user_id=db_user_id,
)
default_release_source_audiobook = app_config.get(
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK",
"",
user_id=db_user_id,
)
configured_metadata_provider = normalize_optional_text(
app_config.get(
"METADATA_PROVIDER",
"",
user_id=db_user_id,
)
)
_configured_metadata_provider_audiobook = normalize_optional_text(
app_config.get(
"METADATA_PROVIDER_AUDIOBOOK",
"",
user_id=db_user_id,
)
)
metadata_ui_provider = (
configured_metadata_provider or _configured_metadata_provider_audiobook
)
config = {
"calibre_web_url": app_config.get("CALIBRE_WEB_URL", ""),
"audiobook_library_url": app_config.get("AUDIOBOOK_LIBRARY_URL", ""),
"search_page_title": app_config.get("SEARCH_PAGE_TITLE", "Shelfmark"),
"debug": app_config.get("DEBUG", False),
"build_version": BUILD_VERSION,
"release_version": RELEASE_VERSION,
"book_languages": _SUPPORTED_BOOK_LANGUAGE,
"default_language": app_config.get("BOOK_LANGUAGE", ["en"], user_id=db_user_id),
"default_content_type": app_config.get(
"DEFAULT_CONTENT_TYPE", "ebook", user_id=db_user_id
),
"supported_formats": app_config.SUPPORTED_FORMATS,
"supported_audiobook_formats": app_config.SUPPORTED_AUDIOBOOK_FORMATS,
"search_mode": search_mode,
"metadata_sort_options": get_provider_sort_options(metadata_ui_provider),
"metadata_search_fields": get_provider_search_fields(metadata_ui_provider),
"default_release_source": default_release_source,
"default_release_source_audiobook": default_release_source_audiobook,
"show_release_source_links": app_config.get("SHOW_RELEASE_SOURCE_LINKS", True),
"show_combined_selector": app_config.get(
"SHOW_COMBINED_SELECTOR", True, user_id=db_user_id
),
"force_combined_search": app_config.get(
"FORCE_COMBINED_SEARCH", False, user_id=db_user_id
),
"books_output_mode": app_config.get("BOOKS_OUTPUT_MODE", "folder"),
"auto_open_downloads_sidebar": app_config.get("AUTO_OPEN_DOWNLOADS_SIDEBAR", True),
"hardcover_auto_remove_on_download": app_config.get(
"HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD", True
),
"download_to_browser_content_types": app_config.get(
"DOWNLOAD_TO_BROWSER_CONTENT_TYPES",
[],
user_id=db_user_id,
),
# The client must not give up before this budget does. `/api/releases`
# answers a spent budget with a message naming the real cause (a protection
# challenge nobody could solve); a browser that aborted first replaces it
# with a generic network/proxy error and RELEASE_SEARCH_TIMEOUT becomes a
# setting the user can raise with no visible effect. See issue #1285.
"release_search_timeout": search_deadline.budget_seconds(),
"settings_enabled": _is_config_dir_writable(),
"onboarding_complete": _get_onboarding_complete(),
# Default sort orders
"default_sort": app_config.get(
"AA_DEFAULT_SORT", ""
), # For direct mode (Anna's Archive) — empty means use local downloads sort
"metadata_default_sort": get_provider_default_sort(
metadata_ui_provider
), # For universal mode
}
return jsonify(config)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Config error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/health", methods=["GET"])
def api_health() -> Response | tuple[Response, int]:
"""Health check endpoint for container orchestration.
No authentication required.
Returns:
flask.Response: JSON with status "ok" and optional degraded features.
"""
response: dict[str, object] = {"status": "ok"}
# Report degraded features
if not backend.WEBSOCKET_AVAILABLE:
response["degraded"] = {"websocket": "WebSocket unavailable - real-time updates disabled"}
return jsonify(response)
def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool, int | None, bool]:
"""Resolve queue-status visibility from session state.
Returns:
(is_admin, db_user_id, can_access_status)
"""
auth_mode = get_auth_mode()
if auth_mode == "none":
return True, None, True
if require_authenticated and "user_id" not in session:
return False, None, False
is_admin = bool(session.get("is_admin", False))
if is_admin:
return True, None, True
db_user_id = get_session_db_user_id(session)
if db_user_id is None:
return False, None, False
return False, db_user_id, True
def _queue_status_to_final_activity_status(status: QueueStatus) -> str | None:
return status.value if status in TERMINAL_QUEUE_STATUSES else None
def _queue_status_to_notification_event(status: QueueStatus) -> NotificationEvent | None:
if status == QueueStatus.COMPLETE:
return NotificationEvent.DOWNLOAD_COMPLETE
if status == QueueStatus.ERROR:
return NotificationEvent.DOWNLOAD_FAILED
return None
def _notify_admin_for_terminal_download_status(
*, task_id: str, status: QueueStatus, task: Any
) -> None:
event = _queue_status_to_notification_event(status)
if event is None:
return
raw_owner_user_id = getattr(task, "user_id", None)
try:
owner_user_id = int(raw_owner_user_id) if raw_owner_user_id is not None else None
except TypeError, ValueError:
owner_user_id = None
content_type = normalize_optional_text(getattr(task, "content_type", None))
context = NotificationContext(
event=event,
title=str(getattr(task, "title", "Unknown title") or "Unknown title"),
author=str(getattr(task, "author", "Unknown author") or "Unknown author"),
username=normalize_optional_text(getattr(task, "username", None)),
content_type=normalize_content_type(content_type) if content_type is not None else None,
format=normalize_optional_text(getattr(task, "format", None)),
source=normalize_source(getattr(task, "source", None)),
error_message=(
normalize_optional_text(getattr(task, "status_message", None))
if event == NotificationEvent.DOWNLOAD_FAILED
else None
),
)
try:
notify_admin(event, context)
except (RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"Failed to trigger admin notification for download %s (%s): %s",
task_id,
status.value,
exc,
)
if owner_user_id is None:
return
try:
notify_user(owner_user_id, event, context)
except (RuntimeError, TypeError, ValueError) as exc:
logger.warning(
"Failed to trigger user notification for download %s (%s, user_id=%s): %s",
task_id,
status.value,
owner_user_id,
exc,
)
def _emit_activity_update_for_task(*, payload: dict[str, Any], task: Any) -> None:
owner_user_id = normalize_positive_int(getattr(task, "user_id", None))
emit_ws_event(
ws_manager,
event_name="activity_update",
room="admins",
payload=payload,
)
if owner_user_id is None:
return
emit_ws_event(
ws_manager,
event_name="activity_update",
room=f"user_{owner_user_id}",
payload=payload,
)
def _record_download_queued(task_id: str, task: Any) -> None:
"""Persist initial download record when a task enters the queue."""
if download_history_service is None:
return
owner_user_id = normalize_positive_int(getattr(task, "user_id", None))
request_id = normalize_positive_int(getattr(task, "request_id", None))
origin = "requested" if request_id else "direct"
source_name = normalize_source(getattr(task, "source", None))
source_display = get_source_display_name(source_name)
try:
download_history_service.record_download(
task_id=task_id,
user_id=owner_user_id,
username=normalize_optional_text(getattr(task, "username", None)),
request_id=request_id,
source=source_name,
source_display_name=source_display,
title=str(getattr(task, "title", "Unknown title") or "Unknown title"),
author=normalize_optional_text(getattr(task, "author", None)),
file_format=normalize_optional_text(getattr(task, "format", None)),
size=normalize_optional_text(getattr(task, "size", None)),
preview=normalize_optional_text(getattr(task, "preview", None)),
content_type=normalize_optional_text(getattr(task, "content_type", None)),
downloads=getattr(task, "downloads", None),
origin=origin,
retry_payload=backend.serialize_task_for_retry(task),
)
except _OPERATIONAL_ERRORS as exc:
logger.warning("Failed to record download at queue time for task %s: %s", task_id, exc)
return
if activity_view_state_service is None:
return
try:
cleared_view_state = 0
cleared_view_state += activity_view_state_service.clear_item_for_all_viewers(
item_type="download",
item_key=f"download:{task_id}",
)
if request_id is not None:
cleared_view_state += activity_view_state_service.clear_item_for_all_viewers(
item_type="request",
item_key=f"request:{request_id}",
)
if cleared_view_state > 0:
_emit_activity_update_for_task(
task=task,
payload={
"kind": "activity_reset",
"task_id": task_id,
},
)
except _OPERATIONAL_ERRORS as exc:
logger.warning("Failed to reset activity viewer state for task %s: %s", task_id, exc)
def _record_download_terminal_snapshot(task_id: str, status: QueueStatus, task: Any) -> None:
_notify_admin_for_terminal_download_status(task_id=task_id, status=status, task=task)
final_status = _queue_status_to_final_activity_status(status)
if final_status is None:
return
finalized_download = False
if download_history_service is not None:
try:
download_history_service.finalize_download(
task_id=task_id,
final_status=final_status,
status_message=normalize_optional_text(getattr(task, "status_message", None)),
download_path=normalize_optional_text(getattr(task, "download_path", None)),
retry_payload=backend.serialize_task_for_retry(task),
)
finalized_download = True
except _OPERATIONAL_ERRORS as exc:
logger.warning("Failed to finalize download history for task %s: %s", task_id, exc)
if finalized_download:
_emit_activity_update_for_task(
task=task,
payload={
"kind": "download_terminal",
"task_id": task_id,
"status": final_status,
},
)
if user_db is None or status != QueueStatus.ERROR:
return
request_id = normalize_positive_int(getattr(task, "request_id", None))
if request_id is None:
return
if backend.can_retry_download_task(task, status):
return
raw_error_message = getattr(task, "status_message", None)
fallback_reason = (
raw_error_message.strip()
if isinstance(raw_error_message, str) and raw_error_message.strip()
else "Download failed"
)
try:
reopened_request = reopen_failed_request(
user_db,
request_id=request_id,
failure_reason=fallback_reason,
)
if reopened_request is not None:
if activity_view_state_service is not None:
activity_view_state_service.clear_item_for_all_viewers(
item_type="request",
item_key=f"request:{request_id}",
)
_emit_request_update_events([reopened_request])
except _OPERATIONAL_ERRORS as exc:
logger.warning(
"Failed to reopen request %s after terminal download error %s: %s",
request_id,
task_id,
exc,
)
def _task_owned_by_actor(
task: Any, *, actor_user_id: int | None, actor_username: str | None
) -> bool:
raw_task_user_id = getattr(task, "user_id", None)
try:
task_user_id = int(raw_task_user_id) if raw_task_user_id is not None else None
except TypeError, ValueError:
task_user_id = None
if actor_user_id is not None and task_user_id is not None:
return task_user_id == actor_user_id
task_username = getattr(task, "username", None)
if isinstance(task_username, str) and task_username.strip() and isinstance(actor_username, str):
return task_username.strip() == actor_username.strip()
return False
def _download_row_owned_by_actor(
row: dict[str, Any],
*,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
owner_user_id = normalize_positive_int(row.get("user_id"))
if actor_user_id is not None and owner_user_id is not None:
return owner_user_id == actor_user_id
row_username = normalize_optional_text(row.get("username"))
if row_username is not None and isinstance(actor_username, str):
return row_username == actor_username.strip()
return False
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
is_admin, db_user_id, can_access_status = _resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin and (not can_access_status or db_user_id is None):
return (
is_admin,
db_user_id,
normalized_actor_username,
jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}),
)
return is_admin, db_user_id, normalized_actor_username, None
def _queue_task_visible_to_actor(
task_id: str,
*,
is_admin: bool,
actor_user_id: int | None,
actor_username: str | None,
) -> bool:
if is_admin:
return True
task = backend.book_queue.get_task(task_id)
if task is None:
return False
return _task_owned_by_actor(
task,
actor_user_id=actor_user_id,
actor_username=actor_username,
)
backend.book_queue.set_queue_hook(_record_download_queued)
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
def _emit_request_update_events(updated_requests: list[dict[str, Any]]) -> None:
"""Broadcast request_update events for rows changed by delivery-state sync."""
if not updated_requests or ws_manager is None:
return
for updated in updated_requests:
payload = {
"request_id": updated["id"],
"status": updated["status"],
"delivery_state": updated.get("delivery_state"),
"title": (updated.get("book_data") or {}).get("title") or "Unknown title",
}
emit_ws_event(
ws_manager,
event_name="request_update",
room=f"user_{updated['user_id']}",
payload=payload,
)
emit_ws_event(
ws_manager,
event_name="request_update",
room="admins",
payload=payload,
)
@app.route("/api/status", methods=["GET"])
@login_required
def api_status() -> Response | tuple[Response, int]:
"""Get current download queue status.
Returns:
flask.Response: JSON object with queue status.
"""
try:
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if not can_access_status:
return jsonify({})
user_id = None if is_admin else db_user_id
status = backend.queue_status(user_id=user_id)
if user_db is not None:
updated_requests = sync_delivery_states_from_queue_status(
user_db,
queue_status=status,
user_id=user_id,
)
_emit_request_update_events(updated_requests)
return jsonify(status)
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Status error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/localdownload", methods=["GET"])
@login_required
def api_local_download() -> Response | tuple[Response, int]:
"""Download an EPUB file from local storage if available.
Query Parameters:
id (str): Book identifier (MD5 hash)
Returns:
flask.Response: The EPUB file if found, otherwise an error response.
"""
book_id = request.args.get("id", "")
if not book_id:
return jsonify({"error": "No book ID provided"}), 400
try:
# The path, not the bytes: send_file streams it off disk, where reading it
# first cost two resident copies of the whole book.
file_path, book_info = backend.get_book_path(book_id)
if file_path is None:
# Fallback for dismissed/history entries where queue task may no longer exist.
if download_history_service is not None:
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if can_access_status:
history_row = download_history_service.get_by_task_id(book_id)
if history_row is not None:
owner_user_id = history_row.get("user_id")
if is_admin or owner_user_id == db_user_id:
download_path = DownloadHistoryService._resolve_existing_download_path(
history_row.get("download_path")
)
if download_path:
return send_file(
download_path,
download_name=Path(download_path).name,
as_attachment=True,
)
# Book data not found or not available
return jsonify({"error": "File not found"}), 404
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if not is_admin:
actor_username = session.get("user_id")
if not can_access_status or not _task_owned_by_actor(
book_info,
actor_user_id=db_user_id,
actor_username=actor_username if isinstance(actor_username, str) else None,
):
return jsonify({"error": "File not found"}), 404
file_name = book_info.get_filename() if book_info is not None else Path(book_id).name
return send_file(file_path, download_name=file_name, as_attachment=True)
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Local download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/covers/<cover_id>", methods=["GET"])
@login_required
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
"""Serve a cached book cover image.
This endpoint proxies and caches cover images from external sources.
Images are cached to disk for faster subsequent requests.
Path Parameters:
cover_id (str): Cover identifier (book ID or composite key for universal mode)
Query Parameters:
url (str): Base64-encoded original image URL (required on first request)
Returns:
flask.Response: Binary image data with appropriate Content-Type, or 404.
"""
try:
import base64
from shelfmark.config.env import is_covers_cache_enabled
from shelfmark.core.image_cache import get_image_cache
# Check if caching is enabled
if not is_covers_cache_enabled():
return jsonify({"error": "Cover caching is disabled"}), 404
cache = get_image_cache()
# Try to get from cache first
cached = cache.get(cover_id)
if cached:
image_data, content_type = cached
response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "HIT"
return response
# Cache miss - get URL from query parameter
encoded_url = request.args.get("url")
if not encoded_url:
return jsonify({"error": "Cover URL not provided"}), 404
try:
original_url = base64.urlsafe_b64decode(encoded_url).decode()
except (binascii.Error, UnicodeDecodeError) as e:
logger.warning("Failed to decode cover URL: %s", e)
return jsonify({"error": "Invalid cover URL encoding"}), 400
# Fetch and cache the image
result = cache.fetch_and_cache(cover_id, original_url)
if not result:
return jsonify({"error": "Failed to fetch cover image"}), 404
image_data, content_type = result
response = app.response_class(response=image_data, status=200, mimetype=content_type)
response.headers["Cache-Control"] = "public, max-age=86400"
response.headers["X-Cache"] = "MISS"
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cover fetch error: {e}")
return jsonify({"error": str(e)}), 500
else:
return response
@app.route("/api/download/<path:book_id>/cancel", methods=["DELETE"])
@login_required
def api_cancel_download(book_id: str) -> Response | tuple[Response, int]:
"""Cancel a download.
Path Parameters:
book_id (str): Book identifier to cancel
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to cancel download or book not found"}), 404
is_admin, db_user_id, can_access_status = _resolve_status_scope()
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
), 403
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
if getattr(task, "request_id", None) is not None:
return jsonify(
{"error": "Forbidden", "code": "requested_download_cancel_forbidden"}
), 403
success = backend.cancel_download(book_id)
if success:
return jsonify({"status": "cancelled", "book_id": book_id})
return jsonify({"error": "Failed to cancel download or book not found"}), 404
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Cancel download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/download/<path:book_id>/retry", methods=["POST"])
@login_required
def api_retry_download(book_id: str) -> Response | tuple[Response, int]:
"""Retry a failed download."""
try:
task = backend.book_queue.get_task(book_id)
history_row = None
if task is None and download_history_service is not None:
history_row = download_history_service.get_by_task_id(book_id)
if task is None and history_row is None:
return jsonify({"error": "Download not found"}), 404
is_admin, db_user_id, can_access_status = _resolve_status_scope()
actor_username = session.get("user_id")
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
if not is_admin:
if not can_access_status or db_user_id is None:
return jsonify(
{"error": "User identity unavailable", "code": "user_identity_unavailable"}
), 403
if task is not None:
if not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
elif history_row is None or not _download_row_owned_by_actor(
history_row,
actor_user_id=db_user_id,
actor_username=normalized_actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
if task is not None:
task_status = backend.book_queue.get_task_status(book_id)
if getattr(
task, "request_id", None
) is not None and not backend.can_retry_download_task(task, task_status):
return jsonify(
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
), 403
success, error = backend.retry_download(book_id)
else:
if history_row is None:
logger.error("Download history row disappeared while retrying task %s", book_id)
return jsonify({"error": "Download history not found"}), 404
request_id = normalize_positive_int(history_row.get("request_id"))
retry_payload = history_row.get("retry_payload")
final_status = history_row.get("final_status")
if request_id is not None:
history_service = download_history_service
if history_service is None:
logger.error(
"Download history service unavailable while retrying task %s", book_id
)
return jsonify({"error": "Download history unavailable"}), 500
if not history_service.is_retry_available(history_row):
return jsonify(
{"error": "Forbidden", "code": "requested_download_retry_forbidden"}
), 403
success, error = backend.retry_persisted_download(
retry_payload,
final_status=final_status,
)
if success:
return jsonify({"status": "queued", "book_id": book_id})
if error == "Download not found":
return jsonify({"error": error}), 404
return jsonify({"error": error or "Download cannot be retried"}), 409
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Retry download error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/queue/<path:book_id>/priority", methods=["PUT"])
@login_required
def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
"""Set priority for a queued book.
Path Parameters:
book_id (str): Book identifier
Request Body:
priority (int): New priority level (lower number = higher priority)
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data or "priority" not in data:
return jsonify({"error": "Priority not provided"}), 400
priority = int(data["priority"])
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
task = backend.book_queue.get_task(book_id)
if task is None:
return jsonify({"error": "Failed to update priority or book not found"}), 404
if not is_admin and not _task_owned_by_actor(
task,
actor_user_id=db_user_id,
actor_username=actor_username,
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
success = backend.set_book_priority(book_id, priority)
if success:
return jsonify({"status": "updated", "book_id": book_id, "priority": priority})
return jsonify({"error": "Failed to update priority or book not found"}), 404
except ValueError:
return jsonify({"error": "Invalid priority value"}), 400
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Set priority error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/queue/reorder", methods=["POST"])
@login_required
def api_reorder_queue() -> Response | tuple[Response, int]:
"""Bulk reorder queue by setting new priorities.
Request Body:
book_priorities (dict): Mapping of book_id to new priority
Returns:
flask.Response: JSON status indicating success or failure.
"""
try:
data = request.get_json(silent=True)
if not data or "book_priorities" not in data:
return jsonify({"error": "book_priorities not provided"}), 400
book_priorities = data["book_priorities"]
if not isinstance(book_priorities, dict):
return jsonify({"error": "book_priorities must be a dictionary"}), 400
# Validate all priorities are integers
for book_id, priority in book_priorities.items():
if not isinstance(priority, int):
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
owned_book_priorities = {}
for book_id in book_priorities:
task = backend.book_queue.get_task(str(book_id))
if task is None:
continue
if not _task_owned_by_actor(
task, actor_user_id=db_user_id, actor_username=actor_username
):
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
owned_book_priorities[book_id] = book_priorities[book_id]
book_priorities = owned_book_priorities
success = backend.reorder_queue(book_priorities)
if success:
return jsonify({"status": "reordered", "updated_count": len(book_priorities)})
return jsonify({"error": "Failed to reorder queue"}), 500
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Reorder queue error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/queue/order", methods=["GET"])
@login_required
def api_queue_order() -> Response | tuple[Response, int]:
"""Get current queue order for display.
Returns:
flask.Response: JSON array of queued books with their order and priorities.
"""
try:
queue_order = backend.get_queue_order()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
queue_order = [
item
for item in queue_order
if _queue_task_visible_to_actor(
str(item.get("id", "")),
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"queue": queue_order})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Queue order error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/downloads/active", methods=["GET"])
@login_required
def api_active_downloads() -> Response | tuple[Response, int]:
"""Get list of currently active downloads.
Returns:
flask.Response: JSON array of active download book IDs.
"""
try:
active_downloads = backend.get_active_downloads()
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
if identity_error is not None:
return identity_error, 403
if not is_admin:
active_downloads = [
task_id
for task_id in active_downloads
if _queue_task_visible_to_actor(
task_id,
is_admin=False,
actor_user_id=db_user_id,
actor_username=actor_username,
)
]
return jsonify({"active_downloads": active_downloads})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Active downloads error: {e}")
return jsonify({"error": str(e)}), 500
@app.errorhandler(404)
def not_found_error(error: Exception) -> Response | tuple[Response, int]:
"""Handle 404 (Not Found) errors.
Args:
error (HTTPException): The 404 error raised by Flask.
Returns:
flask.Response: JSON error message with 404 status.
"""
logger.warning("404 error: %s : %s", request.url, error)
return jsonify({"error": "Resource not found"}), 404
@app.errorhandler(500)
def internal_error(error: Exception) -> Response | tuple[Response, int]:
"""Handle 500 (Internal Server) errors.
Args:
error (HTTPException): The 500 error raised by Flask.
Returns:
flask.Response: JSON error message with 500 status.
"""
logger.error_trace(f"500 error: {error}")
return jsonify({"error": "Internal server error"}), 500
def _failed_login_response(username: str, ip_address: str) -> tuple[Response, int]:
"""Handle a failed login attempt by recording it and returning the appropriate response."""
is_now_locked = record_failed_login(username, ip_address)
if is_now_locked:
return jsonify(
{
"error": f"Account locked due to {MAX_LOGIN_ATTEMPTS} failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes."
}
), 429
attempts_remaining = MAX_LOGIN_ATTEMPTS - failed_login_attempts[username]["count"]
if attempts_remaining <= LOGIN_ATTEMPT_WARNING_THRESHOLD:
return jsonify(
{"error": f"Invalid username or password. {attempts_remaining} attempts remaining."}
), 401
return jsonify({"error": "Invalid username or password."}), 401
@app.route("/api/auth/login", methods=["POST"])
def api_login() -> Response | tuple[Response, int]:
"""Login endpoint that validates credentials and creates a session.
Supports both built-in credentials and CWA database authentication.
Includes rate limiting: 10 failed attempts = 30 minute lockout.
Request Body:
username (str): Username
password (str): Password
remember_me (bool): Whether to extend session duration
Returns:
flask.Response: JSON with success status or error message.
"""
try:
ip_address = get_client_ip()
data = request.get_json(silent=True)
if not data:
return jsonify({"error": "No data provided"}), 400
auth_mode = get_auth_mode()
if auth_mode == "proxy":
return jsonify({"error": "Proxy authentication is enabled"}), 401
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
return jsonify({"error": "Local authentication is disabled"}), 403
if auth_mode == "oidc" and HIDE_LOCAL_AUTH:
return jsonify({"error": "Local authentication is disabled"}), 403
username = data.get("username", "").strip()
password = data.get("password", "")
remember_me = data.get("remember_me", False)
if not username or not password:
return jsonify({"error": "Username and password are required"}), 400
# Check if account is locked due to failed login attempts
if is_account_locked(username):
lockout_until = _get_lockout_until(username, repair_if_locked=True)
if lockout_until is None:
logger.error("Locked account '%s' is missing a lockout timestamp", username)
return jsonify(
{
"error": f"Account temporarily locked due to multiple failed login attempts. Try again in {LOCKOUT_DURATION_MINUTES} minutes."
}
), 429
remaining_time = (lockout_until - datetime.now(UTC)).total_seconds() / 60
logger.warning(
"Login attempt blocked for locked account '%s' from IP %s", username, ip_address
)
return jsonify(
{
"error": f"Account temporarily locked due to multiple failed login attempts. Try again in {int(remaining_time)} minutes."
}
), 429
# If no authentication is configured, authentication always succeeds
if auth_mode == "none":
session["user_id"] = username
session.permanent = remember_me
clear_failed_logins(username)
logger.info(
"Login successful for user '%s' from IP %s (no auth configured)",
username,
ip_address,
)
return jsonify({"success": True})
# Password authentication (builtin and OIDC modes)
# OIDC mode also allows password login as a fallback so admins don't get locked out
if auth_mode in ("builtin", "oidc"):
if user_db is None:
logger.error("User database not available for %s auth", auth_mode)
return jsonify({"error": "Authentication service unavailable"}), 503
try:
db_user = user_db.get_user(username=username)
if not db_user:
return _failed_login_response(username, ip_address)
# Authenticate against DB user
if db_user:
if not db_user.get("password_hash") or not check_password_hash(
db_user["password_hash"], password
):
return _failed_login_response(username, ip_address)
is_admin = db_user["role"] == "admin"
session["user_id"] = username
session["db_user_id"] = db_user["id"]
session["is_admin"] = is_admin
session.permanent = remember_me
clear_failed_logins(username)
logger.info(
"Login successful for user '%s' from IP %s (%s auth, is_admin=%s, remember_me=%s)",
username,
ip_address,
auth_mode,
is_admin,
remember_me,
)
return jsonify({"success": True})
return _failed_login_response(username, ip_address)
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Built-in auth error: {e}")
return jsonify({"error": "Authentication system error"}), 500
# CWA database authentication mode
if auth_mode == "cwa":
# Verify database still exists (it was validated at startup)
if not CWA_DB_PATH or not CWA_DB_PATH.exists():
logger.error("CWA database at %s is no longer accessible", CWA_DB_PATH)
return jsonify({"error": "Database configuration error"}), 500
try:
db_path = os.fspath(CWA_DB_PATH)
db_uri = f"file:{db_path}?mode=ro&immutable=1"
conn = sqlite3.connect(db_uri, uri=True)
cur = conn.cursor()
cur.execute("SELECT password, role, email FROM user WHERE name = ?", (username,))
row = cur.fetchone()
conn.close()
# Check if user exists and password is correct
if not row or not row[0] or not check_password_hash(row[0], password):
return _failed_login_response(username, ip_address)
# Check if user has admin role (ROLE_ADMIN = 1, bit flag)
user_role = row[1] if row[1] is not None else 0
is_admin = (user_role & 1) == 1
cwa_email = row[2] or None
db_user_id = None
if user_db is not None:
role = "admin" if is_admin else "user"
db_user, _ = upsert_cwa_user(
user_db,
cwa_username=username,
cwa_email=cwa_email,
role=role,
context="cwa_login",
)
db_user_id = db_user["id"]
# Successful authentication - create session and clear failed attempts
session["user_id"] = username
session["is_admin"] = is_admin
if db_user_id is not None:
session["db_user_id"] = db_user_id
session.permanent = remember_me
clear_failed_logins(username)
logger.info(
"Login successful for user '%s' from IP %s (CWA auth, is_admin=%s, remember_me=%s)",
username,
ip_address,
is_admin,
remember_me,
)
return jsonify({"success": True})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"CWA database error during login: {e}")
return jsonify({"error": "Authentication system error"}), 500
# Should not reach here, but handle gracefully
return jsonify({"error": "Unknown authentication mode"}), 500
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Login error: {e}")
return jsonify({"error": "Login failed"}), 500
@app.route("/api/auth/logout", methods=["POST"])
def api_logout() -> Response | tuple[Response, int]:
"""Logout endpoint that clears the session.
For proxy auth, returns the logout URL if configured.
Returns:
flask.Response: JSON with success status and optional logout_url.
"""
try:
auth_mode = get_auth_mode()
ip_address = get_client_ip()
username = session.get("user_id", "unknown")
session.clear()
logger.info("Logout successful for user '%s' from IP %s", username, ip_address)
# For proxy auth, include logout URL if configured
if auth_mode == "proxy":
logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "")
if logout_url:
return jsonify({"success": True, "logout_url": logout_url})
return jsonify({"success": True})
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Logout error: {e}")
return jsonify({"error": "Logout failed"}), 500
@app.route("/api/auth/check", methods=["GET"])
def api_auth_check() -> Response | tuple[Response, int]:
"""Check if user has a valid session.
Returns:
flask.Response: JSON with authentication status, whether auth is required,
which auth mode is active, and whether user has admin privileges.
"""
try:
auth_mode = get_auth_mode()
# If no authentication is configured, access is allowed (full admin)
if auth_mode == "none":
return jsonify(
{
"authenticated": True,
"auth_required": False,
"auth_mode": "none",
"is_admin": True,
}
)
# Check if user has a valid session
is_authenticated = "user_id" in session
is_admin = get_auth_check_admin_status(auth_mode, {}, session)
display_name = None
if is_authenticated and session.get("db_user_id") and user_db is not None:
try:
db_user = user_db.get_user(user_id=session["db_user_id"])
if db_user:
display_name = db_user.get("display_name") or None
except (sqlite3.Error, TypeError, ValueError) as exc:
logger.debug("Could not load display name for session user: %s", exc)
response_data = {
"authenticated": is_authenticated,
"auth_required": True,
"auth_mode": auth_mode,
"is_admin": is_admin if is_authenticated else False,
"username": session.get("user_id") if is_authenticated else None,
"display_name": display_name,
}
# Add logout URL for proxy auth if configured
if auth_mode == "proxy" and app_config.get("PROXY_AUTH_USER_HEADER", ""):
logout_url = app_config.get("PROXY_AUTH_LOGOUT_URL", "")
if logout_url:
response_data["logout_url"] = logout_url
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
response_data["hide_local_auth"] = True
# Add custom OIDC button label and SSO enforcement flags if configured
if auth_mode == "oidc":
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
if oidc_button_label:
response_data["oidc_button_label"] = oidc_button_label
if HIDE_LOCAL_AUTH:
response_data["hide_local_auth"] = True
if OIDC_AUTO_REDIRECT:
response_data["oidc_auto_redirect"] = True
return jsonify(response_data)
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Auth check error: {e}")
return jsonify(
{
"authenticated": False,
"auth_required": True,
"auth_mode": "unknown",
"is_admin": False,
}
)
@app.route("/api/metadata/providers", methods=["GET"])
@login_required
def api_metadata_providers() -> Response | tuple[Response, int]:
"""Get list of available metadata providers.
Returns:
flask.Response: JSON with list of providers and their status.
"""
try:
from shelfmark.metadata_providers import (
get_configured_provider_name,
get_provider,
get_provider_kwargs,
list_providers,
)
app_config.refresh()
db_user_id = get_session_db_user_id(session)
configured_metadata_provider = get_configured_provider_name(
content_type="ebook",
user_id=db_user_id,
fallback_to_main=True,
)
configured_audiobook_metadata_provider = get_configured_provider_name(
content_type="audiobook",
user_id=db_user_id,
fallback_to_main=False,
)
configured_combined_metadata_provider = get_configured_provider_name(
content_type="combined",
user_id=db_user_id,
fallback_to_main=False,
)
providers = []
for info in list_providers():
enabled_key = f"{info['name'].upper()}_ENABLED"
provider_info = {
"name": info["name"],
"display_name": info["display_name"],
"requires_auth": info["requires_auth"],
"enabled": app_config.get(enabled_key, False) is True,
"available": False,
}
try:
kwargs = get_provider_kwargs(info["name"])
provider = get_provider(info["name"], **kwargs)
provider_info["available"] = provider.is_available()
except _OPERATIONAL_ERRORS as exc:
logger.debug(
"Metadata provider %s availability check failed: %s",
info["name"],
exc,
)
providers.append(provider_info)
return jsonify(
{
"providers": providers,
"configured_provider": configured_metadata_provider or None,
"configured_provider_audiobook": configured_audiobook_metadata_provider or None,
"configured_provider_combined": configured_combined_metadata_provider or None,
}
)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Metadata providers error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/metadata/config", methods=["GET"])
@login_required
def api_metadata_config() -> Response | tuple[Response, int]:
"""Return provider-specific metadata search config for the active session."""
try:
from shelfmark.metadata_providers import (
get_configured_provider_name,
get_provider,
get_provider_capabilities,
get_provider_default_sort,
get_provider_kwargs,
get_provider_search_fields,
get_provider_sort_options,
is_provider_registered,
)
app_config.refresh()
content_type = request.args.get("content_type", "ebook").strip()
provider_name = request.args.get("provider", "").strip()
db_user_id = get_session_db_user_id(session)
if not provider_name:
provider_name = get_configured_provider_name(
content_type=content_type,
user_id=db_user_id,
fallback_to_main=True,
)
if not provider_name:
return jsonify(
{
"provider": None,
"display_name": None,
"enabled": False,
"available": False,
"search_fields": [],
"capabilities": [],
"sort_options": [{"value": "relevance", "label": "Most relevant"}],
"default_sort": "relevance",
}
)
if not is_provider_registered(provider_name):
return jsonify({"error": f"Unknown metadata provider: {provider_name}"}), 400
kwargs = get_provider_kwargs(provider_name)
provider = get_provider(provider_name, **kwargs)
enabled_key = f"{provider_name.upper()}_ENABLED"
provider_enabled = app_config.get(enabled_key, False) is True
provider_available = provider.is_available()
return jsonify(
{
"provider": provider_name,
"display_name": provider.display_name,
"enabled": provider_enabled,
"available": provider_available,
"search_fields": get_provider_search_fields(provider_name),
"capabilities": get_provider_capabilities(provider_name),
"sort_options": get_provider_sort_options(provider_name),
"default_sort": get_provider_default_sort(provider_name, user_id=db_user_id),
}
)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Metadata config error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/metadata/search", methods=["GET"])
@login_required
def api_metadata_search() -> Response | tuple[Response, int]:
"""Search for books using the configured metadata provider.
Query Parameters:
query (str): Search query (required)
limit (int): Maximum number of results (default: 40, max: 100)
sort (str): Sort order - relevance, popularity, rating, newest, oldest (default: relevance)
[dynamic fields]: Provider-specific search fields passed as query params
Returns:
flask.Response: JSON with list of books from metadata provider.
"""
try:
from dataclasses import asdict
from shelfmark.metadata_providers import (
CheckboxSearchField,
MetadataSearchOptions,
NumberSearchField,
SortOrder,
get_configured_provider,
get_provider,
get_provider_kwargs,
is_provider_enabled,
is_provider_registered,
)
query = request.args.get("query", "").strip()
content_type = request.args.get("content_type", "ebook").strip()
provider_name = request.args.get("provider", "").strip()
try:
limit = min(int(request.args.get("limit", 40)), 100)
except ValueError:
limit = 40
try:
page = max(1, int(request.args.get("page", 1)))
except ValueError:
page = 1
# Parse sort parameter
sort_value = request.args.get("sort", "relevance").lower()
try:
sort_order = SortOrder(sort_value)
except ValueError:
sort_order = SortOrder.RELEVANCE
db_user_id = get_session_db_user_id(session)
if provider_name:
if not is_provider_registered(provider_name):
return jsonify(
{
"error": f"Unknown metadata provider: {provider_name}",
"message": f"Unknown metadata provider: {provider_name}",
}
), 400
if not is_provider_enabled(provider_name):
return jsonify(
{
"error": f"Metadata provider '{provider_name}' is not enabled",
"message": f"{provider_name} is not enabled. Enable it in Settings first.",
}
), 503
kwargs = get_provider_kwargs(provider_name)
provider = get_provider(provider_name, **kwargs)
else:
provider = get_configured_provider(content_type=content_type, user_id=db_user_id)
if not provider:
return jsonify(
{
"error": "No metadata provider configured",
"message": "No metadata provider configured. Enable one in Settings.",
}
), 503
if not provider.is_available():
return jsonify(
{
"error": f"Metadata provider '{provider.name}' is not available",
"message": f"{provider.display_name} is not available. Check configuration in Settings.",
}
), 503
# Extract custom search field values from query params
fields: dict[str, Any] = {}
for search_field in provider.search_fields:
value = request.args.get(search_field.key)
if value is not None:
# Strip string values to handle whitespace-only input
value = value.strip()
if value != "":
# Parse value based on field type
if isinstance(search_field, CheckboxSearchField):
fields[search_field.key] = value.lower() in ("true", "1", "yes", "on")
elif isinstance(search_field, NumberSearchField):
with suppress(ValueError):
fields[search_field.key] = int(value)
else:
fields[search_field.key] = value
# Require either a query or at least one field value
if not query and not fields:
return jsonify({"error": "Either 'query' or search field values are required"}), 400
options = MetadataSearchOptions(
query=query, limit=limit, page=page, sort=sort_order, fields=fields
)
search_result = provider.search_paginated(options)
# Convert BookMetadata objects to dicts
books_data = [asdict(book) for book in search_result.books]
# Transform cover_url to local proxy URLs when caching is enabled
from shelfmark.core.utils import transform_cover_url
for book_dict in books_data:
if book_dict.get("cover_url"):
cache_id = f"{book_dict['provider']}_{book_dict['provider_id']}"
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
response_data = {
"books": books_data,
"provider": provider.name,
"query": query,
"page": search_result.page,
"total_found": search_result.total_found,
"has_more": search_result.has_more,
}
if search_result.source_url:
response_data["source_url"] = search_result.source_url
if search_result.source_title:
response_data["source_title"] = search_result.source_title
return jsonify(response_data)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Metadata search error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/metadata/field-options", methods=["GET"])
@login_required
def api_metadata_field_options() -> Response:
"""Return dynamic search-field options for a metadata provider."""
try:
from shelfmark.metadata_providers import (
get_configured_provider,
get_provider,
get_provider_kwargs,
is_provider_registered,
)
field_key = request.args.get("field", "").strip()
provider_name = request.args.get("provider", "").strip()
content_type = request.args.get("content_type", "ebook").strip()
query_text = request.args.get("query", "").strip()
if not field_key:
return jsonify({"options": []})
db_user_id = get_session_db_user_id(session)
provider = None
if provider_name:
if not is_provider_registered(provider_name):
return jsonify({"options": []})
kwargs = get_provider_kwargs(provider_name)
provider = get_provider(provider_name, **kwargs)
else:
provider = get_configured_provider(content_type=content_type, user_id=db_user_id)
if not provider or not provider.is_available():
return jsonify({"options": []})
options = provider.get_search_field_options(field_key, query=query_text or None)
return jsonify({"options": options})
except _OPERATIONAL_ERRORS as e:
logger.warning("Metadata field options endpoint error: %s", e)
return jsonify({"options": []})
def _resolve_metadata_provider(provider_name: str) -> MetadataProvider:
"""Validate, instantiate and return a ready metadata provider.
Raises appropriate HTTP-friendly exceptions on failure.
"""
from shelfmark.metadata_providers import (
get_provider,
get_provider_kwargs,
is_provider_registered,
)
if not is_provider_registered(provider_name):
msg = f"Unknown metadata provider: {provider_name}"
raise ValueError(msg)
kwargs = get_provider_kwargs(provider_name)
prov = get_provider(provider_name, **kwargs)
if not prov.is_available():
msg = f"Provider '{provider_name}' is not available"
raise RuntimeError(msg)
return prov
@app.route("/api/metadata/book/<provider>/<book_id>", methods=["GET"])
@login_required
def api_metadata_book(provider: str, book_id: str) -> Response | tuple[Response, int]:
"""Get detailed book information from a metadata provider.
Path Parameters:
provider (str): Provider name (e.g., "hardcover", "openlibrary")
book_id (str): Book ID in the provider's system
Returns:
flask.Response: JSON with book details.
"""
try:
from dataclasses import asdict
prov = _resolve_metadata_provider(provider)
book = prov.get_book(book_id)
if not book:
return jsonify({"error": "Book not found"}), 404
book_dict = asdict(book)
# Transform cover_url to local proxy URL when caching is enabled
from shelfmark.core.utils import transform_cover_url
if book_dict.get("cover_url"):
cache_id = f"{provider}_{book_id}"
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
return jsonify(book_dict)
except ValueError as e:
return jsonify({"error": str(e)}), 400
except RuntimeError as e:
return jsonify({"error": str(e)}), 503
except (OSError, TypeError, sqlite3.Error) as e:
logger.error_trace(f"Metadata book error: {e}")
return jsonify({"error": str(e)}), 500
def _handle_target_errors(
fallback_message: str,
) -> Callable[
[Callable[..., Response | tuple[Response, int]]], Callable[..., Response | tuple[Response, int]]
]:
"""Wrap a metadata-target route with standard error handling."""
def decorator(
fn: Callable[..., Response | tuple[Response, int]],
) -> Callable[..., Response | tuple[Response, int]]:
@wraps(fn)
def wrapper(*args: object, **kwargs: object) -> Response | tuple[Response, int]:
try:
return fn(*args, **kwargs)
except (NotImplementedError, ValueError) as e:
return jsonify({"error": str(e)}), 400
except RuntimeError as e:
return jsonify({"error": str(e)}), 502
except (OSError, TypeError, sqlite3.Error) as e:
logger.error_trace(f"{fallback_message}: {e}")
return jsonify({"error": fallback_message}), 500
return wrapper
return decorator
@app.route("/api/metadata/book/<provider>/<book_id>/targets", methods=["GET"])
@login_required
@_handle_target_errors("Failed to load book targets")
def api_metadata_book_targets(provider: str, book_id: str) -> Response | tuple[Response, int]:
"""Get provider-managed list/status targets for a specific book."""
prov = _resolve_metadata_provider(provider)
return jsonify({"options": prov.get_book_targets(book_id)})
@app.route("/api/metadata/book/<provider>/targets/batch", methods=["POST"])
@login_required
@_handle_target_errors("Failed to load book targets")
def api_metadata_book_targets_batch(provider: str) -> Response | tuple[Response, int]:
"""Get provider-managed list/status targets for multiple books."""
prov = _resolve_metadata_provider(provider)
payload = request.get_json(silent=True) or {}
raw_ids = payload.get("book_ids", []) if isinstance(payload, dict) else []
if not isinstance(raw_ids, list) or not raw_ids:
return jsonify({"error": "book_ids must be a non-empty array"}), 400
book_ids = [str(bid) for bid in raw_ids[:50]]
return jsonify({"results": prov.get_book_targets_batch(book_ids)})
@app.route("/api/metadata/book/<provider>/<book_id>/targets", methods=["PUT"])
@login_required
@_handle_target_errors("Failed to update book targets")
def api_metadata_book_targets_update(
provider: str, book_id: str
) -> Response | tuple[Response, int]:
"""Set whether a book belongs to a provider-managed list or shelf."""
prov = _resolve_metadata_provider(provider)
payload = request.get_json(silent=True) or {}
target = str(payload.get("target", "")).strip() if isinstance(payload, dict) else ""
selected = payload.get("selected") if isinstance(payload, dict) else None
if not target:
return jsonify({"error": "target is required"}), 400
if not isinstance(selected, bool):
return jsonify({"error": "selected must be a boolean"}), 400
result = prov.set_book_target_state(book_id, target, selected=selected)
response: dict = {
"success": True,
"changed": bool(result.get("changed", True)),
"selected": selected,
}
deselected = result.get("deselected_target")
if isinstance(deselected, str) and deselected:
response["deselected_target"] = deselected
return jsonify(response)
@app.route("/api/releases", methods=["GET"])
@login_required
def api_releases() -> Response | tuple[Response, int]:
"""Search for downloadable releases of a book.
This endpoint takes book metadata and searches available release sources
(e.g., Anna's Archive, Libgen) for downloadable files.
Query Parameters:
provider (str): Metadata provider name (required)
book_id (str): Book ID from metadata provider (required)
source (str): Release source to search (optional, default: all)
Returns:
flask.Response: JSON with list of available releases.
"""
try:
from dataclasses import asdict
from shelfmark.core.release_search import search_source_releases
from shelfmark.metadata_providers import (
BookMetadata,
get_provider,
get_provider_kwargs,
is_provider_registered,
)
from shelfmark.release_sources import (
browse_record_to_book_metadata,
get_source,
list_available_sources,
serialize_column_config,
source_results_are_releases,
)
def _search_source_releases(
source_name: str, search_book: BookMetadata
) -> tuple[Any | None, list[Any], str | None]:
"""Search one source and return any error message instead of raising."""
return search_source_releases(
source_name,
search_book,
languages=(browse_filters.lang if source_query_filters is not None else languages),
manual_query=(query_text if source_query_filters is not None else manual_query),
indexers=indexers,
expand_search=expand_search,
content_type=content_type,
source_filters=source_query_filters,
user_id=db_user_id,
)
provider = request.args.get("provider", "").strip()
book_id = request.args.get("book_id", "").strip()
source_filter = request.args.get("source", "").strip()
query_text = request.args.get("query", "").strip()
# Accept title/author from frontend to avoid re-fetching metadata
title_param = request.args.get("title", "").strip()
author_param = request.args.get("author", "").strip()
expand_search = request.args.get("expand_search", "").lower() == "true"
# Accept language codes for filtering (comma-separated)
languages_param = request.args.get("languages", "").strip()
languages = (
[lang.strip() for lang in languages_param.split(",") if lang.strip()]
if languages_param
else None
)
# Without an explicit filter the plan falls back to this user's default languages.
db_user_id = get_session_db_user_id(session)
# Content type for audiobook vs ebook search
content_type = request.args.get("content_type", "ebook").strip()
manual_query = request.args.get("manual_query", "").strip()
# Accept indexer names for Prowlarr filtering (comma-separated)
indexers_param = request.args.get("indexers", "").strip()
indexers = (
[idx.strip() for idx in indexers_param.split(",") if idx.strip()]
if indexers_param
else None
)
browse_filters = _parse_search_filters_from_request()
has_browse_filters = bool(query_text or any(vars(browse_filters).values()))
source_query_filters = None
is_source_provider = bool(provider) and source_results_are_releases(provider)
book: BookMetadata
if not provider or not book_id:
if not source_filter or not has_browse_filters:
return jsonify({"error": "Parameters 'provider' and 'book_id' are required"}), 400
if not source_results_are_releases(source_filter):
return jsonify(
{"error": f"Source does not support browse release search: {source_filter}"}
), 400
book = _build_source_query_book(query_text, browse_filters)
source_query_filters = browse_filters
elif is_source_provider:
# Source-backed browse flows can reopen the release modal with provider=<source name>.
# In that flow, treat the source-native record as release-search context instead of
# requiring a metadata provider registration.
source = get_source(provider)
direct_record = source.get_record(book_id)
if direct_record is None:
return jsonify({"error": "Book not found in release source"}), 404
book = browse_record_to_book_metadata(
direct_record,
title_override=title_param or None,
author_override=author_param or None,
)
elif provider == "manual":
resolved_title = title_param or manual_query or "Manual Search"
resolved_author = author_param or ""
# The release modal sends `authors.join(', ')` as `author`, so the commas here
# are joins between contributors, not part of one name. This split is the only
# place that knows that, so `search_author` comes from it rather than from the
# joined text - see issue #1252.
authors = [a.strip() for a in resolved_author.split(",") if a.strip()]
book = BookMetadata(
provider="manual",
provider_id=book_id,
provider_display_name="Manual Search",
title=resolved_title,
search_title=resolved_title,
search_author=authors[0] if authors else None,
authors=authors,
)
else:
if not is_provider_registered(provider):
return jsonify({"error": f"Unknown metadata provider: {provider}"}), 400
# Get book metadata from provider
kwargs = get_provider_kwargs(provider)
prov = get_provider(provider, **kwargs)
resolved_book = prov.get_book(book_id)
if not resolved_book:
return jsonify({"error": "Book not found in metadata provider"}), 404
book = resolved_book
# Override title from frontend if available (search results may have better data)
# Note: We intentionally DON'T override authors here - get_book() now returns
# filtered authors (primary authors only, excluding translators/narrators),
# which gives better release search results than the unfiltered search data
if title_param:
book.title = title_param
# Determine which release sources to search
if source_query_filters is not None or source_filter:
sources_to_search = [source_filter]
elif is_source_provider:
# Source-backed browse flows stay within the source that produced the record.
sources_to_search = [provider]
else:
# Search only enabled sources
sources_to_search = [src["name"] for src in list_available_sources() if src["enabled"]]
# Search each source for releases.
#
# Under a wall-clock budget: this endpoint is synchronous, and the bypass path it
# can reach used to be allowed minutes per URL with nothing bounding the request
# as a whole. A search that ran into an unsolvable protection challenge therefore
# outlived every reverse proxy in front of it and surfaced to the user as
# "Server unavailable (504)" - a gateway timeout that blames their proxy for a
# challenge failure. The budget is shared across sources, so a stuck first source
# cannot spend the whole request on its own. See issue #1276.
all_releases = []
errors = []
source_instances = {} # Keep source instances for column config
# A real search is under way, so a warm-up still sitting on its start-up delay
# should stand down rather than queue its throwaway solve in front of this one.
warmup.note_user_search()
with search_deadline.search_deadline():
for source_name in sources_to_search:
if search_deadline.expired():
logger.warning("Release search budget spent; %s not searched", source_name)
errors.append(f"{source_name}: {search_deadline.deadline_message()}")
continue
source, releases, error = _search_source_releases(source_name, book)
if source is not None:
source_instances[source_name] = source
all_releases.extend(releases)
if error is not None:
errors.append(error)
# Convert Release objects to dicts
releases_data = [_serialize_release(release) for release in all_releases]
# Get column config from the first source searched
# Reuse the same instance to get any dynamic data (e.g., online_servers for IRC)
column_config = None
if sources_to_search and sources_to_search[0] in source_instances:
try:
first_source = source_instances[sources_to_search[0]]
column_config = serialize_column_config(first_source.get_column_config())
except _OPERATIONAL_ERRORS as e:
logger.warning("Failed to get column config: %s", e)
# Convert book to dict and transform cover_url
book_dict = asdict(book)
from shelfmark.core.utils import transform_cover_url
if book_dict.get("cover_url"):
cache_id = f"{provider}_{book_id}"
book_dict["cover_url"] = transform_cover_url(book_dict["cover_url"], cache_id)
search_info = {}
for source_name, source_instance in source_instances.items():
info: dict[str, str | int | None] = {}
if hasattr(source_instance, "last_search_type") and source_instance.last_search_type:
info["search_type"] = source_instance.last_search_type
if hasattr(source_instance, "total_results"):
info["total_results"] = source_instance.total_results
if info:
search_info[source_name] = info
response = {
"releases": releases_data,
"book": book_dict,
"sources_searched": sources_to_search,
"column_config": column_config,
"search_info": search_info,
}
if errors:
response["errors"] = errors
# If no releases found and there were errors, return 503 with the first
# source failure message so direct-mode source query searches surface the
# same unavailable-state messaging as release modal searches.
if not releases_data and errors:
# Use the first error message (typically the most relevant)
error_message = errors[0]
# Strip the source prefix if present (e.g., "direct_download: message" -> "message")
if ": " in error_message:
error_message = error_message.split(": ", 1)[1]
return jsonify({"error": error_message}), 503
return jsonify(response)
except SourceUnavailableError as e:
logger.warning("Release search unavailable: %s", e)
return jsonify({"error": str(e)}), 503
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Releases search error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/release-sources", methods=["GET"])
@login_required
def api_release_sources() -> Response | tuple[Response, int]:
"""Get available release sources from the plugin registry.
Returns:
flask.Response: JSON list of available release sources.
"""
try:
from shelfmark.release_sources import list_available_sources
sources = list_available_sources()
return jsonify(sources)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Release sources error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/release-sources/<source_name>/records/<path:record_id>", methods=["GET"])
@login_required
def api_release_source_record(source_name: str, record_id: str) -> Response | tuple[Response, int]:
"""Resolve a source-native browse record for a release source."""
try:
from shelfmark.release_sources import get_source
source = get_source(source_name)
record = source.get_record(record_id)
if record is None:
return jsonify({"error": "Record not found"}), 404
return jsonify(_serialize_browse_record(record))
except ValueError:
return jsonify({"error": f"Unknown release source: {source_name}"}), 400
except SourceUnavailableError as e:
logger.warning("Release source record unavailable: %s", e)
return jsonify({"error": str(e)}), 503
except _OPERATIONAL_ERRORS as e:
logger.error_trace(f"Release source record error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/settings", methods=["GET"])
@login_required
def api_settings_get_all() -> Response | tuple[Response, int]:
"""Get all settings tabs with their fields and current values.
Returns:
flask.Response: JSON with all settings tabs.
"""
try:
import_module("shelfmark.config.notifications_settings")
import_module("shelfmark.config.security")
# Ensure settings are registered by importing settings modules
# This triggers the @register_settings decorators
import_module("shelfmark.config.settings")
import_module("shelfmark.config.users_settings")
from shelfmark.core.settings_registry import serialize_all_settings
data = serialize_all_settings(include_values=True)
return jsonify(data)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Settings get error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/settings/<tab_name>", methods=["GET"])
@login_required
def api_settings_get_tab(tab_name: str) -> Response | tuple[Response, int]:
"""Get settings for a specific tab.
Path Parameters:
tab_name (str): Settings tab name (e.g., "general", "hardcover")
Returns:
flask.Response: JSON with tab settings and values.
"""
try:
import_module("shelfmark.config.notifications_settings")
import_module("shelfmark.config.security")
# Ensure settings are registered
import_module("shelfmark.config.settings")
import_module("shelfmark.config.users_settings")
from shelfmark.core.settings_registry import (
get_settings_tab,
serialize_tab,
)
tab = get_settings_tab(tab_name)
if not tab:
return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404
return jsonify(serialize_tab(tab, include_values=True))
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Settings get tab error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/settings/<tab_name>", methods=["PUT"])
@login_required
def api_settings_update_tab(tab_name: str) -> Response | tuple[Response, int]:
"""Update settings for a specific tab.
Path Parameters:
tab_name (str): Settings tab name
Request Body:
JSON object with setting keys and values to update.
Returns:
flask.Response: JSON with update result.
"""
try:
import_module("shelfmark.config.notifications_settings")
import_module("shelfmark.config.security")
# Ensure settings are registered
import_module("shelfmark.config.settings")
import_module("shelfmark.config.users_settings")
from shelfmark.core.settings_registry import (
get_settings_tab,
update_settings,
)
tab = get_settings_tab(tab_name)
if not tab:
return jsonify({"error": f"Unknown settings tab: {tab_name}"}), 404
values = request.get_json(silent=True)
if values is None or not isinstance(values, dict):
return jsonify({"error": "Request body must be a JSON object"}), 400
# If no values to update, return success with empty updated list
if not values:
return jsonify({"success": True, "message": "No changes to save", "updated": []})
result = update_settings(tab_name, values)
if result["success"]:
return jsonify(result)
return jsonify(result), 400
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Settings update error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/settings/<tab_name>/action/<action_key>", methods=["POST"])
@login_required
def api_settings_execute_action(tab_name: str, action_key: str) -> Response | tuple[Response, int]:
"""Execute a settings action (e.g., test connection).
Path Parameters:
tab_name (str): Settings tab name
action_key (str): Action key to execute
Request Body (optional):
JSON object with current form values (unsaved)
Returns:
flask.Response: JSON with action result.
"""
try:
import_module("shelfmark.config.notifications_settings")
import_module("shelfmark.config.security")
# Ensure settings are registered
import_module("shelfmark.config.settings")
import_module("shelfmark.config.users_settings")
from shelfmark.core.settings_registry import execute_action
# Get current form values if provided (for testing with unsaved values)
current_values = request.get_json(silent=True) or {}
result = execute_action(tab_name, action_key, current_values)
if result["success"]:
return jsonify(result)
return jsonify(result), 400
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Settings action error: {e}")
return jsonify({"error": str(e)}), 500
# =============================================================================
# Onboarding API
# =============================================================================
@app.route("/api/onboarding", methods=["GET"])
@login_required
def api_onboarding_get() -> Response | tuple[Response, int]:
"""Get onboarding configuration including steps, fields, and current values.
Returns:
flask.Response: JSON with onboarding steps and values.
"""
try:
# Ensure settings are registered
import_module("shelfmark.config.settings")
from shelfmark.core.onboarding import get_onboarding_config
config = get_onboarding_config()
return jsonify(config)
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Onboarding get error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/onboarding", methods=["POST"])
@login_required
def api_onboarding_save() -> Response | tuple[Response, int]:
"""Save onboarding settings and mark as complete.
Request Body:
JSON object with all onboarding field values
Returns:
flask.Response: JSON with success/error status.
"""
try:
# Ensure settings are registered
import_module("shelfmark.config.settings")
from shelfmark.core.onboarding import save_onboarding_settings
data = request.get_json(silent=True)
if not data:
return jsonify({"success": False, "message": "No data provided"}), 400
result = save_onboarding_settings(data)
if result["success"]:
return jsonify(result)
return jsonify(result), 400
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Onboarding save error: {e}")
return jsonify({"error": str(e)}), 500
@app.route("/api/onboarding/skip", methods=["POST"])
@login_required
def api_onboarding_skip() -> Response | tuple[Response, int]:
"""Skip onboarding and mark as complete without saving any settings.
Returns:
flask.Response: JSON with success status.
"""
try:
from shelfmark.core.onboarding import mark_onboarding_complete
mark_onboarding_complete()
return jsonify({"success": True, "message": "Onboarding skipped"})
except _IMPORT_OPERATIONAL_ERRORS as e:
logger.error_trace(f"Onboarding skip error: {e}")
return jsonify({"error": str(e)}), 500
# Catch-all route for React Router (must be last)
# This handles client-side routing by serving index.html for any unmatched routes
@app.route("/<path:path>")
def catch_all(path: str) -> Response | tuple[Response, int]:
"""Serve the React app for any route not matched by API endpoints.
This allows React Router to handle client-side routing.
Authentication is handled by the React app itself.
"""
# If the request is for an API endpoint or static file, let it 404
if path.startswith(("api/", "assets/")):
return jsonify({"error": "Resource not found"}), 404
# Otherwise serve the React app
return _serve_index_html()
def _get_request_sid() -> str | None:
"""Return the Socket.IO session id for the active request when available."""
sid = getattr(request, "sid", None)
return sid if isinstance(sid, str) and sid else None
# WebSocket event handlers
@socketio.on("connect")
def handle_connect() -> None:
"""Handle client connection."""
logger.info("WebSocket client connected")
# Track the connection (triggers warmup callbacks on first connect)
ws_manager.client_connected()
# Join appropriate room based on authenticated user session
is_admin, db_user_id, can_access_status = _resolve_status_scope()
sid = _get_request_sid()
if sid is None:
logger.warning("Socket.IO connect event missing sid")
return
ws_manager.join_user_room(sid, is_admin=is_admin, db_user_id=db_user_id)
# Send initial status to the newly connected client (filtered)
try:
if not can_access_status:
emit("status_update", {})
return
user_id = None if is_admin else db_user_id
status = backend.queue_status(user_id=user_id)
emit("status_update", status)
except _OPERATIONAL_ERRORS:
logger.exception("Error sending initial status")
@socketio.on("disconnect")
def handle_disconnect() -> None:
"""Handle client disconnection."""
logger.info("WebSocket client disconnected")
# Leave room
sid = _get_request_sid()
if sid is not None:
ws_manager.leave_user_room(sid)
# Track the disconnection
ws_manager.client_disconnected()
@socketio.on("request_status")
def handle_status_request() -> None:
"""Handle manual status request from client."""
try:
is_admin, db_user_id, can_access_status = _resolve_status_scope()
sid = _get_request_sid()
if sid is None:
logger.warning("Socket.IO request_status event missing sid")
emit("status_update", {})
return
ws_manager.sync_user_room(sid, is_admin=is_admin, db_user_id=db_user_id)
if not can_access_status:
emit("status_update", {})
return
user_id = None if is_admin else db_user_id
status = backend.queue_status(user_id=user_id)
emit("status_update", status)
except _OPERATIONAL_ERRORS:
logger.exception("Error handling status request")
emit("error", {"message": "Failed to get status"})
logger.log_resource_usage()
# Warn if config directory is not writable (settings won't persist)
if not _is_config_dir_writable():
logger.warning(
"Config directory %s is not writable. Settings will not persist. Mount a config volume to enable settings persistence (see docs for details).",
CONFIG_DIR,
)
if __name__ == "__main__":
debug_enabled = _is_debug_enabled()
logger.info(
"Starting Flask application with WebSocket support on %s:%s (debug=%s)",
FLASK_HOST,
FLASK_PORT,
debug_enabled,
)
socketio.run(
app,
host=FLASK_HOST,
port=FLASK_PORT,
debug=debug_enabled,
allow_unsafe_werkzeug=True, # For development only
)