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:
+402
@@ -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()
|
||||
Reference in New Issue
Block a user