diff --git a/shelfmark/core/system_routes.py b/shelfmark/core/system_routes.py
new file mode 100644
index 00000000..cb212498
--- /dev/null
+++ b/shelfmark/core/system_routes.py
@@ -0,0 +1,330 @@
+"""System routes: the config and health endpoints, the debug tools, the frontend files and the SPA fallback."""
+
+from __future__ import annotations
+
+import os
+import sqlite3
+import subprocess
+from functools import wraps
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, NamedTuple, NoReturn
+
+from flask import jsonify, send_file, send_from_directory, session
+from werkzeug.wrappers import Response
+
+from shelfmark.config.env import BUILD_VERSION, RELEASE_VERSION
+from shelfmark.config.settings import _SUPPORTED_BOOK_LANGUAGE
+from shelfmark.core import search_deadline
+from shelfmark.core.config import config as app_config
+from shelfmark.core.logger import setup_logger
+from shelfmark.core.request_helpers import normalize_optional_text
+from shelfmark.download import orchestrator as backend
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+ from flask import Flask
+
+logger = setup_logger(__name__)
+
+_OPERATIONAL_ERRORS = (OSError, RuntimeError, TypeError, ValueError, sqlite3.Error)
+_IMPORT_OPERATIONAL_ERRORS = (ImportError, *_OPERATIONAL_ERRORS)
+
+_BASE_TAG = ''
+
+
+def _raise_runtime_error(message: str) -> NoReturn:
+ raise RuntimeError(message)
+
+
+class DebugHooks(NamedTuple):
+ """What the DEBUG-only routes need from the app: stop the GUI, then run the debug script.
+
+ ``run_script`` waits a second for the GUI to stop before it starts the script.
+ """
+
+ stop_gui: Callable[[], None]
+ run_script: Callable[[], subprocess.CompletedProcess[str]]
+
+
+def register_system_routes(
+ app: Flask,
+ login_required: Callable[..., Callable[..., Response | tuple[Response, int]]],
+ *,
+ resolve_auth_mode: Callable[[], str],
+ resolve_db_user_id: Callable[[], int | None],
+ get_base_path: Callable[[], str],
+ get_frontend_dist: Callable[[], Path],
+ debug_hooks: DebugHooks | None,
+) -> None:
+ """Register the system routes, the frontend files and the catch-all for client-side routes.
+
+ ``login_required`` is the app's decorator. ``resolve_auth_mode``, ``resolve_db_user_id``,
+ ``get_base_path`` and ``get_frontend_dist`` are called per request so a runtime patch of any of them still takes
+ effect. ``/api/debug`` and ``/api/restart`` are registered only when ``debug_hooks`` is given.
+ """
+
+ def admin_required(
+ f: Callable[..., Response | tuple[Response, int]],
+ ) -> Callable[..., Response | tuple[Response, int]]:
+ """Require an authenticated admin session for a Flask route.
+
+ Builds on `login_required`, so auth mode "none" (no users, the single
+ operator owns the instance) still has access.
+ """
+
+ @login_required
+ @wraps(f)
+ def decorated_function(*args: object, **kwargs: object) -> Response | tuple[Response, int]:
+ if resolve_auth_mode() != "none" and not session.get("is_admin", False):
+ return jsonify({"error": "Admin access required"}), 403
+ return f(*args, **kwargs)
+
+ return decorated_function
+
+ def _base_href() -> str:
+ base_path = get_base_path()
+ 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."""
+ frontend_dist = get_frontend_dist()
+ 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 get_base_path() and _BASE_TAG in html:
+ html = html.replace(_BASE_TAG, f'', 1)
+
+ return Response(html, mimetype="text/html")
+
+ # Serve frontend static files
+ @app.route("/assets/")
+ def serve_frontend_assets(filename: str) -> Response:
+ """Serve static assets from the built frontend."""
+ return send_from_directory(get_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(
+ get_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(get_frontend_dist(), "logo.png", mimetype="image/png")
+
+ @app.route("/favicon.ico")
+ @app.route("/favico")
+ def favicon(_: Any = None) -> Response:
+ """Serve favicon from built frontend assets."""
+ return send_from_directory(
+ get_frontend_dist(), "favicon.ico", mimetype="image/vnd.microsoft.icon"
+ )
+
+ @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 = resolve_db_user_id()
+
+ 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": "Could not load the configuration"}), 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. Answers 503 with
+ status "error" when the download coordinator is not running, since nothing is
+ downloaded without it.
+
+ """
+ response: dict[str, object] = {"status": "ok"}
+
+ # Report degraded features
+ if not backend.WEBSOCKET_AVAILABLE:
+ response["degraded"] = {
+ "websocket": "WebSocket unavailable - real-time updates disabled"
+ }
+
+ coordinator_up = backend.coordinator_running()
+ response["coordinator"] = "running" if coordinator_up else "stopped"
+ if not coordinator_up:
+ response["status"] = "error"
+ return jsonify(response), 503
+
+ return jsonify(response)
+
+ if debug_hooks is not None:
+
+ @app.route("/api/debug", methods=["GET"])
+ @admin_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...")
+ debug_hooks.stop_gui()
+ result = debug_hooks.run_script()
+ 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"])
+ @admin_required
+ def restart() -> Response | tuple[Response, int]:
+ """Restart the application."""
+ os._exit(0)
+
+ # Catch-all route for React Router. Werkzeug tries static rules before dynamic ones, so it
+ # does not have to be registered last.
+ # This handles client-side routing by serving index.html for any unmatched routes
+ @app.route("/")
+ 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()
diff --git a/shelfmark/main.py b/shelfmark/main.py
index eef7fa3d..583f0539 100644
--- a/shelfmark/main.py
+++ b/shelfmark/main.py
@@ -14,7 +14,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, NoReturn, cast
from urllib.parse import quote
-from flask import Flask, g, jsonify, request, send_file, send_from_directory, session
+from flask import Flask, g, jsonify, request, send_file, session
from flask_cors import CORS
from flask_socketio import SocketIO, emit
from werkzeug.middleware.proxy_fix import ProxyFix
@@ -23,7 +23,6 @@ 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,
@@ -31,7 +30,6 @@ from shelfmark.config.env import (
FLASK_PORT,
HIDE_LOCAL_AUTH,
OIDC_AUTO_REDIRECT,
- RELEASE_VERSION,
SESSION_COOKIE_NAME,
SESSION_COOKIE_SECURE_ENV,
SOCKETIO_ALLOWED_ORIGINS,
@@ -40,7 +38,6 @@ from shelfmark.config.env import (
)
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
@@ -97,6 +94,7 @@ from shelfmark.core.requests_service import (
from shelfmark.core.settings_routes import register_settings_routes
from shelfmark.core.socketio_origins import build_cors_allowed_origins
from shelfmark.core.stats_routes import STATS_PATH, register_stats_routes
+from shelfmark.core.system_routes import DebugHooks, register_system_routes
from shelfmark.core.user_db import UserDB
from shelfmark.core.utils import AUDIOBOOK_FORMATS, normalize_base_path
from shelfmark.core.viewer_redaction import (
@@ -1102,84 +1100,7 @@ def login_required(
return decorated_function
-def admin_required(
- f: Callable[..., Response | tuple[Response, int]],
-) -> Callable[..., Response | tuple[Response, int]]:
- """Require an authenticated admin session for a Flask route.
-
- Builds on `login_required`, so auth mode "none" (no users, the single
- operator owns the instance) still has access.
- """
-
- @login_required
- @wraps(f)
- def decorated_function(*args: object, **kwargs: object) -> Response | tuple[Response, int]:
- if get_auth_mode() != "none" and not session.get("is_admin", False):
- return jsonify({"error": "Admin access required"}), 403
- return f(*args, **kwargs)
-
- return decorated_function
-
-
-_BASE_TAG = ''
-
-
-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'', 1)
-
- return Response(html, mimetype="text/html")
-
-
-# Serve frontend static files
-@app.route("/assets/")
-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")
-def favicon(_: Any = None) -> Response:
- """Serve favicon from built frontend assets."""
- return send_from_directory(FRONTEND_DIST, "favicon.ico", mimetype="image/vnd.microsoft.icon")
-
-
+_debug_hooks: DebugHooks | None = None
if _is_debug_enabled():
import subprocess
@@ -1194,47 +1115,21 @@ if _is_debug_enabled():
def _stop_gui() -> None:
_cleanup_orphan_processes()
- @app.route("/api/debug", methods=["GET"])
- @admin_required
- def debug() -> Response | tuple[Response, int]:
- """Run `/app/genDebug.sh`, generate a debug zip, and return it.
+ def _run_debug_script() -> subprocess.CompletedProcess[str]:
+ time.sleep(1)
+ return subprocess.run(["/app/genDebug.sh"], capture_output=True, text=True, check=True)
- 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
+ _debug_hooks = DebugHooks(stop_gui=_stop_gui, run_script=_run_debug_script)
- 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"])
- @admin_required
- def restart() -> Response | tuple[Response, int]:
- """Restart the application."""
- os._exit(0)
+register_system_routes(
+ app,
+ login_required,
+ resolve_auth_mode=_resolve_auth_mode_for_routes,
+ resolve_db_user_id=lambda: get_session_db_user_id(session),
+ get_base_path=lambda: BASE_PATH,
+ get_frontend_dist=lambda: FRONTEND_DIST,
+ debug_hooks=_debug_hooks,
+)
def _parse_search_filters_from_request() -> SearchFilters:
@@ -1388,112 +1283,6 @@ def api_download_release() -> Response | tuple[Response, int]:
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": "Could not load the configuration"}), 500
-
-
register_stats_routes(
app,
get_user_db=lambda: user_db,
@@ -1501,33 +1290,6 @@ register_stats_routes(
)
-@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. Answers 503 with
- status "error" when the download coordinator is not running, since nothing is
- downloaded without it.
-
- """
- response: dict[str, object] = {"status": "ok"}
-
- # Report degraded features
- if not backend.WEBSOCKET_AVAILABLE:
- response["degraded"] = {"websocket": "WebSocket unavailable - real-time updates disabled"}
-
- coordinator_up = backend.coordinator_running()
- response["coordinator"] = "running" if coordinator_up else "stopped"
- if not coordinator_up:
- response["status"] = "error"
- return jsonify(response), 503
-
- return jsonify(response)
-
-
def _resolve_status_scope(*, require_authenticated: bool = True) -> tuple[bool, int | None, bool]:
"""Resolve queue-status visibility from session state.
@@ -3080,22 +2842,6 @@ register_settings_routes(app, login_required)
register_onboarding_routes(app, login_required)
-# Catch-all route for React Router (must be last)
-# This handles client-side routing by serving index.html for any unmatched routes
-@app.route("/")
-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)
diff --git a/tests/core/test_system_routes.py b/tests/core/test_system_routes.py
new file mode 100644
index 00000000..352a181c
--- /dev/null
+++ b/tests/core/test_system_routes.py
@@ -0,0 +1,120 @@
+"""Tests for the frontend-serving routes and the gaps in /api/config coverage."""
+
+from __future__ import annotations
+
+import importlib
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+_INDEX = 'app'
+
+
+@pytest.fixture(scope="module")
+def main_module():
+ """Import `shelfmark.main` with background startup disabled."""
+ with patch("shelfmark.download.orchestrator.start"):
+ import shelfmark.main as main
+
+ importlib.reload(main)
+ return main
+
+
+@pytest.fixture
+def client(main_module):
+ return main_module.app.test_client()
+
+
+@pytest.fixture
+def dist(main_module, tmp_path: Path):
+ """A throwaway frontend build in place of the real `frontend-dist`."""
+ (tmp_path / "assets").mkdir()
+ (tmp_path / "assets" / "app.js").write_text("console.log(1)")
+ (tmp_path / "index.html").write_text(_INDEX)
+ (tmp_path / "logo.png").write_bytes(b"\x89PNG")
+ (tmp_path / "favicon.ico").write_bytes(b"ico")
+ (tmp_path / "theme-init.js").write_text("// theme")
+ with (
+ patch.object(main_module, "FRONTEND_DIST", tmp_path),
+ patch.object(main_module, "BASE_PATH", ""),
+ ):
+ yield tmp_path
+
+
+def test_index_serves_the_app_with_the_default_base_tag(client, dist):
+ resp = client.get("/")
+
+ assert resp.status_code == 200
+ assert resp.mimetype == "text/html"
+ assert resp.get_data(as_text=True) == _INDEX
+
+
+def test_index_rewrites_the_base_tag_for_a_subpath(main_module, client, dist):
+ with patch.object(main_module, "BASE_PATH", "/books"):
+ resp = client.get("/")
+
+ assert '' in resp.get_data(as_text=True)
+
+
+def test_index_without_a_frontend_build_is_a_404(client, dist):
+ (dist / "index.html").unlink()
+
+ assert client.get("/").status_code == 404
+
+
+def test_catch_all_serves_the_app_for_client_side_routes(client, dist):
+ resp = client.get("/settings/general")
+
+ assert resp.status_code == 200
+ assert resp.get_data(as_text=True) == _INDEX
+
+
+@pytest.mark.parametrize("path", ["/api/no-such-route", "/api/deeper/no-such-route"])
+def test_catch_all_keeps_unknown_api_paths_a_404(client, dist, path):
+ resp = client.get(path)
+
+ assert resp.status_code == 404
+ assert resp.get_json() == {"error": "Resource not found"}
+
+
+def test_assets_are_served_from_the_build(client, dist):
+ resp = client.get("/assets/app.js")
+
+ assert resp.status_code == 200
+ assert resp.get_data(as_text=True) == "console.log(1)"
+
+
+def test_a_missing_asset_is_a_404_not_the_app(client, dist):
+ assert client.get("/assets/missing.js").status_code == 404
+
+
+@pytest.mark.parametrize(
+ ("path", "mimetype"),
+ [
+ ("/logo.png", "image/png"),
+ ("/favicon.ico", "image/vnd.microsoft.icon"),
+ ("/favicon-32.png", "image/vnd.microsoft.icon"),
+ ("/theme-init.js", "application/javascript"),
+ ],
+)
+def test_root_files_are_served_with_their_mimetype(client, dist, path, mimetype):
+ resp = client.get(path)
+
+ assert resp.status_code == 200
+ assert resp.mimetype == mimetype
+
+
+def test_config_requires_a_session_when_auth_is_on(main_module, client):
+ with patch.object(main_module, "get_auth_mode", return_value="builtin"):
+ resp = client.get("/api/config")
+
+ assert resp.status_code == 401
+
+
+def test_config_reports_an_operational_error(client):
+ with patch("shelfmark.core.onboarding.is_onboarding_complete", side_effect=RuntimeError("x")):
+ resp = client.get("/api/config")
+
+ assert resp.status_code == 500
+ assert resp.get_json() == {"error": "Could not load the configuration"}