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
|
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
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
|
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
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",
|
"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]
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue