TUI: Bücher per diora-tui sync vom diora-Server holen und entschlüsseln

Neuer `sync`-Subcommand: authentifiziert über den Personal-Access-Token
gegen GET /api/sync/ + GET /books/<id>/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).
This commit is contained in:
marwin 2026-08-15 16:41:50 +02:00
parent 3fcb74631c
commit a4b10ae265
7 changed files with 299 additions and 7 deletions

View file

@ -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 vorwärts überschrieben ("furthest wins"), damit ein älterer/gestaffelter Lauf nie eine
bereits weiter gelesene Position zurücksetzt. bereits weiter gelesene Position zurücksetzt.
Sobald die diora-Sync-API für E-Book-Fortschritt steht, bekommt dieses Tool einen `diora-tui sync` kann Bücher jetzt vom diora-Server holen und lokal entschlüsseln (siehe
Sync-Layer, der `progress.json` gegen den Server abgleicht. Bis dahin ist alles rein unten) — Lesefortschritt bleibt aber weiterhin rein lokal, siehe **Grenzen** unten.
lokal — keine Verschlüsselung, kein Server-Kontakt, keine Kenntnis von diora-Accounts.
## Setup ## Setup
@ -33,8 +32,43 @@ Tastenkürzel:
- `Enter` — markiertes Buch aus der Bibliothek öffnen - `Enter` — markiertes Buch aus der Bibliothek öffnen
- `Escape` / `q` — zurück zur Bibliothek (im Reader) bzw. beenden (in der Bibliothek) - `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/<id>/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 `<id> - <Titel>.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 ## 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. - 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.

34
tui/diora_tui/api.py Normal file
View file

@ -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()

View file

@ -4,6 +4,7 @@ diora's e-book progress API once it exists (see project README)."""
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import getpass
from pathlib import Path from pathlib import Path
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
@ -12,7 +13,8 @@ from textual.containers import VerticalScroll
from textual.screen import Screen from textual.screen import Screen
from textual.widgets import Footer, Header, Label, ListItem, ListView, Static 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" DEFAULT_LIBRARY = Path.home() / "Books"
@ -150,6 +152,58 @@ class DioraTuiApp(App):
self.push_screen(LibraryScreen(self.library_dir)) 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: def main() -> None:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)" description="diora-tui — lokaler EPUB-Reader (Vorstufe des diora-Sync-Clients)"
@ -160,8 +214,21 @@ def main() -> None:
default=DEFAULT_LIBRARY, default=DEFAULT_LIBRARY,
help=f"Verzeichnis mit EPUB-Dateien (Standard: {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() 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__": if __name__ == "__main__":

42
tui/diora_tui/config.py Normal file
View file

@ -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)

27
tui/diora_tui/crypto.py Normal file
View file

@ -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

86
tui/diora_tui/remote.py Normal file
View file

@ -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

View file

@ -8,6 +8,8 @@ dependencies = [
"ebooklib>=0.18", "ebooklib>=0.18",
"beautifulsoup4>=4.12", "beautifulsoup4>=4.12",
"platformdirs>=4.0", "platformdirs>=4.0",
"requests>=2.31",
"cryptography>=42.0",
] ]
[project.scripts] [project.scripts]