From a4b10ae2654507803be0cc8985272c28e605e976 Mon Sep 17 00:00:00 2001 From: marwin Date: Sat, 15 Aug 2026 16:41:50 +0200 Subject: [PATCH] =?UTF-8?q?TUI:=20B=C3=BCcher=20per=20`diora-tui=20sync`?= =?UTF-8?q?=20vom=20diora-Server=20holen=20und=20entschl=C3=BCsseln?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token gegen GET /api/sync/ + GET /books//data/, entschlüsselt Metadaten und Buchbytes lokal mit AES-256-GCM (diora_tui/crypto.py, kompatibel zu static/js/app.js' encryptBytes/decryptBytes) und legt EPUBs als normale Dateien in der Library ab — PDFs werden übersprungen. Zugangsdaten (Server-URL, Token, Base64-Key) landen auf Wunsch in ~/.config/diora-tui/config.json (0600), sonst interaktive Abfrage pro Lauf. Lesefortschritt wird bewusst noch nicht synced: der Server verankert Position als "blockIndex:innerFraction" in der Absatz-Nummerierung des Web-Readers, die nicht 1:1 auf das Kapitel-basierte scroll_fraction dieser TUI abbildet — ein naiver Abgleich würde falsche Positionen liefern. Getestet: AES-GCM-Rundreise + Falsch-Schlüssel-Ablehnung, vollständiger sync-Lauf gegen einen echten Dev-Server (Token/Key-Abfrage, Download, Entschlüsselung, Dedup bei erneutem Lauf, Fehlerfälle bei falschem Token/Key, --save inkl. 0600-Datei und Wiederverwendung ohne Prompt). --- tui/README.md | 44 ++++++++++++++++++--- tui/diora_tui/api.py | 34 ++++++++++++++++ tui/diora_tui/app.py | 71 +++++++++++++++++++++++++++++++++- tui/diora_tui/config.py | 42 ++++++++++++++++++++ tui/diora_tui/crypto.py | 27 +++++++++++++ tui/diora_tui/remote.py | 86 +++++++++++++++++++++++++++++++++++++++++ tui/pyproject.toml | 2 + 7 files changed, 299 insertions(+), 7 deletions(-) create mode 100644 tui/diora_tui/api.py create mode 100644 tui/diora_tui/config.py create mode 100644 tui/diora_tui/crypto.py create mode 100644 tui/diora_tui/remote.py diff --git a/tui/README.md b/tui/README.md index db7531e..7bd92ef 100644 --- a/tui/README.md +++ b/tui/README.md @@ -9,9 +9,8 @@ z.B. `~/.local/share/diora-tui/` unter Linux, via `platformdirs`). Der Fortschri vorwärts überschrieben ("furthest wins"), damit ein älterer/gestaffelter Lauf nie eine bereits weiter gelesene Position zurücksetzt. -Sobald die diora-Sync-API für E-Book-Fortschritt steht, bekommt dieses Tool einen -Sync-Layer, der `progress.json` gegen den Server abgleicht. Bis dahin ist alles rein -lokal — keine Verschlüsselung, kein Server-Kontakt, keine Kenntnis von diora-Accounts. +`diora-tui sync` kann Bücher jetzt vom diora-Server holen und lokal entschlüsseln (siehe +unten) — Lesefortschritt bleibt aber weiterhin rein lokal, siehe **Grenzen** unten. ## Setup @@ -33,8 +32,43 @@ Tastenkürzel: - `Enter` — markiertes Buch aus der Bibliothek öffnen - `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek) +## Bücher vom Server holen (`diora-tui sync`) + +```bash +diora-tui sync # nutzt gespeicherte Zugangsdaten, sonst interaktive Abfrage +diora-tui sync --server https://diora.creamfresh.xyz --save # einmalig einrichten + speichern +``` + +Lädt alle EPUBs des Accounts über `GET /api/sync/` + `GET /books//data/` herunter, +entschlüsselt sie lokal (AES-256-GCM, kompatibel zu `static/js/app.js`) und legt sie als +normale `.epub`-Dateien in `--library` ab (Dateiname ` - .epub`) — von da an +funktionieren sie wie jedes andere lokale Buch. Bereits heruntergeladene Bücher werden +beim nächsten Lauf übersprungen (kein erneuter Download). + +Dafür nötig, beim ersten Lauf abgefragt (danach optional lokal gespeichert unter +`~/.config/diora-tui/config.json`, `chmod 600`): + +- **Server-URL** — z.B. `https://diora.creamfresh.xyz`. +- **API-Token** — diora → Einstellungen (`/accounts/settings/`) → "Personal Access Token". +- **Verschlüsselungs-Key (Base64)** — der AES-Schlüssel, mit dem deine Bücher im Browser + verschlüsselt wurden. Der Export-Button dafür ist im UI aktuell nicht verdrahtet + (`exportEncKey()` in `app.js` existiert, hat aber keinen sichtbaren Button); bis das + nachgezogen ist, in der Browser-Devtools-Konsole auf der diora-Seite ausführen: + ```js + await exportEncKey() + ``` + Das kopiert den Key ins Clipboard — von dort ins `sync`-Prompt einfügen. + +Der Key/Token wird genauso vertrauensvoll behandelt wie im Web-Client (dort liegt der +Schlüssel unverschlüsselt in `localStorage`): lokal als Klartext in einer 0600-Datei. + ## Grenzen der aktuellen Version -- Nur EPUB, kein PDF (diora selbst unterstützt beides). +- Nur EPUB, kein PDF — `sync` lädt PDFs im Account gar nicht erst herunter (übersprungen, + wird gemeldet), da der Reader sie ohnehin nicht darstellen kann. - Text wird pro Kapitel als Fließtext ohne Bild-/Layout-Rendering dargestellt. -- Fortschritt ist rein lokal; es gibt noch keinen Login/Sync gegen eine diora-Instanz. +- Lesefortschritt bleibt rein lokal — `sync` holt nur Bücher, keinen Fortschritt. Der + Web-Reader verankert Position als `"blockIndex:innerFraction"` in seiner eigenen, über + das ganze Buch laufenden Absatz-Nummerierung; diese TUI zählt Position dagegen pro + Kapitel. Ohne eine echte Übersetzung zwischen beiden Schemata würde ein naiver Abgleich + falsche Positionen liefern — deshalb bewusst (noch) nicht gebaut. diff --git a/tui/diora_tui/api.py b/tui/diora_tui/api.py new file mode 100644 index 0000000..daa03d0 --- /dev/null +++ b/tui/diora_tui/api.py @@ -0,0 +1,34 @@ +"""HTTP client for diora's sync API (see the repo's CLAUDE.md, "Sync API for +local clients"). Status: that API is provisional — expect endpoint/field +changes as the server-side implementation settles. +""" + +from __future__ import annotations + +import requests + + +class ApiError(Exception): + pass + + +def _headers(token: str) -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def fetch_sync_snapshot(server_url: str, token: str) -> dict: + resp = requests.get(f"{server_url}/api/sync/", headers=_headers(token), timeout=30) + if resp.status_code == 401: + raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") + resp.raise_for_status() + return resp.json() + + +def fetch_book_data(server_url: str, token: str, book_id: int) -> dict: + resp = requests.get(f"{server_url}/books/{book_id}/data/", headers=_headers(token), timeout=120) + if resp.status_code == 401: + raise ApiError("Authentifizierung fehlgeschlagen — Token falsch oder abgelaufen?") + if resp.status_code == 404: + raise ApiError(f"Buch {book_id} nicht gefunden (falscher Owner oder gelöscht?)") + resp.raise_for_status() + return resp.json() diff --git a/tui/diora_tui/app.py b/tui/diora_tui/app.py index ffa76b3..809413d 100644 --- a/tui/diora_tui/app.py +++ b/tui/diora_tui/app.py @@ -4,6 +4,7 @@ diora's e-book progress API once it exists (see project README).""" from __future__ import annotations import argparse +import getpass from pathlib import Path from textual.app import App, ComposeResult @@ -12,7 +13,8 @@ from textual.containers import VerticalScroll from textual.screen import Screen from textual.widgets import Footer, Header, Label, ListItem, ListView, Static -from . import epub, progress +from . import config as config_mod +from . import epub, progress, remote DEFAULT_LIBRARY = Path.home() / "Books" @@ -150,6 +152,58 @@ class DioraTuiApp(App): self.push_screen(LibraryScreen(self.library_dir)) +def _prompt(label: str, *, secret: bool = False) -> str: + value = (getpass.getpass(f"{label}: ") if secret else input(f"{label}: ")).strip() + if not value: + raise SystemExit(f"Abgebrochen: {label} darf nicht leer sein.") + return value + + +def _ask_yes_no(question: str) -> bool: + return input(f"{question} [y/N]: ").strip().lower() in ("y", "yes", "j", "ja") + + +def _resolve_remote_config(server_override: str | None, *, save: bool) -> config_mod.RemoteConfig: + cfg = None if server_override else config_mod.load() + if cfg is not None: + return cfg + + print("Keine gespeicherten Zugangsdaten gefunden — bitte einmalig eingeben.") + server_url = (server_override or _prompt("Server-URL (z.B. https://diora.creamfresh.xyz)")).rstrip("/") + token = _prompt("API-Token (diora → Einstellungen → /accounts/settings/)", secret=True) + enc_key = _prompt( + "Verschlüsselungs-Key (Base64; in der Browser-Konsole auf der diora-Seite: " + "await exportEncKey() kopiert ihn ins Clipboard)", + secret=True, + ) + cfg = config_mod.RemoteConfig(server_url=server_url, api_token=token, enc_key_b64=enc_key) + if save or _ask_yes_no("Zugangsdaten lokal speichern, damit du sie nicht erneut eingeben musst?"): + config_mod.save(cfg) + print("Gespeichert.") + return cfg + + +def run_sync(library_dir: Path, server_override: str | None, *, save: bool) -> None: + cfg = _resolve_remote_config(server_override, save=save) + print(f"Verbinde zu {cfg.server_url} …") + try: + result = remote.sync_library(library_dir, cfg) + except remote.SyncError as e: + raise SystemExit(f"Fehler: {e}") + + print(f"{len(result.downloaded)} Buch/Bücher neu heruntergeladen nach {library_dir}.") + for name in result.downloaded: + print(f" + {name}") + if result.unchanged: + print(f"{result.unchanged} Buch/Bücher bereits lokal vorhanden, übersprungen.") + if result.skipped_pdf: + titles = ", ".join(result.skipped_pdf) + print(f"{len(result.skipped_pdf)} PDF(s) übersprungen (TUI liest aktuell nur EPUB): {titles}") + if result.failed: + titles = ", ".join(result.failed) + print(f"{len(result.failed)} Buch/Bücher konnten nicht entschlüsselt werden (falscher Key?): {titles}") + + def main() -> None: parser = argparse.ArgumentParser( description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)" @@ -160,8 +214,21 @@ def main() -> None: default=DEFAULT_LIBRARY, help=f"Verzeichnis mit EPUB-Dateien (Standard: {DEFAULT_LIBRARY})", ) + subparsers = parser.add_subparsers(dest="command") + sync_parser = subparsers.add_parser("sync", help="EPUBs vom diora-Server holen und entschlüsseln") + sync_parser.add_argument("--server", help="Server-URL, überschreibt gespeicherte Zugangsdaten für diesen Lauf") + sync_parser.add_argument( + "--save", action="store_true", help="Eingegebene Zugangsdaten lokal speichern, ohne zu fragen" + ) + args = parser.parse_args() - DioraTuiApp(args.library.expanduser()).run() + library_dir = args.library.expanduser() + + if args.command == "sync": + run_sync(library_dir, args.server, save=args.save) + return + + DioraTuiApp(library_dir).run() if __name__ == "__main__": diff --git a/tui/diora_tui/config.py b/tui/diora_tui/config.py new file mode 100644 index 0000000..bc82a5f --- /dev/null +++ b/tui/diora_tui/config.py @@ -0,0 +1,42 @@ +"""Local storage for diora server credentials used by `diora-tui sync`. + +Mirrors the trust model of the web client's key storage (static/js/app.js, +getOrCreateEncKey/exportEncKey): the raw AES-256 key and API token are kept +in plaintext on disk, scoped to this machine/user (0600), with no additional +at-rest encryption — same exposure as the browser's localStorage already has. +""" + +from __future__ import annotations + +import json +import stat +from dataclasses import asdict, dataclass +from pathlib import Path + +from platformdirs import user_config_dir + +_CONFIG_DIR = Path(user_config_dir("diora-tui", "diora")) +_CONFIG_FILE = _CONFIG_DIR / "config.json" + + +@dataclass +class RemoteConfig: + server_url: str + api_token: str + enc_key_b64: str + + +def load() -> RemoteConfig | None: + if not _CONFIG_FILE.exists(): + return None + try: + data = json.loads(_CONFIG_FILE.read_text()) + return RemoteConfig(**data) + except (json.JSONDecodeError, OSError, TypeError): + return None + + +def save(cfg: RemoteConfig) -> None: + _CONFIG_DIR.mkdir(parents=True, exist_ok=True) + _CONFIG_FILE.write_text(json.dumps(asdict(cfg), indent=2)) + _CONFIG_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) diff --git a/tui/diora_tui/crypto.py b/tui/diora_tui/crypto.py new file mode 100644 index 0000000..d0268ab --- /dev/null +++ b/tui/diora_tui/crypto.py @@ -0,0 +1,27 @@ +"""AES-256-GCM helpers matching static/js/app.js's encryptBytes/decryptBytes +(Web Crypto AES-GCM, 12-byte IV hex-encoded, ciphertext base64, key handled +as raw bytes) — diora's books are end-to-end encrypted client-side, so the +server (and this module) only ever sees ciphertext plus the key the user +supplies out-of-band. +""" + +from __future__ import annotations + +import base64 + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + +class DecryptError(Exception): + """Ciphertext could not be decrypted with the given key (wrong key, or corrupt data).""" + + +def decrypt(key_b64: str, iv_hex: str, ciphertext_b64: str) -> bytes: + try: + key = base64.b64decode(key_b64) + iv = bytes.fromhex(iv_hex) + ct = base64.b64decode(ciphertext_b64) + return AESGCM(key).decrypt(iv, ct, None) + except (InvalidTag, ValueError) as e: + raise DecryptError(str(e)) from e diff --git a/tui/diora_tui/remote.py b/tui/diora_tui/remote.py new file mode 100644 index 0000000..c6ba299 --- /dev/null +++ b/tui/diora_tui/remote.py @@ -0,0 +1,86 @@ +"""Fetch + decrypt books from a diora server into the local TUI library. + +Reading progress is intentionally NOT synced yet: the server tracks EPUB +position as a "blockIndex:innerFraction" anchor into the *web* reader's flat +paragraph numbering (see books/models.py, EBookProgress), which doesn't line +up with this TUI's per-chapter scroll_fraction (diora_tui/progress.py) +without an actual translation layer between the two block-numbering +schemes. Downloaded books simply start fresh in the local progress store; +wiring up that translation is future work. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from pathlib import Path + +import requests + +from . import api, crypto +from .config import RemoteConfig + + +class SyncError(Exception): + pass + + +@dataclass +class SyncResult: + downloaded: list[str] = field(default_factory=list) + unchanged: int = 0 + skipped_pdf: list[str] = field(default_factory=list) + failed: list[str] = field(default_factory=list) + + +def _sanitize_filename(name: str) -> str: + name = re.sub(r"[^\w\s.-]", "_", name).strip() + return (name or "buch")[:80] + + +def _parse_meta(raw: bytes) -> dict: + return json.loads(raw.decode("utf-8")) + + +def sync_library(library_dir: Path, cfg: RemoteConfig) -> SyncResult: + try: + snapshot = api.fetch_sync_snapshot(cfg.server_url, cfg.api_token) + except api.ApiError as e: + raise SyncError(str(e)) from e + except requests.RequestException as e: + raise SyncError(f"Verbindung zu {cfg.server_url} fehlgeschlagen: {e}") from e + + library_dir.mkdir(parents=True, exist_ok=True) + result = SyncResult() + + for book in snapshot.get("books", []): + book_id = book["id"] + try: + meta = _parse_meta(crypto.decrypt(cfg.enc_key_b64, book["meta_iv"], book["meta_ct"])) + except crypto.DecryptError: + result.failed.append(f"#{book_id}") + continue + + if meta.get("type") == "pdf": + result.skipped_pdf.append(meta.get("title") or f"#{book_id}") + continue + + existing = next(library_dir.glob(f"{book_id:04d} - *.epub"), None) + if existing is not None: + result.unchanged += 1 + continue + + try: + data = api.fetch_book_data(cfg.server_url, cfg.api_token, book_id) + raw = crypto.decrypt(cfg.enc_key_b64, data["data_iv"], data["data_ct"]) + except (api.ApiError, crypto.DecryptError, requests.RequestException): + result.failed.append(meta.get("title") or f"#{book_id}") + continue + + title = meta.get("title") or f"book-{book_id}" + dest = library_dir / f"{book_id:04d} - {_sanitize_filename(title)}.epub" + dest.write_bytes(raw) + result.downloaded.append(dest.name) + + return result diff --git a/tui/pyproject.toml b/tui/pyproject.toml index 3e8fc6f..3fa7cf1 100644 --- a/tui/pyproject.toml +++ b/tui/pyproject.toml @@ -8,6 +8,8 @@ dependencies = [ "ebooklib>=0.18", "beautifulsoup4>=4.12", "platformdirs>=4.0", + "requests>=2.31", + "cryptography>=42.0", ] [project.scripts]