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:
parent
3fcb74631c
commit
a4b10ae265
7 changed files with 299 additions and 7 deletions
|
|
@ -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/<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
|
||||
|
||||
- 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.
|
||||
|
|
|
|||
34
tui/diora_tui/api.py
Normal file
34
tui/diora_tui/api.py
Normal 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()
|
||||
|
|
@ -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__":
|
||||
|
|
|
|||
42
tui/diora_tui/config.py
Normal file
42
tui/diora_tui/config.py
Normal 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
27
tui/diora_tui/crypto.py
Normal 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
86
tui/diora_tui/remote.py
Normal 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
|
||||
|
|
@ -8,6 +8,8 @@ dependencies = [
|
|||
"ebooklib>=0.18",
|
||||
"beautifulsoup4>=4.12",
|
||||
"platformdirs>=4.0",
|
||||
"requests>=2.31",
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue