epub2pdf: title-based export names, .pdf download headers, security hardening (68 tests green)
This commit is contained in:
@@ -0,0 +1,670 @@
|
||||
#!/usr/bin/env python3
|
||||
"""EPUB3 -> PDF local web converter.
|
||||
|
||||
Serves a small web UI where you upload an EPUB3 book and download a
|
||||
typeset PDF. Rendering is done by WeasyPrint (HTML/CSS -> PDF), which
|
||||
preserves the EPUB's own CSS, images, headings and CJK text. The final
|
||||
PDF is assembled per spine item and merged with pypdf.
|
||||
|
||||
Runs entirely locally; nothing is sent to any external service.
|
||||
|
||||
Usage:
|
||||
python app.py # web server on 0.0.0.0:8030
|
||||
python app.py --port 9000 # custom port
|
||||
python app.py --cli book.epub # headless one-shot conversion
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import codecs
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, abort, jsonify, render_template, request, send_file
|
||||
from werkzeug.exceptions import HTTPException, RequestEntityTooLarge
|
||||
from lxml import etree
|
||||
from pypdf import PdfWriter
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
# DATA_DIR is overridable via env so container deployments (e.g. Coolify)
|
||||
# can point uploads/output/work at a persistent volume.
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", str(BASE_DIR / "data")))
|
||||
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||
OUTPUT_DIR = DATA_DIR / "output"
|
||||
WORK_DIR = DATA_DIR / "work"
|
||||
for _d in (UPLOAD_DIR, OUTPUT_DIR, WORK_DIR):
|
||||
_d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
MAX_UPLOAD_MB = 400
|
||||
|
||||
# ZIP-bomb guard: total uncompressed size must stay within 8x the upload
|
||||
# size, with a 2 GiB floor so normal books always fit (finding: zip bomb,
|
||||
# app.py extractall).
|
||||
ZIP_BOMB_MIN_CAP = 2 * 1024 ** 3
|
||||
|
||||
# Bounded conversion pool: at most 4 concurrent conversions; extra uploads
|
||||
# receive a JSON 503 (backpressure) instead of an unbounded Thread per
|
||||
# upload (finding: unbounded threads, app.py:293-307).
|
||||
WORKER_SLOTS = threading.BoundedSemaphore(4)
|
||||
|
||||
# Crash leftovers: sweep work dirs / uploads older than these at startup.
|
||||
STALE_WORK_SECONDS = 6 * 3600
|
||||
STALE_UPLOAD_SECONDS = 3600
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["MAX_CONTENT_LENGTH"] = MAX_UPLOAD_MB * 1024 * 1024
|
||||
|
||||
log = logging.getLogger("epub2pdf")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# conversion jobs (in-memory, single worker process)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
JOBS: dict[str, dict] = {}
|
||||
_JOBS_LOCK = threading.Lock()
|
||||
|
||||
TERMINAL_STATUSES = ("done", "error")
|
||||
|
||||
|
||||
def _register_job(epub_name: str) -> str:
|
||||
"""Register a job and enforce the 50-entry cap.
|
||||
|
||||
Eviction never touches in-flight (queued/working) jobs while any
|
||||
terminal (done/error) entries exist to evict — the UI must not lose a
|
||||
live job mid-conversion (finding: eviction kills in-flight jobs,
|
||||
app.py:69-73). Only when no terminal entry exists at all is the oldest
|
||||
job of any status evicted, preserving the plain 50-cap contract.
|
||||
"""
|
||||
job = {
|
||||
"id": uuid.uuid4().hex[:12],
|
||||
"epub": epub_name,
|
||||
"status": "queued",
|
||||
"progress": 0.0,
|
||||
"message": "queued",
|
||||
"filename": None,
|
||||
"error": None,
|
||||
"created": time.time(),
|
||||
}
|
||||
with _JOBS_LOCK:
|
||||
JOBS[job["id"]] = job
|
||||
while len(JOBS) > 50:
|
||||
terminal = [k for k, v in JOBS.items() if v["status"] in TERMINAL_STATUSES]
|
||||
if terminal and len(terminal) <= 50:
|
||||
break # terminal entries fit the cap; keep in-flight jobs
|
||||
pool = terminal or list(JOBS)
|
||||
oldest = min(pool, key=lambda k: JOBS[k]["created"])
|
||||
JOBS.pop(oldest, None)
|
||||
return job["id"]
|
||||
|
||||
|
||||
def _set_job(job_id: str, **kw) -> None:
|
||||
with _JOBS_LOCK:
|
||||
if job_id in JOBS:
|
||||
JOBS[job_id].update(kw)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CSS handed to WeasyPrint (EPUB's own stylesheets still apply on top)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
BASE_CSS = """
|
||||
@page { size: A4; margin: 20mm 18mm; }
|
||||
body {
|
||||
font-family: Georgia, "Times New Roman", "Noto Serif",
|
||||
"Noto Serif CJK SC", "Noto Serif CJK TC", "Noto Serif CJK JP",
|
||||
"Noto Serif CJK KR", serif;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.65;
|
||||
color: #1c1b1a;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-weight: 700; line-height: 1.25; color: #111;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
h1 { font-size: 21pt; margin: 0 0 0.8em 0; }
|
||||
h2 { font-size: 16pt; margin: 1.2em 0 0.5em 0; }
|
||||
h3 { font-size: 13pt; margin: 1em 0 0.4em 0; }
|
||||
p { margin: 0 0 0.7em 0; }
|
||||
img, svg { max-width: 100%; height: auto; }
|
||||
pre, code {
|
||||
font-family: "DejaVu Sans Mono", "Courier New", monospace;
|
||||
font-size: 9pt;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
pre { background: #f4f1ec; border-radius: 4px; padding: 0.6em 0.8em; margin: 0 0 1em 0; }
|
||||
blockquote {
|
||||
margin: 1em 0; padding-left: 1em;
|
||||
border-left: 3px solid #b5541c; color: #444; font-style: italic;
|
||||
}
|
||||
table { border-collapse: collapse; margin: 1em 0; width: 100%; }
|
||||
th, td { border: 0.5pt solid #999; padding: 0.35em 0.6em; text-align: left; }
|
||||
th { background: #f4f1ec; }
|
||||
hr { border: none; border-top: 0.5pt solid #bbb; margin: 2em 0; }
|
||||
ul, ol { margin: 0 0 0.8em 0; padding-left: 1.6em; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
figure { margin: 1.2em 0; text-align: center; }
|
||||
figcaption { font-size: 8.5pt; color: #666; margin-top: 0.4em; }
|
||||
[hidden] { display: none; }
|
||||
"""
|
||||
|
||||
COVER_CSS = """
|
||||
@page { margin: 0; }
|
||||
html, body { margin: 0; padding: 0; height: 100%; }
|
||||
body img, body svg { width: 210mm; height: 297mm; object-fit: cover; display: block; }
|
||||
"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# EPUB structure (OPF) parsing
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
NS = {
|
||||
"opf": "http://www.idpf.org/2007/opf",
|
||||
"dc": "http://purl.org/dc/elements/1.1/",
|
||||
}
|
||||
|
||||
|
||||
def _contained(base: Path, rel: str) -> Path:
|
||||
"""Resolve `rel` against `base`, refusing to leave the tree.
|
||||
|
||||
Same guard class as zip-slip: a crafted OPF (rootfile full-path or a
|
||||
spine/manifest href) must not be able to point outside the extraction
|
||||
dir (finding: spine/manifest href containment check).
|
||||
"""
|
||||
p = (base / rel).resolve()
|
||||
if not p.is_relative_to(base.resolve()):
|
||||
raise ValueError(f"path escapes extraction dir: {rel}")
|
||||
return p
|
||||
|
||||
|
||||
def _find_opf_path(extract_dir: Path) -> Path:
|
||||
container = extract_dir / "META-INF" / "container.xml"
|
||||
if not container.exists():
|
||||
raise ValueError("Not an EPUB: missing META-INF/container.xml")
|
||||
root = etree.parse(str(container)).getroot()
|
||||
rootfile = root.find(".//rootfile")
|
||||
if rootfile is None: # tolerate spec-correct namespaced container.xml
|
||||
for el in root.iter():
|
||||
if el.tag.rsplit("}", 1)[-1] == "rootfile":
|
||||
rootfile = el
|
||||
break
|
||||
if rootfile is None:
|
||||
raise ValueError("Not an EPUB: container.xml has no rootfile")
|
||||
return _contained(extract_dir, rootfile.get("full-path"))
|
||||
|
||||
|
||||
def parse_opf(extract_dir: Path):
|
||||
"""Return (meta, opf_dir, spine_items, page_progression_direction)."""
|
||||
opf_path = _find_opf_path(extract_dir)
|
||||
opf_dir = opf_path.parent
|
||||
root = etree.parse(str(opf_path)).getroot()
|
||||
|
||||
def _local(tag: str) -> str:
|
||||
return tag.rsplit("}", 1)[-1]
|
||||
|
||||
meta = {}
|
||||
for el in root.iter():
|
||||
if not isinstance(el.tag, str) or _local(el.tag) not in ("title", "creator", "language", "description"):
|
||||
continue
|
||||
ns = el.tag.rsplit("}", 1)[0].lstrip("{") if "}" in el.tag else ""
|
||||
if "dc" in ns or not ns: # Dublin Core metadata (namespaced or plain)
|
||||
meta.setdefault(_local(el.tag), (el.text or "").strip())
|
||||
|
||||
manifest: dict[str, dict] = {}
|
||||
for el in root.iter():
|
||||
if not isinstance(el.tag, str) or _local(el.tag) != "item" or not el.get("id"):
|
||||
continue
|
||||
manifest[el.get("id")] = {
|
||||
"href": el.get("href", ""),
|
||||
"media_type": el.get("media-type") or el.get("media_type") or "",
|
||||
"properties": el.get("properties") or "",
|
||||
}
|
||||
|
||||
spine: list[dict] = []
|
||||
for ref in root.iter():
|
||||
if not isinstance(ref.tag, str) or _local(ref.tag) != "itemref":
|
||||
continue
|
||||
item = manifest.get(ref.get("idref"))
|
||||
if item is None or ref.get("linear", "yes") == "no":
|
||||
continue
|
||||
if "html" not in item["media_type"]:
|
||||
continue
|
||||
try: # containment: a spine href must stay inside the extraction tree
|
||||
_contained(opf_dir, item["href"])
|
||||
except ValueError:
|
||||
continue
|
||||
spine.append(item)
|
||||
|
||||
if not spine: # malformed book: fall back to all readable documents
|
||||
for item in manifest.values():
|
||||
if "html" in item["media_type"] and "nav" not in item["properties"]:
|
||||
spine.append(item)
|
||||
if not spine:
|
||||
raise ValueError("No readable content found in EPUB spine")
|
||||
|
||||
pdir = root.get("page-progression-direction", "ltr") or "ltr"
|
||||
return meta, opf_dir, spine, pdir
|
||||
|
||||
|
||||
def _looks_like_cover(opf_dir: Path, item: dict) -> bool:
|
||||
props = item["properties"].split()
|
||||
if "cover-image" in props:
|
||||
return True
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
text = _decode_html((opf_dir / item["href"]).read_bytes())
|
||||
except OSError:
|
||||
return False
|
||||
if "<img" not in text.lower():
|
||||
return False
|
||||
soup = BeautifulSoup(text, "html.parser")
|
||||
return len(soup.get_text(" ", strip=True).split()) <= 20
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# chapter decoding (declared charset, finding: app.py:262)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_XML_DECL_ENC = re.compile(
|
||||
rb'^<\?xml[^>]*?encoding[\s=]+["\']?([A-Za-z0-9_\-\.]+)', re.IGNORECASE
|
||||
)
|
||||
_META_ENC = re.compile(
|
||||
rb'<meta[^>]*charset[\s=]+["\']?([A-Za-z0-9_\-\.]+)', re.IGNORECASE
|
||||
)
|
||||
_BODY_INNER = re.compile(r"<body[^>]*>(.*)</body>", re.IGNORECASE | re.DOTALL)
|
||||
|
||||
|
||||
def _decode_html(raw: bytes) -> str:
|
||||
"""Decode a chapter document honoring its declared charset.
|
||||
|
||||
Order: XML declaration encoding, then <meta charset>, then UTF-8 with
|
||||
errors='replace'. Fixes legacy EPUB2 books in windows-1252/latin-1 that
|
||||
would mojibake under a blind UTF-8 decode (same bug class as the
|
||||
em-dash incident). Undecodable input must not raise.
|
||||
"""
|
||||
m = _XML_DECL_ENC.search(raw[:4096]) or _META_ENC.search(raw[:8192])
|
||||
if m:
|
||||
enc = m.group(1).decode("ascii", errors="replace").strip()
|
||||
try:
|
||||
codecs.lookup(enc)
|
||||
text = raw.decode(enc)
|
||||
except (LookupError, UnicodeDecodeError):
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
else:
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
# WeasyPrint receives the <body> content: a bare fragment keeps per-chapter
|
||||
# layout stable and drops EPUB head cruft (meta/style) that WeasyPrint
|
||||
# would otherwise apply globally.
|
||||
bm = _BODY_INNER.search(text)
|
||||
return bm.group(1) if bm else text
|
||||
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# environment validation
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# Non-numeric PORT must fail fast with a clear message (finding: launcher
|
||||
# divergence) — printed to stdout and exit 2 so both `python app.py` and the
|
||||
# gunicorn import path stop instead of binding a garbage port.
|
||||
try:
|
||||
PORT = int(os.environ.get("PORT", "8030"))
|
||||
except (TypeError, ValueError):
|
||||
print("PORT must be an integer, got: %r" % os.environ.get("PORT"))
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def _format_progress(done: int, total: int, label: str) -> str:
|
||||
"""Progress line matching the UI regex \\(\\d+/\\d+\\) exactly."""
|
||||
return f"Rendering {label} ({done}/{total})"
|
||||
|
||||
|
||||
def _cjk_fonts_present() -> bool:
|
||||
"""True if fontconfig reports at least one CJK font (health check)."""
|
||||
try:
|
||||
out = subprocess.run(
|
||||
["fc-list", ":lang=zh", "family"],
|
||||
capture_output=True, text=True, timeout=15,
|
||||
)
|
||||
return bool(out.stdout.strip())
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
|
||||
|
||||
def _sweep_stale_work_dirs() -> None:
|
||||
"""Remove WORK_DIR/job-* left by crashed runs older than the 1h window."""
|
||||
now = time.time()
|
||||
try:
|
||||
entries = list(WORK_DIR.iterdir())
|
||||
except OSError:
|
||||
return
|
||||
for entry in entries:
|
||||
if not entry.is_dir() or not entry.name.startswith("job-"):
|
||||
continue
|
||||
try:
|
||||
if now - entry.stat().st_mtime > STALE_WORK_SECONDS:
|
||||
shutil.rmtree(entry, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# Crash leftovers from the previous process start (finding: stale work dirs).
|
||||
_sweep_stale_work_dirs()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# JSON error handlers (client pollers must never receive an HTML error page)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.errorhandler(RequestEntityTooLarge)
|
||||
@app.errorhandler(413)
|
||||
def _handle_413(err): # noqa: ARG001
|
||||
"""Oversized upload: JSON 413 with the human-readable limit."""
|
||||
return jsonify(error=f"File too large (max {MAX_UPLOAD_MB} MB)"), 413
|
||||
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def _handle_http_exception(err):
|
||||
"""Any other HTTP error: JSON body so API clients never parse HTML."""
|
||||
return jsonify(error=err.description or err.name), err.code
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# safe extraction (zip-slip + zip-bomb, finding: app.py extractall)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _safe_extract_zip(epub_path: Path, extract_dir: Path) -> None:
|
||||
"""Unzip an EPUB refusing path-escaping, absolute and symlink members.
|
||||
|
||||
Also enforces the zip-bomb cap: total uncompressed size must stay within
|
||||
max(8x upload size, ZIP_BOMB_MIN_CAP) or the job fails with a clear error.
|
||||
"""
|
||||
upload_size = epub_path.stat().st_size
|
||||
cap = max(8 * upload_size, ZIP_BOMB_MIN_CAP)
|
||||
try:
|
||||
zf = zipfile.ZipFile(epub_path)
|
||||
except zipfile.BadZipFile:
|
||||
raise ValueError("Not a valid EPUB: file is not a readable zip archive")
|
||||
with zf:
|
||||
infos = zf.infolist()
|
||||
total = sum(i.file_size for i in infos)
|
||||
if total > cap:
|
||||
raise ValueError(
|
||||
f"zip-bomb guard: uncompressed size {total} exceeds cap {cap}"
|
||||
)
|
||||
for info in infos:
|
||||
if info.file_size > ZIP_BOMB_MIN_CAP:
|
||||
raise ValueError(
|
||||
f"zip-bomb guard: member {info.filename} is {info.file_size} bytes "
|
||||
f"(max member size {ZIP_BOMB_MIN_CAP})"
|
||||
)
|
||||
for info in infos:
|
||||
name = info.filename
|
||||
if ((info.external_attr >> 16) & 0xF000) == 0xA000: # S_IFLNK
|
||||
raise ValueError(f"unsafe zip member (symlink): {name}")
|
||||
if name.startswith("/") or name.startswith("\\"):
|
||||
raise ValueError(f"unsafe zip member (absolute): {name}")
|
||||
parts = name.replace("\\", "/").split("/")
|
||||
if ".." in parts:
|
||||
raise ValueError(f"unsafe zip member (dotdot): {name}")
|
||||
for info in infos:
|
||||
target = (extract_dir / info.filename).resolve()
|
||||
if not target.is_relative_to(extract_dir.resolve()):
|
||||
raise ValueError(f"unsafe zip member (escapes): {info.filename}")
|
||||
if info.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(info) as src, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# conversion pipeline
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def convert_epub(epub_path: Path, job_id: str, progress_cb=None, output_dir=None,
|
||||
source_name=None) -> str:
|
||||
"""Render an EPUB to a merged PDF; return the output file name (no dir).
|
||||
|
||||
progress_cb(pct, message) is called per spine chapter and before the
|
||||
merge. The message format matches the UI poll regex exactly.
|
||||
output_dir overrides OUTPUT_DIR (CLI mode); defaults to OUTPUT_DIR.
|
||||
"""
|
||||
import io
|
||||
from pypdf import PdfReader
|
||||
from weasyprint import CSS, HTML
|
||||
|
||||
out_dir = Path(output_dir) if output_dir else OUTPUT_DIR
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
extract_dir = WORK_DIR / f"job-{job_id}"
|
||||
extract_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
_safe_extract_zip(epub_path, extract_dir)
|
||||
meta, opf_dir, spine, pdir = parse_opf(extract_dir)
|
||||
total = len(spine)
|
||||
writer = PdfWriter()
|
||||
for idx, item in enumerate(spine, start=1):
|
||||
label = item["href"]
|
||||
if progress_cb:
|
||||
progress_cb(min(99.0, 100.0 * idx / total), _format_progress(idx, total, label))
|
||||
try:
|
||||
text = _decode_html(_contained(opf_dir, item["href"]).read_bytes())
|
||||
except (ValueError, OSError):
|
||||
continue # unreadable or escaping chapter: skip, keep going
|
||||
css = COVER_CSS if _looks_like_cover(opf_dir, item) else BASE_CSS
|
||||
if pdir == "rtl": # right-to-left books (finding: page-progression)
|
||||
css += "\nhtml { direction: rtl; }\n"
|
||||
buf = io.BytesIO()
|
||||
HTML(string=text, base_url=str(opf_dir)).write_pdf(
|
||||
buf, stylesheets=[CSS(string=css)]
|
||||
)
|
||||
for page in PdfReader(io.BytesIO(buf.getvalue())).pages:
|
||||
writer.add_page(page)
|
||||
if len(writer.pages) == 0:
|
||||
raise ValueError("no renderable content: EPUB produced 0 PDF pages")
|
||||
if progress_cb:
|
||||
progress_cb(99.0, "Merging pages\u2026")
|
||||
title = (meta.get("title") or "").strip() or "Untitled"
|
||||
author = (meta.get("creator") or "").strip()
|
||||
meta_info = {"/Title": title} # pypdf 5+/6x requires NameObject keys
|
||||
if author:
|
||||
meta_info["/Author"] = author
|
||||
writer.add_metadata(meta_info)
|
||||
# req 1: export name = clean book title, else uploaded .epub stem, else 'book'
|
||||
base = re.sub(r"[^A-Za-z0-9._ -]+", "_", (meta.get("title") or "").strip()).strip()[:60].strip()
|
||||
if not base:
|
||||
# req 1 fallback: uploaded .epub stem (original name when provided,
|
||||
# else the on-disk stem). Guard: stem may be empty -> 'book'.
|
||||
stem_src = source_name if source_name else Path(epub_path).stem
|
||||
base = re.sub(r"[^A-Za-z0-9._ -]+", "_", Path(stem_src).stem).strip()[:60].strip() or "book"
|
||||
# req 2: never double the extension (case-insensitive 'x.pdf' -> 'x')
|
||||
if base.lower().endswith(".pdf"):
|
||||
base = base[:-4].rstrip() or "book"
|
||||
# req 3: collision-safe name: first free of base.pdf, base-2.pdf, base-3.pdf, ...
|
||||
filename = f"{base}.pdf"
|
||||
counter = 2
|
||||
while (out_dir / filename).exists():
|
||||
filename = f"{base}-{counter}.pdf"
|
||||
counter += 1
|
||||
tmp_path = out_dir / (filename + ".tmp") # atomic: write .tmp then rename
|
||||
with open(tmp_path, "wb") as out:
|
||||
writer.write(out)
|
||||
os.replace(tmp_path, out_dir / filename)
|
||||
return filename
|
||||
finally:
|
||||
shutil.rmtree(extract_dir, ignore_errors=True)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# worker wrapper
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_job(job_id: str, epub_path: Path) -> None:
|
||||
"""Worker-thread body: run the pipeline, track status, always release."""
|
||||
_set_job(job_id, status="working", message="Working\u2026", progress=0.0)
|
||||
|
||||
def _cb(pct, msg):
|
||||
_set_job(job_id, progress=pct, message=msg)
|
||||
|
||||
try:
|
||||
filename = convert_epub(epub_path, job_id, progress_cb=_cb,
|
||||
source_name=JOBS.get(job_id, {}).get("epub"))
|
||||
_set_job(job_id, status="done", progress=100.0, message="Done",
|
||||
filename=filename, error=None)
|
||||
except Exception as exc: # noqa: BLE001 - any failure is a job error
|
||||
log.exception("job %s failed", job_id)
|
||||
_set_job(job_id, status="error", error=str(exc), message="failed")
|
||||
finally:
|
||||
try:
|
||||
WORKER_SLOTS.release()
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
(UPLOAD_DIR / f"{job_id}.epub").unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
_sweep_stale_work_dirs()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# routes
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("index.html")
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Real liveness: DATA_DIR writable, weasyprint importable, CJK fonts."""
|
||||
problems = []
|
||||
try:
|
||||
probe = DATA_DIR / ".health_probe"
|
||||
probe.write_text("ok", encoding="utf-8")
|
||||
probe.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
problems.append(f"DATA_DIR not writable: {DATA_DIR}")
|
||||
try:
|
||||
import weasyprint # noqa: F401
|
||||
except Exception: # noqa: BLE001
|
||||
problems.append("weasyprint not importable")
|
||||
if not _cjk_fonts_present():
|
||||
problems.append("CJK fonts missing (fc-list found none)")
|
||||
body = {"status": "ok", "max_upload_mb": MAX_UPLOAD_MB}
|
||||
if problems:
|
||||
body = {"status": "degraded", "max_upload_mb": MAX_UPLOAD_MB, "problems": problems}
|
||||
return jsonify(body), 503
|
||||
return jsonify(body), 200
|
||||
|
||||
|
||||
@app.route("/api/convert", methods=["POST"])
|
||||
def convert_upload():
|
||||
up = request.files.get("file")
|
||||
if up is None or not up.filename:
|
||||
return jsonify(error="No file uploaded"), 400
|
||||
if not up.filename.lower().endswith(".epub"):
|
||||
return jsonify(error="Only .epub files are accepted"), 400
|
||||
data = up.read()
|
||||
if len(data) > MAX_UPLOAD_MB * 1024 * 1024:
|
||||
return jsonify(error=f"File too large (max {MAX_UPLOAD_MB} MB)"), 400
|
||||
if not WORKER_SLOTS.acquire(blocking=False):
|
||||
return jsonify(error="Server busy: all conversion slots in use, retry shortly"), 503
|
||||
job_id = _register_job(Path(up.filename).name)
|
||||
dest = UPLOAD_DIR / f"{job_id}.epub"
|
||||
try:
|
||||
with open(dest, "wb") as out:
|
||||
out.write(data)
|
||||
except OSError:
|
||||
WORKER_SLOTS.release()
|
||||
_set_job(job_id, status="error", error="failed to store upload", message="failed")
|
||||
return jsonify(error="failed to store upload"), 500
|
||||
threading.Thread(target=_run_job, args=(job_id, dest), daemon=True).start()
|
||||
return jsonify(job_id=job_id), 202
|
||||
|
||||
|
||||
@app.route("/api/jobs/<job_id>")
|
||||
def job_status(job_id):
|
||||
with _JOBS_LOCK:
|
||||
snapshot = dict(JOBS[job_id]) if job_id in JOBS else None
|
||||
if snapshot is None:
|
||||
return jsonify(error="unknown job"), 404
|
||||
return jsonify(snapshot), 200
|
||||
|
||||
|
||||
@app.route("/download/<name>")
|
||||
def download(name):
|
||||
"""Serve a finished PDF; the name is re-rooted to OUTPUT_DIR (no traversal)."""
|
||||
safe = Path(name).name
|
||||
target = (OUTPUT_DIR / safe).resolve()
|
||||
if not target.is_relative_to(OUTPUT_DIR.resolve()) or not target.is_file():
|
||||
return jsonify(error="unknown file"), 404
|
||||
# req 4: explicit PDF mimetype + defensive .pdf on download_name
|
||||
download_name = safe if safe.lower().endswith(".pdf") else safe + ".pdf"
|
||||
return send_file(
|
||||
target, as_attachment=True, mimetype="application/pdf", download_name=download_name
|
||||
)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI mode + entrypoint
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _run_cli(argv: list[str]) -> int:
|
||||
"""`python app.py --cli <epub> [--output <dir>]` — headless conversion."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="EPUB -> PDF converter")
|
||||
parser.add_argument("--cli", metavar="EPUB", help="convert EPUB headlessly")
|
||||
parser.add_argument("--output", metavar="DIR", default=str(OUTPUT_DIR),
|
||||
help="output directory (default: data/output)")
|
||||
args = parser.parse_args(argv)
|
||||
if not args.cli:
|
||||
# No --cli: fall through to the dev server (see __main__ guard).
|
||||
return None
|
||||
src = Path(args.cli)
|
||||
if not src.is_file():
|
||||
print(f"{src} not found")
|
||||
return 2
|
||||
out_dir = Path(args.output)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
job_id = uuid.uuid4().hex
|
||||
|
||||
def _cb(pct, message):
|
||||
print(f"[{pct:5.1f}%] {message}")
|
||||
|
||||
try:
|
||||
filename = convert_epub(src, job_id, progress_cb=_cb, output_dir=out_dir)
|
||||
except Exception as exc: # noqa: BLE001 - CLI reports any failure as error:
|
||||
print(f"error: {exc}")
|
||||
return 1
|
||||
print(f"Saved: {out_dir / filename}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_code = _run_cli(sys.argv[1:])
|
||||
if _code is not None:
|
||||
sys.exit(_code)
|
||||
app.run(host="0.0.0.0", port=PORT) # dev server; gunicorn uses app:app
|
||||
Reference in New Issue
Block a user