The new "Book Downloads" group in Settings holds a Calibre template for the file name of a direct download. Without a template, the name stays the title. The name is also the title if the template fails or gives no name for a book.
518 lines
17 KiB
Python
518 lines
17 KiB
Python
"""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 Forbidden, 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. The login gate also lets /api/health through, before it reads a setting.
|
|
PUBLIC_PATHS = {"/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:
|
|
return orchestrator.whole_number(value, name, negative=True)
|
|
|
|
|
|
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)
|
|
|
|
|
|
def _admin_only() -> None:
|
|
if not auth.is_admin(g.identity):
|
|
raise Forbidden("Only an admin can do this. BINDERY_ADMIN_USERS lists the admins.")
|
|
|
|
|
|
@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 = config.AUTH_METHOD
|
|
return {
|
|
"authenticated": identity is not None,
|
|
"mode": mode,
|
|
"user": identity.name if identity else None,
|
|
"admin": identity is not None and auth.is_admin(identity),
|
|
"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 config.AUTH_METHOD == 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"] = config.AUTH_METHOD
|
|
if config.AUTH_METHOD == 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(),
|
|
"cwa_enabled": config.CWA_ENABLED,
|
|
"update_interval": config.UPDATE_INTERVAL_HOURS,
|
|
"job_list_max_hours": config.JOB_LIST_MAX_HOURS,
|
|
"job_list_max_count": config.JOB_LIST_MAX_COUNT,
|
|
"download_fields": orchestrator.download_fields(),
|
|
**orchestrator.job_list_settings(),
|
|
}
|
|
|
|
|
|
@app.route("/api/settings", methods=["GET", "PUT"])
|
|
def settings() -> Any:
|
|
if request.method == "PUT":
|
|
_admin_only()
|
|
data = _json()
|
|
return orchestrator.save_settings(**{name: data.get(name) for name in orchestrator.SETTINGS})
|
|
settings = orchestrator.get_settings()
|
|
if not auth.is_admin(g.identity):
|
|
del settings["personal_ini"] # it can hold passwords for story sites
|
|
return 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:
|
|
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/<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 = config.flag(request.form.get("check", ""), True)
|
|
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()
|
|
# 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/<int:story_id>/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_view(story_id)
|
|
|
|
|
|
@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_view(story_id)
|
|
|
|
|
|
@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_view(story_id), "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, _ids(data), bool(data.get("included", True)))
|
|
|
|
|
|
@app.post("/api/stories/<int:story_id>/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/<int:story_id>/chapters/delete")
|
|
def delete_chapters(story_id: int) -> Any:
|
|
return orchestrator.delete_chapters(story_id, _ids(_json()))
|
|
|
|
|
|
@app.get("/api/stories/<int:story_id>/epub")
|
|
def story_epub(story_id: int) -> Any:
|
|
file, name = orchestrator.build_for_download(story_id)
|
|
response = send_file(file, download_name=name, as_attachment=True, mimetype="application/epub+zip")
|
|
response.content_length = os.fstat(file.fileno()).st_size
|
|
return response
|
|
|
|
|
|
@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.
|
|
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/<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":
|
|
if story_id is None:
|
|
_admin_only()
|
|
data = _json()
|
|
return orchestrator.save_rules(story_id, _rules(data), 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,
|
|
_rules(data),
|
|
_int(data.get("chapter_id", 0), "chapter_id"),
|
|
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), "rev"))
|
|
|
|
|
|
@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, "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"<script>(.*?)</script>", 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()
|