Add the JSON API and logins

Logins follow shelfmark: none, CWA accounts, or a reverse proxy header.
CWA accounts are checked against the OPDS feed, with a lockout after
repeated wrong passwords. An API key works in every mode, and changes
need an X-Requested-With header or the key. URL_BASE serves the app
under a path prefix.
This commit is contained in:
tim
2026-09-27 23:38:28 +02:00
parent 49cd18a09f
commit 09d6b76a17
6 changed files with 1320 additions and 0 deletions
+402
View File
@@ -0,0 +1,402 @@
"""The Flask app: login, the JSON API and the static web interface."""
import io
import logging
import os
import re
import secrets
import uuid
from collections.abc import Callable, Iterable
from datetime import timedelta
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 {}
# Login gate and errors
def _identity() -> auth.Identity | None:
return auth.current_identity(request.headers, session)
@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
logger.exception(f"Error in {request.path}: {e}")
return {"error": f"{type(e).__name__}: {e}"}, 500
# Login
@app.get("/api/health")
def health() -> Any:
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:
identity = auth.login_cwa(
str(data.get("username", "")),
str(data.get("password", "")),
orchestrator.cwa(),
auth.client_address(request.headers, request.remote_addr),
)
except auth.AuthError as e:
return {"error": str(e)}, 401
session.clear()
session["user"] = identity.name
session["mode"] = auth.AUTH_CWA
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,
}
@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")
)
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/<job_id>/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:
urls = _json().get("urls", [])
if isinstance(urls, str):
urls = re.split(r"\s+", 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/<int:story_id>/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/<int:story_id>")
def story(story_id: int) -> Any:
return orchestrator.story_details(story_id)
@app.delete("/api/stories/<int:story_id>")
def delete_story(story_id: int) -> Any:
orchestrator.delete_story(story_id)
return {"deleted": story_id}
@app.post("/api/stories/<int:story_id>/download")
def download(story_id: int) -> Any:
data = _json()
return orchestrator.queue_story(
story_id, data.get("mode", DownloadMode.NEW.value), data.get("chapter_ids"), data.get("force_fields")
)
@app.put("/api/stories/<int:story_id>/settings")
def story_settings(story_id: int) -> Any:
data = _json()
orchestrator.update_story_settings(
story_id, data.get("locked_fields"), data.get("auto_update"), data.get("recheck_days")
)
return orchestrator.story_details(story_id)["story"]
@app.put("/api/stories/<int:story_id>/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/<int:story_id>/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/<int:story_id>/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/<int:story_id>/chapters")
def chapters_included(story_id: int) -> Any:
data = _json()
return orchestrator.set_chapters_included(
story_id, [int(c) for c in data.get("chapter_ids", [])], bool(data.get("included", True))
)
@app.get("/api/stories/<int:story_id>/epub")
def story_epub(story_id: int) -> Any:
path, title = orchestrator.build_for_download(story_id)
name = re.sub(r'[\\/:*?"<>|]', "_", title.strip())[:200] or "story"
return send_file(path, download_name=f"{name}.epub", as_attachment=True)
@app.get("/api/stories/<int:story_id>/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.
return send_file(io.BytesIO(cover["data"]), mimetype=cover["mime"], max_age=365 * 24 * 60 * 60)
# Replacement rules. Story rules run after the global rules.
@app.route("/api/stories/<int:story_id>/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, data.get("rules", []), data.get("paragraphs", ...))
return orchestrator.get_rules(story_id)
@app.post("/api/stories/<int:story_id>/rules/preview")
@app.post("/api/rules/preview")
def rules_preview(story_id: int | None = None) -> Any:
data = _json()
return orchestrator.preview_rules(
story_id,
data.get("rules", []),
int(data.get("chapter_id", 0)),
bool(data.get("source")),
data.get("paragraphs"),
)
# Chapters
@app.get("/api/chapters/<int:chapter_id>/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/<int:chapter_id>/revision")
def chapter_revision(chapter_id: int) -> Any:
return orchestrator.set_chapter_revision(chapter_id, int(_json().get("rev", 0)))
@app.delete("/api/chapters/<int:chapter_id>/revisions/<int:rev>")
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 for b in data.get("books", []) if isinstance(b, dict) and "id" in b]
return orchestrator.queue_import(books, bool(data.get("check", True)))
# Gunicorn runs one worker, and the dev server runs without the reloader, so this runs once.
if config.START_THREADS:
orchestrator.start_threads()