"""The Flask app: login, the JSON API and the static web interface.""" import base64 import hashlib import io import logging import os import re import secrets import uuid from collections.abc import Callable, Iterable from datetime import timedelta from pathlib import Path from typing import Any from flask import Flask, Response, g, jsonify, request, send_file, session from werkzeug.exceptions import HTTPException from bindery import auth, config, orchestrator from bindery.cwa import CwaError from bindery.epub import EpubError from bindery.fff import StoryError from bindery.jobs import METADATA_FIELDS, MODE_LABELS, DownloadMode from bindery.rules import RuleError logging.basicConfig(level=config.LOG_LEVEL, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logger = logging.getLogger(__name__) # API paths that need no login. PUBLIC_PATHS = {"/api/health", "/api/auth/check", "/api/auth/login", "/api/auth/logout"} # The browser sends this header with every change. Another site cannot send it # without a CORS preflight, which Bindery never allows, so it stops cross-site requests. CSRF_HEADER = "X-Requested-With" CSRF_VALUE = "bindery" MUTATING = {"POST", "PUT", "PATCH", "DELETE"} class PrefixMiddleware: """Serve the app under URL_BASE, for a reverse proxy that forwards the full path.""" def __init__(self, app: Callable[..., Iterable[bytes]], prefix: str) -> None: self.app = app self.prefix = prefix def __call__(self, environ: dict[str, Any], start_response: Callable[..., Any]) -> Iterable[bytes]: path = environ.get("PATH_INFO", "") if path == "/api/health": # for the container health check return self.app(environ, start_response) if path == self.prefix or path.startswith(self.prefix + "/"): environ["SCRIPT_NAME"] = self.prefix environ["PATH_INFO"] = path[len(self.prefix) :] or "/" return self.app(environ, start_response) start_response("404 Not Found", [("Content-Type", "text/plain")]) return [b"Not found"] def _secret_key() -> bytes: """A key kept in CONFIG_DIR, so logins survive a restart.""" try: return config.SECRET_KEY_FILE.read_bytes() except FileNotFoundError: key = secrets.token_bytes(64) fd = os.open(config.SECRET_KEY_FILE, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) with os.fdopen(fd, "wb") as f: f.write(key) return key config.prepare() app = Flask(__name__) app.config.update( SECRET_KEY=_secret_key(), SEND_FILE_MAX_AGE_DEFAULT=0, SESSION_COOKIE_NAME="bindery_session", SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SECURE=config.SESSION_COOKIE_SECURE, SESSION_COOKIE_PATH=config.URL_BASE or "/", # Bindery cannot check a CWA login again, so every session ends within a day. PERMANENT_SESSION_LIFETIME=timedelta(days=1), MAX_CONTENT_LENGTH=256 * 1024 * 1024, # epub uploads ) if error := auth.config_error(): logger.error(f"{error}. Bindery refuses all requests until you fix the setting.") if config.URL_BASE: app.wsgi_app = PrefixMiddleware(app.wsgi_app, config.URL_BASE) # type: ignore[method-assign] def _json() -> dict[str, Any]: data = request.get_json(silent=True) return data if isinstance(data, dict) else {} # Values of a request body. A value of the wrong type is an error of the request, so it gets a 400. def _list(data: dict[str, Any], name: str) -> list[Any]: value = data.get(name) if value is None: return [] if not isinstance(value, list): raise ValueError(f"{name} must be a list") return value def _int(value: Any, name: str) -> int: try: return int(value) except (TypeError, ValueError): raise ValueError(f"{name} must be a whole number") from None def _ids(data: dict[str, Any], name: str = "chapter_ids") -> list[int]: return [_int(v, name) for v in _list(data, name)] def _rules(data: dict[str, Any]) -> list[dict[str, Any]]: rules = _list(data, "rules") if not all(isinstance(r, dict) for r in rules): raise ValueError("rules must be a list of rules") return rules def _names(data: dict[str, Any], name: str) -> list[str] | None: """A list of text values, or None if the request leaves it out.""" if data.get(name) is None: return None values = _list(data, name) if not all(isinstance(v, str) for v in values): raise ValueError(f"{name} must be a list of names") return values # Login gate and errors def _identity() -> auth.Identity | None: return auth.current_identity(request.headers, session, request.remote_addr) @app.before_request def check_host() -> Any: if request.path == "/api/health" or auth.host_allowed(request.host): return None return jsonify({"error": f"Bindery does not answer to the host name {request.host}. Add it to ALLOWED_HOSTS."}), 400 @app.before_request def check_login() -> Any: if not request.path.startswith("/api/") or request.path == "/api/health": return None error = auth.config_error() if error: return jsonify({"error": f"{error}. Fix the setting and restart Bindery."}), 503 identity = _identity() if ( request.method in MUTATING and (identity is None or identity.via != "api_key") and request.headers.get(CSRF_HEADER) != CSRF_VALUE ): return jsonify({"error": f"Missing the {CSRF_HEADER} header"}), 403 if request.path in PUBLIC_PATHS: return None if identity is None: return jsonify({"error": "Unauthorized"}), 401 g.identity = identity return None @app.after_request def security_headers(response: Response) -> Response: response.headers.setdefault("X-Content-Type-Options", "nosniff") response.headers.setdefault("X-Frame-Options", "SAMEORIGIN") response.headers.setdefault("Referrer-Policy", "same-origin") return response @app.errorhandler(KeyError) def not_found(e: KeyError) -> Any: return {"error": e.args[0] if e.args else "Not found"}, 404 @app.errorhandler(RuleError) def bad_rule(e: RuleError) -> Any: return {"error": str(e), "index": e.index}, 400 @app.errorhandler(ValueError) @app.errorhandler(StoryError) @app.errorhandler(CwaError) @app.errorhandler(EpubError) def bad_request(e: Exception) -> Any: return {"error": str(e)}, 400 @app.errorhandler(Exception) def server_error(e: Exception) -> Any: if isinstance(e, HTTPException): return {"error": e.description}, e.code # The text of an unexpected error can hold paths and addresses, so it goes only to the log. logger.exception(f"Error in {request.path}: {e}") return {"error": "Bindery hit an unexpected error. The log has the details."}, 500 # Login @app.get("/api/health") def health() -> Any: stopped = orchestrator.stopped_threads() if stopped: return {"status": "error", "error": f"Stopped: {', '.join(stopped)}. Restart Bindery."}, 503 return {"status": "ok"} @app.get("/api/auth/check") def auth_check() -> Any: identity = _identity() mode = auth.active_mode() return { "authenticated": identity is not None, "mode": mode, "user": identity.name if identity else None, "logout_url": config.PROXY_AUTH_LOGOUT_URL or None if mode == auth.AUTH_PROXY else None, } @app.post("/api/auth/login") def auth_login() -> Any: data = _json() try: username = str(data.get("username", "")) password = str(data.get("password", "")) address = auth.client_address(request.headers, request.remote_addr) if auth.active_mode() == auth.AUTH_LOCAL: identity = auth.login_local(username, password, address) else: identity = auth.login_cwa(username, password, orchestrator.cwa(), address) except auth.AuthError as e: return {"error": str(e)}, 401 session.clear() session["user"] = identity.name session["mode"] = auth.active_mode() if auth.active_mode() == auth.AUTH_LOCAL: session["local_password_version"] = auth.local_password_version() session.permanent = bool(data.get("remember")) logger.info(f"{identity.name} logged in") return {"authenticated": True, "user": identity.name} @app.post("/api/auth/logout") def auth_logout() -> Any: session.clear() return {"authenticated": False} @app.get("/api/config") def app_config() -> Any: return { "metadata_fields": METADATA_FIELDS, "mode_labels": MODE_LABELS, "library_available": orchestrator.library_available(), "update_interval": config.UPDATE_INTERVAL_HOURS, "job_list_max_hours": config.JOB_LIST_MAX_HOURS, "job_list_max_count": config.JOB_LIST_MAX_COUNT, **orchestrator.job_list_settings(), } @app.route("/api/settings", methods=["GET", "PUT"]) def settings() -> Any: if request.method == "PUT": data = _json() return orchestrator.save_settings( data.get("recent_chapters"), data.get("personal_ini"), data.get("hold_new_links"), data.get("pause_uploads"), data.get("story_jobs_hours"), data.get("story_jobs_count"), data.get("main_jobs_hours"), data.get("main_jobs_count"), link_books=data.get("link_books"), ) return orchestrator.get_settings() # Jobs @app.get("/api/jobs") def jobs() -> Any: return orchestrator.queue_status(request.args.get("story_id", type=int)) @app.post("/api/jobs//cancel") def cancel_job(job_id: str) -> Any: return {"cancelled": orchestrator.cancel_job(job_id)} # Stories @app.get("/api/stories") def stories() -> Any: return orchestrator.list_stories() @app.post("/api/stories") def add_stories() -> Any: data = _json() urls = re.split(r"\s+", data["urls"]) if isinstance(data.get("urls"), str) else _list(data, "urls") queued, errors = orchestrator.queue_urls([str(u) for u in urls]) return {"jobs": queued, "errors": errors} @app.post("/api/stories/import-epub") @app.post("/api/stories//import-epub") def import_epub(story_id: int | None = None) -> Any: upload = request.files.get("file") if upload is None or not upload.filename: raise ValueError("Choose an epub file") path = config.UPLOAD_DIR / f"{uuid.uuid4().hex}.epub" upload.save(path) check = request.form.get("check", "true").lower() not in ("0", "false", "no", "off") return orchestrator.queue_epub_file(path, os.path.basename(upload.filename), story_id, check) @app.get("/api/stories/") def story(story_id: int) -> Any: return orchestrator.story_details(story_id) @app.delete("/api/stories/") def delete_story(story_id: int) -> Any: orchestrator.delete_story(story_id) return {"deleted": story_id} @app.post("/api/stories//download") def download(story_id: int) -> Any: data = _json() # A rebuild that the user asks for also builds and sends a book that did not change. return orchestrator.queue_story( story_id, str(data.get("mode", DownloadMode.NEW.value)), _ids(data), _names(data, "force_fields"), True ) @app.put("/api/stories//settings") def story_settings(story_id: int) -> Any: data = _json() days = data.get("recheck_days") orchestrator.update_story_settings( story_id, _names(data, "locked_fields"), data.get("auto_update"), None if days is None else _int(days, "recheck_days"), ) return orchestrator.story_details(story_id)["story"] @app.put("/api/stories//metadata") def edit_metadata(story_id: int) -> Any: values = _json().get("values") if not isinstance(values, dict): raise ValueError("Send the edited fields as values") return orchestrator.edit_metadata(story_id, values) @app.post("/api/stories//link") def link(story_id: int) -> Any: orchestrator.link_story(story_id, _json().get("uuid") or None) return orchestrator.story_details(story_id)["story"] @app.post("/api/stories//approve") def approve_upload(story_id: int) -> Any: job = orchestrator.approve_upload(story_id, _json().get("uuid") or None) return {"story": orchestrator.story_details(story_id)["story"], "job": job} @app.post("/api/stories//chapters") def chapters_included(story_id: int) -> Any: data = _json() return orchestrator.set_chapters_included(story_id, _ids(data), bool(data.get("included", True))) @app.post("/api/stories//chapters/move") def move_chapters(story_id: int) -> Any: data = _json() after = data.get("after") return orchestrator.move_chapters(story_id, _ids(data), None if after is None else _int(after, "after")) @app.post("/api/stories//chapters/delete") def delete_chapters(story_id: int) -> Any: return orchestrator.delete_chapters(story_id, _ids(_json())) @app.get("/api/stories//epub") def story_epub(story_id: int) -> Any: file, title = orchestrator.build_for_download(story_id) name = re.sub(r'[\\/:*?"<>|]', "_", title.strip())[:200] or "story" response = send_file(file, download_name=f"{name}.epub", as_attachment=True, mimetype="application/epub+zip") response.content_length = os.fstat(file.fileno()).st_size return response @app.get("/api/stories//cover") def story_cover(story_id: int) -> Any: cover = orchestrator.story_cover(story_id) if not cover: raise KeyError("This story has no cover") # The page puts the cover version in the URL, so the browser can keep the image for a year. response = send_file(io.BytesIO(cover["data"]), mimetype=cover["mime"], max_age=365 * 24 * 60 * 60) # A cover needs a login, so a shared cache must not keep it. response.cache_control.public = False response.cache_control.private = True return response # Replacement rules. Story rules run after the global rules. @app.route("/api/stories//rules", methods=["GET", "PUT"]) @app.route("/api/rules", methods=["GET", "PUT"]) def rules(story_id: int | None = None) -> Any: if request.method == "PUT": data = _json() return orchestrator.save_rules(story_id, _rules(data), data.get("paragraphs", ...)) return orchestrator.get_rules(story_id) @app.post("/api/stories//rules/preview") @app.post("/api/rules/preview") def rules_preview(story_id: int | None = None) -> Any: data = _json() return orchestrator.preview_rules( story_id, _rules(data), _int(data.get("chapter_id", 0), "chapter_id"), bool(data.get("source")), data.get("paragraphs"), ) # Chapters @app.get("/api/chapters//diff") def chapter_diff(chapter_id: int) -> Any: return orchestrator.chapter_diff( chapter_id, request.args.get("a", type=int), request.args.get("b", type=int), request.args.get("source") == "1" ) @app.post("/api/chapters//revision") def chapter_revision(chapter_id: int) -> Any: return orchestrator.set_chapter_revision(chapter_id, _int(_json().get("rev", 0), "rev")) @app.delete("/api/chapters//revisions/") def delete_revision(chapter_id: int, rev: int) -> Any: return orchestrator.delete_revision(chapter_id, rev) # Library @app.get("/api/library/candidates") def library_candidates() -> Any: return orchestrator.library_candidates() @app.get("/api/library/search") def library_search() -> Any: return orchestrator.search_library(request.args.get("q", "")) @app.post("/api/library/import") def library_import() -> Any: data = _json() books = [{**b, "id": _int(b["id"], "id")} for b in _list(data, "books") if isinstance(b, dict) and "id" in b] return orchestrator.queue_import(books, bool(data.get("check", True))) # Web interface. It uses relative URLs and hash routes, so it works under any path. def _content_security_policy(html: str) -> str: """Allow only Bindery's own files, and the inline scripts of the page by their hashes. The trailing-slash redirect in index.html must be inline: before the redirect, a relative script URL misses the path prefix. """ hashes = [ "'sha256-" + base64.b64encode(hashlib.sha256(script.encode()).digest()).decode() + "'" for script in re.findall(r"", html, re.DOTALL) ] return ( f"default-src 'self'; script-src 'self' {' '.join(hashes)}; object-src 'none'; " "base-uri 'none'; form-action 'self'; frame-ancestors 'self'" ) @app.get("/") def index() -> Any: response = app.send_static_file("index.html") html = Path(app.static_folder or "", "index.html").read_text(encoding="utf-8") response.headers["Content-Security-Policy"] = _content_security_policy(html) return response # Gunicorn runs one worker, and the dev server runs without the reloader, so this runs once. if config.START_THREADS: orchestrator.start_threads()