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).
42 lines
1.2 KiB
Python
42 lines
1.2 KiB
Python
"""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)
|