diff --git a/README.md b/README.md index 3801d03..1bb6ba6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,25 @@ pr. person under `PORTAL_DATA/erindringer/` (persondata, aldrig i git). Røgtest uden OIDC: `PORTAL_DATA=/tmp/x python tests/erindringer_smoke.py` (kalder de rigtige API'er, koster lidt credits). +## E-bog / lydbog (`/e-pub`) + +Ét felt: indsæt et link, få en `.epub` eller `.m4b` at hente. Selve arbejdet laves af +medfølgende kommandolinjeværktøjer (`fetch.sh`, `run.sh`, `run-audiobook.sh`, `lib.sh`), som +portalen kører i en frisk mappe pr. job (under `EPUB_SCRATCH`, standard `PORTAL_DATA/e-pub-scratch`) +med et minimalt env (`PATH`, `TMP`, `RP_API_KEY`, `RP_BASE_URL`, `RP_REQUIRED`). stderr vises live +(siden poller `/e-pub/status/`). En færdig fil kan hentes én gang og slettes ellers efter +`output_ttl_seconds`; jobs lever i hukommelsen, så en genstart/udrulning afbryder igangværende jobs, +og scratch-mappen ryddes ved opstart. + +- Adgang: admins, plus medlemmer af den Authentik-gruppe der står i `allowed_group` (tom = kun admins). +- `/admin/epub`: adgangsgruppe, samtidige jobs, timeout, TTL, værktøjsmappe, "proxy påkrævet". + Siden viser også, om værktøjer, programmer og `RP_API_KEY` mangler. +- **Skal være installeret hvor portalen kører:** `bash`, `curl`, `jq`, `openssl`, `ffmpeg` + (`apt install jq` m.fl.) og værktøjsfilerne i `tools_dir` (standard `/opt/portal/tools/e-pub`, + uden for appmappen/git, læsbare for brugeren portalen kører som). +- Plads: et job kan give op til ~300 MB (plus midlertidige filer), gange antal samtidige jobs. +- Test uden OIDC og med et stub-værktøj: `python tests/epub_uvicorn.py`. + ## Kør ```bash diff --git a/config.py b/config.py index 4a5224a..67ce1b3 100644 --- a/config.py +++ b/config.py @@ -81,6 +81,20 @@ YT_DEFAULTS = { "library_name": "YouTube", } +# ---- e-pub (ebog/lydbog fra en biblioteks-reference) ---- +EPUB_SETTINGS_PATH = DATA / "epub_settings.json" +# Per-job-mapper (output + TMP) oprettes her og slettes efter hentning/udløb. +EPUB_SCRATCH = Path(os.environ.get("EPUB_SCRATCH", str(DATA / "e-pub-scratch"))) +# Standardindstillinger - kan redigeres i /admin/epub (RP_API_KEY/RP_BASE_URL kommer fra env ovenfor): +EPUB_DEFAULTS = { + "max_concurrent_jobs": 2, # samtidige jobs; resten venter i kø + "job_timeout_seconds": 1200, # et job dræbes efter så lang tid + "output_ttl_seconds": 1800, # færdig fil slettes efter så lang tid, hvis den ikke er hentet + "tools_dir": "/opt/portal/tools/e-pub", # mappen med fetch.sh m.fl. + "rp_required": True, # job fejler (exit 4) frem for at gå direkte ud uden proxy-nøgle + "allowed_group": "", # tom = kun admins må bruge /e-pub +} + GROUP_ORDER = {n: i for i, n in enumerate( ["Home", "Familie", "Medie", "Social", "AI", "Office", "Finans", "dev", "game", "Admin"])} @@ -107,6 +121,8 @@ ADMIN_MODULES = [ "desc": "Slet aldrig-set film & serier automatisk — med karens, behold-liste og ntfy."}, {"key": "yt", "title": "YouTube offline", "icon": "📺", "desc": "Hent YouTube-videoer til Emby - og slet dem igen naar de er set."}, + {"key": "epub", "title": "E-bøger & lydbøger", "icon": "📚", + "desc": "/e-pub: hvem der må bruge den, samtidige jobs, timeout og hvor længe filer ligger."}, {"key": "secrets", "title": "Hemmeligheder til Claude", "icon": "🔏", "desc": "Giv et token eller kodeord til serveren krypteret, uden at skrive det i chatten."}, {"key": "podcast", "title": "Podcast", "icon": "🎙️", diff --git a/deploy/portal.env.example b/deploy/portal.env.example index 586fcd1..d409ca6 100644 --- a/deploy/portal.env.example +++ b/deploy/portal.env.example @@ -17,3 +17,9 @@ ANTHROPIC_WORKSPACE_ID= # kun for bruger-nøgler (sk-ant-usr…), ell ERINDRINGER_MODEL=claude-sonnet-5 ELEVEN_API_KEY=__xi-…__ ERINDRINGER_VOICE=tJDFCHyviItsYF1qqToS +# /e-pub: per-job-mapper (output + TMP). Valgfri, standard PORTAL_DATA/e-pub-scratch. +# Bruger RP_API_KEY/RP_BASE_URL (random-proxy) fra samme fil. Kræver bash curl jq openssl ffmpeg +# + værktøjerne i tools_dir (sættes i /admin/epub, standard /opt/portal/tools/e-pub). +#EPUB_SCRATCH=/opt/portal/data/e-pub-scratch +RP_BASE_URL=https://rp.weircon.dk +RP_API_KEY=__random-proxy-noegle__ diff --git a/epub.py b/epub.py new file mode 100644 index 0000000..1eaed18 --- /dev/null +++ b/epub.py @@ -0,0 +1,262 @@ +"""e-pub: en indsat biblioteks-reference → .epub eller .m4b til download. + +Selve arbejdet laves af værktøjerne i settings["tools_dir"] (fetch.sh m.fl.); portalen +kører dem bare: frisk per-job-mappe som CWD, rent env, stderr som live-log, timeout +og en semafor fra indstillingerne. Jobs lever i hukommelsen; en genstart dræber +igangværende jobs, og scratch-roden ryddes ved opstart. +""" +import asyncio +import json +import os +import re +import secrets +import shutil +import signal +import time +from collections import deque +from pathlib import Path + +import config +import store + +MAX_INPUT = 2048 # tegn i det indsatte link +MAX_AKTIVE_PR_BRUGER = 3 # i kø + kørende +LOG_LINJER = 300 + +TILSTANDE_AKTIVE = ("koe", "koerer") +UDFALD = { + 2: "Kunne ikke læse linket.", + 3: "Ikke tilgængelig (udløbet?).", + 4: "Serverfejl: proxy påkrævet, men ikke sat op. Sig det til en administrator.", +} +CONTENT_TYPES = {".epub": "application/epub+zip", ".m4b": "audio/mp4"} +KRAEVEDE_PROGRAMMER = ("bash", "curl", "jq", "openssl", "ffmpeg") +KRAEVEDE_FILER = ("fetch.sh", "run.sh", "run-audiobook.sh", "lib.sh") +PATH = "/usr/local/bin:/usr/bin:/bin" + +_jobs: dict[str, dict] = {} +_sem: tuple[int, asyncio.Semaphore] | None = None + +# Rester fra før en genstart kan ingen længere hente: væk med dem. +shutil.rmtree(config.EPUB_SCRATCH, ignore_errors=True) +config.EPUB_SCRATCH.mkdir(parents=True, exist_ok=True) + + +# ---------------- indstillinger ---------------- +def get_settings() -> dict: + return {**config.EPUB_DEFAULTS, **(store.load_json(config.EPUB_SETTINGS_PATH, config.EPUB_DEFAULTS) or {})} + + +def save_settings(patch: dict) -> dict: + s = get_settings() + s.update(patch) + config.EPUB_SETTINGS_PATH.write_text(json.dumps(s, ensure_ascii=False, indent=2), "utf-8") + return s + + +def har_adgang(user, admin: bool) -> bool: + if admin: + return True + gruppe = (get_settings().get("allowed_group") or "").strip() + return bool(user and gruppe and gruppe in (user.get("groups") or [])) + + +def tjek_opsaetning(s: dict | None = None) -> dict: + """Til admin-siden: mangler der værktøjer eller programmer hvor portalen kører?""" + s = s or get_settings() + d = Path(s["tools_dir"]) + return { + "mangler_filer": [f for f in KRAEVEDE_FILER if not (d / f).is_file()], + "mangler_programmer": [p for p in KRAEVEDE_PROGRAMMER if not shutil.which(p, path=PATH)], + "rp_noegle": bool(config.RP_API_KEY), + } + + +# ---------------- jobs ---------------- +def _semafor(n: int) -> asyncio.Semaphore: + # Ny semafor når indstillingen ændres; jobs der allerede venter på den gamle bliver der. + global _sem + if _sem is None or _sem[0] != n: + _sem = (n, asyncio.Semaphore(n)) + return _sem[1] + + +def valider_input(tekst: str) -> str | None: + """Returnerer fejltekst eller None. Strengen sendes ellers ordret videre.""" + if not tekst: + return "Indsæt et link." + if len(tekst) > MAX_INPUT: + return "Linket er for langt." + if any(ord(c) < 32 or ord(c) == 127 for c in tekst) or tekst.startswith("-"): + return UDFALD[2] + return None + + +def opret(user: str, tekst: str) -> tuple[dict | None, str | None]: + aktive = sum(1 for j in _jobs.values() if j["ejer"] == user and j["tilstand"] in TILSTANDE_AKTIVE) + if aktive >= MAX_AKTIVE_PR_BRUGER: + return None, f"Du har allerede {aktive} i gang. Vent til et af dem er færdigt." + jid = secrets.token_urlsafe(12) + job = {"id": jid, "ejer": user, "input": tekst, "tilstand": "koe", "log": deque(maxlen=LOG_LINJER), + "besked": "", "fil": None, "filnavn": "", "stoerrelse": 0, + "oprettet": time.time(), "startet": None, "slut": None, "hentet": False} + _jobs[jid] = job + return job, None + + +def job(jid: str) -> dict | None: + return _jobs.get(jid) + + +def brugerens_jobs(user: str) -> list[dict]: + return sorted((j for j in _jobs.values() if j["ejer"] == user), key=lambda j: -j["oprettet"]) + + +def alle_jobs() -> list[dict]: + return sorted(_jobs.values(), key=lambda j: -j["oprettet"]) + + +def status(j: dict) -> dict: + return {"id": j["id"], "tilstand": j["tilstand"], "besked": j["besked"], "log": list(j["log"])[-80:], + "filnavn": j["filnavn"], "stoerrelse": j["stoerrelse"], "oprettet": j["oprettet"], + "startet": j["startet"], "slut": j["slut"]} + + +def _mappe(j: dict) -> Path: + return config.EPUB_SCRATCH / j["id"] + + +def ryd(j: dict, tilstand: str = "udloebet") -> None: + shutil.rmtree(_mappe(j), ignore_errors=True) + if j["tilstand"] == "faerdig": + j["tilstand"] = tilstand + j["fil"] = None + + +def _log(j: dict, linje: str) -> None: + linje = linje.rstrip() + if not linje: + return + if config.RP_API_KEY: + linje = linje.replace(config.RP_API_KEY, "***") + j["log"].append(linje[:500]) + + +async def _laes_stderr(stream: asyncio.StreamReader, j: dict) -> None: + # Delt på både \n og \r, så fremdriftslinjer der overskriver sig selv også kommer med. + rest = "" + while True: + bid = await stream.read(4096) + if not bid: + break + dele = re.split(r"[\r\n]", rest + bid.decode("utf-8", "replace")) + rest = dele.pop() + for d in dele: + _log(j, d) + if len(rest) > 4096: + _log(j, rest) + rest = "" + _log(j, rest) + + +def _find_fil(ud: Path, log: list[str]) -> Path | None: + filer = [p for p in ud.iterdir() if p.is_file() and p.suffix.lower() in CONTENT_TYPES] + if len(filer) == 1: + return filer[0] + # Flere/ingen i mappen: fald tilbage på den sidste "Wrote (...)"-linje. + for linje in reversed(log): + m = re.match(r"Wrote (/.+?) \(", linje) + if m: + p = Path(m.group(1)) + if p.is_file() and p.parent == ud and p.suffix.lower() in CONTENT_TYPES: + return p + break + return None + + +async def koer(jid: str) -> None: + """Køres i baggrunden: vent på plads, kør værktøjet, ryd op efter TTL.""" + j = _jobs[jid] + s = get_settings() + try: + async with _semafor(int(s["max_concurrent_jobs"])): + s = get_settings() + await _koer_vaerktoej(j, s) + except Exception as e: # sidste skanse: et job må aldrig hænge i "koerer" + j["tilstand"], j["besked"] = "fejl", f"Uventet fejl: {e}" + finally: + j["slut"] = j["slut"] or time.time() + shutil.rmtree(_mappe(j) / "tmp", ignore_errors=True) + if j["tilstand"] != "faerdig": + shutil.rmtree(_mappe(j), ignore_errors=True) + await asyncio.sleep(int(s["output_ttl_seconds"])) + ryd(j) + # Jobbet glemmes efter endnu en TTL, så "udløbet" kan ses et stykke tid. + await asyncio.sleep(int(s["output_ttl_seconds"])) + _jobs.pop(jid, None) + + +async def _koer_vaerktoej(j: dict, s: dict) -> None: + fetch = Path(s["tools_dir"]) / "fetch.sh" + if not fetch.is_file(): + j["tilstand"], j["besked"] = "fejl", "Serverfejl: værktøjet er ikke installeret." + return + ud, tmp = _mappe(j) / "ud", _mappe(j) / "tmp" + ud.mkdir(parents=True) + tmp.mkdir() + env = {"PATH": PATH, "TMP": str(tmp), "RP_API_KEY": config.RP_API_KEY, + "RP_BASE_URL": config.RP_BASE_URL, "RP_REQUIRED": "1" if s["rp_required"] else "0"} + j["tilstand"], j["startet"] = "koerer", time.time() + proc = await asyncio.create_subprocess_exec( + "bash", str(fetch), j["input"], cwd=ud, env=env, + stdin=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL, + stderr=asyncio.subprocess.PIPE, start_new_session=True) + laeser = asyncio.create_task(_laes_stderr(proc.stderr, j)) + try: + rc = await asyncio.wait_for(proc.wait(), timeout=int(s["job_timeout_seconds"])) + except asyncio.TimeoutError: + _draeb(proc) + await proc.wait() + rc = None + except asyncio.CancelledError: + _draeb(proc) + raise + finally: + try: + await asyncio.wait_for(laeser, timeout=5) + except Exception: + laeser.cancel() + j["slut"] = time.time() + if rc is None: + j["tilstand"], j["besked"] = "fejl", f"Tog for lang tid (over {int(s['job_timeout_seconds']) // 60} min) og blev stoppet." + return + if rc == 0: + fil = _find_fil(ud, list(j["log"])) + if fil: + j.update(tilstand="faerdig", fil=str(fil), filnavn=fil.name, stoerrelse=fil.stat().st_size, + besked="Klar til download.") + return + rc = -1 + sidste = " · ".join(list(j["log"])[-3:]) + j["tilstand"] = "fejl" + j["besked"] = UDFALD.get(rc) or ("Noget gik galt." + (f" ({sidste})" if sidste else "")) + + +def _draeb(proc) -> None: + # Hele procesgruppen: værktøjet starter selv underprocesser. + try: + os.killpg(proc.pid, signal.SIGKILL) + except ProcessLookupError: + pass + + +def content_type(filnavn: str) -> str: + return CONTENT_TYPES.get(Path(filnavn).suffix.lower(), "application/octet-stream") + + +def human(n: int) -> str: + for enhed in ("B", "KB", "MB", "GB"): + if n < 1024: + return f"{n:.0f} {enhed}" if enhed == "B" else f"{n:.1f} {enhed}" + n /= 1024 + return f"{n:.1f} TB" diff --git a/main.py b/main.py index 0b89de4..e99ac2d 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ Forretningslogik ligger i moduler: config, cache, store, services, authentik.""" from authlib.integrations.starlette_client import OAuth from fastapi import FastAPI, Form, HTTPException, Request +from starlette.background import BackgroundTask from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -28,6 +29,7 @@ import secretdrop import claudelogin import podcast import erindringer +import epub # Portalens egen MCP (FastMCP), monteret in-process på /mcp. json_response=True # => almindelig JSON (ingen SSE), så det passerer edge'en utunet. Bearer-gated. @@ -1148,6 +1150,112 @@ async def admin_access_save(request: Request, slug: str): return RedirectResponse(f"/admin/access?saved=Adgang til '{app_['name']}' gemt — {detail}", status_code=303) +# ---------------- e-pub (ebog/lydbog) ---------------- +def require_epub(request: Request): + """Logget ind + admin eller medlem af indstillingens allowed_group. Uden login: login og tilbage.""" + user = current_user(request) + if not user and request.method == "GET": + nxt = request.url.path + (f"?{request.url.query}" if request.url.query else "") + raise HTTPException(303, headers={"Location": "/auth/login?next=" + urllib.parse.quote(nxt, safe="")}) + if not user: + raise HTTPException(401, "Log ind først") + if not epub.har_adgang(user, is_admin(user)): + raise HTTPException(403, "Du har ikke adgang til denne side") + return user + + +def _epub_job(request: Request, jid: str) -> dict: + user = require_epub(request) + j = epub.job(jid) + if not j or (j["ejer"] != user["username"] and not is_admin(user)): + raise HTTPException(404, "Ukendt job") + return j + + +@app.get("/e-pub", response_class=HTMLResponse) +async def epub_side(request: Request, saved: str = ""): + user = require_epub(request) + return templates.TemplateResponse(request, "epub.html", { + "request": request, "user": user, "is_admin": is_admin(user), "saved": saved, + "jobs": [epub.status(j) for j in epub.brugerens_jobs(user["username"])]}) + + +@app.post("/e-pub/start") +async def epub_start(request: Request): + user = require_epub(request) + vil_json = "application/json" in request.headers.get("accept", "") + try: + laengde = int(request.headers.get("content-length") or 0) + except ValueError: + laengde = 0 + if laengde > 16384: + raise HTTPException(413, "For stor forespørgsel") + f = await request.form() + tekst = str(f.get("link") or "").strip() + fejl = epub.valider_input(tekst) + j = None + if not fejl: + j, fejl = epub.opret(user["username"], tekst) + if fejl: + if vil_json: + return JSONResponse({"error": fejl}, status_code=400) + return RedirectResponse("/e-pub?saved=" + urllib.parse.quote(fejl), status_code=303) + _i_baggrunden(epub.koer(j["id"])) + if vil_json: + return JSONResponse(epub.status(j)) + return RedirectResponse("/e-pub?saved=" + urllib.parse.quote("Startet"), status_code=303) + + +@app.get("/e-pub/status/{jid}") +async def epub_status(request: Request, jid: str): + return JSONResponse(epub.status(_epub_job(request, jid)), headers={"Cache-Control": "no-store"}) + + +@app.get("/e-pub/hent/{jid}") +async def epub_hent(request: Request, jid: str): + j = _epub_job(request, jid) + if j["tilstand"] != "faerdig" or not j["fil"]: + raise HTTPException(410, "Filen er her ikke længere") + # Én hentning: filen og jobmappen slettes, når svaret er sendt (ellers ved TTL). + return FileResponse(j["fil"], filename=j["filnavn"], media_type=epub.content_type(j["filnavn"]), + headers={"Cache-Control": "no-store"}, + background=BackgroundTask(epub.ryd, j, "hentet")) + + +@app.get("/admin/epub", response_class=HTMLResponse) +async def admin_epub(request: Request, saved: str = ""): + user = require_admin(request) + s = epub.get_settings() + return templates.TemplateResponse(request, "admin_epub.html", { + "request": request, "user": user, "is_admin": True, "saved": saved, "settings": s, + "tjek": epub.tjek_opsaetning(s), "jobs": [{**epub.status(j), "ejer": j["ejer"]} for j in epub.alle_jobs()], + "human": epub.human}) + + +@app.post("/admin/epub") +async def admin_epub_save(request: Request): + require_admin(request) + f = await request.form() + + def num(name, lo, hi): + try: + return max(lo, min(hi, int(f.get(name)))) + except (TypeError, ValueError): + return config.EPUB_DEFAULTS[name] + tools_dir = str(f.get("tools_dir") or "").strip() + if not tools_dir.startswith("/"): + return RedirectResponse("/admin/epub?saved=" + urllib.parse.quote("Afvist: værktøjsmappen skal være en absolut sti"), status_code=303) + epub.save_settings({ + "max_concurrent_jobs": num("max_concurrent_jobs", 1, 8), + "job_timeout_seconds": num("job_timeout_seconds", 60, 3600), + "output_ttl_seconds": num("output_ttl_seconds", 60, 86400), + "tools_dir": tools_dir.rstrip("/") or "/", + "rp_required": f.get("rp_required") == "on", + "allowed_group": str(f.get("allowed_group") or "").strip()[:150], + }) + return RedirectResponse("/admin/epub?saved=Indstillinger gemt", status_code=303) + + # ---------------- medie-oprydning ---------------- @app.get("/admin/media", response_class=HTMLResponse) async def admin_media(request: Request, saved: str = ""): @@ -1300,6 +1408,8 @@ async def search_index(request: Request): {"type": "page", "title": "Medie-oprydning", "url": "/admin/media", "extra": "film serier emby sonarr radarr slet disk plads"}, {"type": "page", "title": "YouTube offline", "url": "/admin/yt", "extra": "youtube yt-dlp download offline fly emby"}, {"type": "page", "title": "MCP-nøgler", "url": "/admin/mcp-keys", "extra": "mcp api nøgler token claude"}] + if epub.har_adgang(user, is_admin(user)): + items.append({"type": "page", "title": "E-bog / lydbog", "url": "/e-pub", "extra": "epub m4b bog lydbog bibliotek"}) try: for a in await ak.fetch_user_apps(user["username"]): items.append({"type": "app", "title": a["name"], "url": a["url"], diff --git a/templates/admin_epub.html b/templates/admin_epub.html new file mode 100644 index 0000000..fc692ce --- /dev/null +++ b/templates/admin_epub.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% block title %}Admin — E-bøger & lydbøger{% endblock %} +{% block body %} +

← Admin

+

E-bøger & lydbøger

+

Indstillinger for /e-pub. Proxy-nøglen styres i /etc/portal.env og vises ikke her.

+{% if saved %}
{{ saved }}
{% endif %} + +{% if tjek.mangler_filer or tjek.mangler_programmer or (settings.rp_required and not tjek.rp_noegle) %} +
+ {% if tjek.mangler_filer %}

Mangler i {{ settings.tools_dir }}: {{ tjek.mangler_filer | join(", ") }}

{% endif %} + {% if tjek.mangler_programmer %}

Mangler på serveren: {{ tjek.mangler_programmer | join(", ") }}

{% endif %} + {% if settings.rp_required and not tjek.rp_noegle %}

Proxy er påkrævet, men RP_API_KEY er ikke sat: alle jobs fejler.

{% endif %} +
+{% else %} +

✅ Værktøjer, programmer og proxy-nøgle er på plads.

+{% endif %} + +
+ + + + + + +

+
+ +

Jobs lige nu

+{% if jobs %} + + + + {% for j in jobs %} + + + {% endfor %} + +
BrugerTilstandFilBesked
{{ j.ejer }}{{ j.tilstand }}{% if j.filnavn %}{{ j.filnavn }} ({{ human(j.stoerrelse) }}){% endif %}{{ j.besked }}
+{% else %} +

Ingen.

+{% endif %} +{% endblock %} diff --git a/templates/epub.html b/templates/epub.html new file mode 100644 index 0000000..4715c5d --- /dev/null +++ b/templates/epub.html @@ -0,0 +1,101 @@ +{% extends "base.html" %} +{% block title %}E-bog / lydbog{% endblock %} +{% block body %} +

E-bog / lydbog

+

Indsæt et link, så laver serveren en fil du kan hente: en e-bog (.epub) eller en lydbog (.m4b). + Det kan tage fra få sekunder til et kvarter. Filen kan hentes én gang og slettes ellers af sig selv efter et stykke tid.

+{% if saved %}
{{ saved }}
{% endif %} + +
+ + +
+ + +
+ + +{% endblock %} diff --git a/tests/epub_uvicorn.py b/tests/epub_uvicorn.py new file mode 100644 index 0000000..c208a52 --- /dev/null +++ b/tests/epub_uvicorn.py @@ -0,0 +1,118 @@ +# Integrationstest af /e-pub under en RIGTIG uvicorn (baggrundsjobs + subprocess), med et +# stub-værktøj i stedet for det rigtige og current_user patchet (ingen OIDC). Kør fra repo-roden: +# python tests/epub_uvicorn.py +import json, os, pathlib, sys, tempfile, threading, time + +ROD = pathlib.Path(tempfile.mkdtemp(prefix="epub-test-")) +os.environ.update(PORTAL_DATA=str(ROD / "data"), AUTHENTIK_BASE="https://x.invalid", OIDC_CLIENT_ID="x", + OIDC_CLIENT_SECRET="x", AUTHENTIK_API_TOKEN="x", SESSION_SECRET="x", RP_API_KEY="hemmelig-noegle") +VAERKTOEJ = ROD / "tools" +VAERKTOEJ.mkdir() +for f in ("run.sh", "run-audiobook.sh", "lib.sh"): + (VAERKTOEJ / f).write_text("#!/bin/bash\n") +(VAERKTOEJ / "fetch.sh").write_text(r'''#!/bin/bash +echo "trin 1: $1" >&2 +case "$1" in + ok-epub*) printf 'PK' > "$PWD/Min Bog.epub"; echo "Wrote $PWD/Min Bog.epub (2 B)" >&2 ;; + ok-m4b) head -c 1000 /dev/zero > "$PWD/Lydbog æøå.m4b"; printf 'trin 2\rtrin 3\n' >&2; echo "Wrote $PWD/Lydbog æøå.m4b (1000 B)" >&2 ;; + env) env | sort >&2; [ -d "$TMP" ] && [ -z "$(ls -A)" ] && touch "$PWD/env.epub" ;; + bad) exit 2 ;; + gone) exit 3 ;; + proxy) exit 4 ;; + slow) sleep 31.7 & wait ;; + *) echo "noget andet: $RP_API_KEY" >&2; exit 7 ;; +esac +''') +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) +os.chdir(pathlib.Path(__file__).resolve().parents[1]) +import config # noqa: E402 +config.EPUB_SETTINGS_PATH.write_text(json.dumps({"tools_dir": str(VAERKTOEJ), "job_timeout_seconds": 3, + "output_ttl_seconds": 4, "max_concurrent_jobs": 1})) +import main # noqa: E402 +import uvicorn # noqa: E402 +import httpx # noqa: E402 + +BRUGER = {"name": "Test", "username": "test", "groups": []} +main.current_user = lambda request: BRUGER +srv = uvicorn.Server(uvicorn.Config(main.app, port=8019, log_level="warning")) +threading.Thread(target=srv.run, daemon=True).start() +while not srv.started: + time.sleep(0.1) +c = httpx.Client(base_url="http://127.0.0.1:8019", headers={"Accept": "application/json"}, timeout=30) + + +def tjek(navn, ok): + print(("OK " if ok else "FEJL ") + navn) + if not ok: + global fejl + fejl += 1 + + +def vent(jid, slut=("faerdig", "fejl")): + for _ in range(100): + st = c.get(f"/e-pub/status/{jid}").json() + if st["tilstand"] in slut: + return st + time.sleep(0.2) + return st + + +fejl = 0 +tjek("ingen adgang uden gruppe", c.get("/e-pub").status_code == 403) +BRUGER["groups"] = ["authentik Admins"] +tjek("admin har adgang", c.get("/e-pub").status_code == 200) +tjek("admin-side", c.get("/admin/epub").status_code == 200) +tjek("tom input afvist", c.post("/e-pub/start", data={"link": " "}).status_code == 400) +tjek("styretegn afvist", c.post("/e-pub/start", data={"link": "a\nb"}).status_code == 400) +tjek("for stor body afvist", c.post("/e-pub/start", data={"link": "x" * 20000}).status_code == 413) + +a = c.post("/e-pub/start", data={"link": "ok-epub 1"}).json() +b = c.post("/e-pub/start", data={"link": "ok-m4b"}).json() +tjek("semafor: andet job venter i kø", c.get(f"/e-pub/status/{b['id']}").json()["tilstand"] == "koe") +sa = vent(a["id"]) +tjek("epub færdig", sa["tilstand"] == "faerdig" and sa["filnavn"] == "Min Bog.epub") +r = c.get(f"/e-pub/hent/{a['id']}") +tjek("epub content-type", r.headers["content-type"] == "application/epub+zip" and r.content == b"PK") +tjek("filnavn i Content-Disposition", "Min%20Bog.epub" in r.headers["content-disposition"] or "Min Bog.epub" in r.headers["content-disposition"]) +time.sleep(0.3) +tjek("slettet efter hentning", c.get(f"/e-pub/status/{a['id']}").json()["tilstand"] == "hentet" + and not (config.EPUB_SCRATCH / a["id"]).exists()) +tjek("anden hentning = 410", c.get(f"/e-pub/hent/{a['id']}").status_code == 410) +sb = vent(b["id"]) +tjek("m4b færdig, \\r-linjer splittet", sb["filnavn"] == "Lydbog æøå.m4b" and "trin 3" in sb["log"]) +tjek("m4b content-type", c.get(f"/e-pub/hent/{b['id']}").headers["content-type"] == "audio/mp4") + +e = vent(c.post("/e-pub/start", data={"link": "env"}).json()["id"]) +navne = {l.split("=", 1)[0] for l in e["log"] if "=" in l and not l.startswith("trin")} +tjek("rent env (kun PATH/TMP/RP_* + bash' egne)", navne <= {"PATH", "TMP", "RP_API_KEY", "RP_BASE_URL", "RP_REQUIRED", "PWD", "SHLVL", "_"}) +tjek("RP_REQUIRED=1, nøgle maskeret, frisk mappe", "RP_REQUIRED=1" in e["log"] and "RP_API_KEY=***" in e["log"] and e["tilstand"] == "faerdig") + +for inp, forventet in (("bad", "Kunne ikke læse linket."), ("gone", "Ikke tilgængelig (udløbet?)."), ("proxy", "Serverfejl: proxy")): + s = vent(c.post("/e-pub/start", data={"link": inp}).json()["id"]) + tjek(f"exit for {inp}", s["tilstand"] == "fejl" and s["besked"].startswith(forventet)) +s = vent(c.post("/e-pub/start", data={"link": "andet"}).json()["id"]) +tjek("generisk fejl m. sidste linjer, ingen nøgle", s["besked"].startswith("Noget gik galt.") and "hemmelig" not in s["besked"]) +t0 = time.time() +s = vent(c.post("/e-pub/start", data={"link": "slow"}).json()["id"]) +tjek("timeout dræber jobbet", s["tilstand"] == "fejl" and "lang tid" in s["besked"] and time.time() - t0 < 8) +tjek("underprocesser dræbt med", os.system("pgrep -f '^sleep 31.7$' >/dev/null") != 0) + +BRUGER.update(username="anden", groups=["Bogklub"]) +tjek("andre brugeres job = 403 uden gruppe", c.get(f"/e-pub/status/{b['id']}").status_code == 403) +config.EPUB_SETTINGS_PATH.write_text(json.dumps({**json.loads(config.EPUB_SETTINGS_PATH.read_text()), "allowed_group": "Bogklub"})) +tjek("gruppe giver adgang", c.get("/e-pub").status_code == 200) +tjek("men ikke til andres job", c.get(f"/e-pub/status/{b['id']}").status_code == 404) +tjek("og ikke admin", c.get("/admin/epub").status_code == 403) + +BRUGER.update(username="test", groups=["authentik Admins"]) +u = vent(c.post("/e-pub/start", data={"link": "ok-epub 2"}).json()["id"]) +time.sleep(5) +tjek("ikke-hentet fil udløber efter TTL", c.get(f"/e-pub/status/{u['id']}").json()["tilstand"] == "udloebet" + and not (config.EPUB_SCRATCH / u["id"]).exists()) +tjek("admin gem validerer", c.post("/admin/epub", data={"tools_dir": "relativ"}, follow_redirects=False).headers["location"].startswith("/admin/epub?saved=Afvist")) +c.post("/admin/epub", data={"tools_dir": "/x", "max_concurrent_jobs": "99", "job_timeout_seconds": "abc", "rp_required": "on"}) +s = json.loads(config.EPUB_SETTINGS_PATH.read_text()) +tjek("ints klemmes, ugyldige → standard", s["max_concurrent_jobs"] == 8 and s["job_timeout_seconds"] == 1200 and s["rp_required"] is True) +print("ALT OK" if not fejl else f"{fejl} FEJL") +srv.should_exit = True +sys.exit(1 if fejl else 0)