progress.py setzt docs/progress-sync.md um (read_at in Serverzeit mit Uhrversatz, sent_at beim
Senden, force, Warteschlange je Buch, Neuanwendung nach dem Abgleich; dieselben Regeln wie
Ops.kt/ops.js). Der Reader speichert nur, was der Leser bewegt hat; wiederherstellen,
Umbruch und Sprünge schreiben nichts. Neuere Stellen anderer Geräte werden angeboten
("Auf X weitergelesen – springen?"), nie von allein angewendet; "w" springt zur weitesten
Stelle. Fortschritt als Textanteil in Zeichen statt 0.0.
Abgleich über GET /books/ (ohne Markierungs-Blobs), Bücher erst beim Öffnen geladen
(☁ in der Liste). `diora-tui login` meldet wie die Apps an, liest den vorhandenen Token,
leitet den Schlüssel aus dem Passwort ab und speichert beides geschützt (DPAPI).
DIORA_TUI_HOME legt alle Dateien in einen Ordner (platformdirs folgt unter Windows den
Umgebungsvariablen nicht). 35 Tests, tools/smoke_server.py gegen einen lokalen Server.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
109 lines
4.4 KiB
Python
109 lines
4.4 KiB
Python
"""`diora-tui login`: sign in once like the apps do and keep what a sync needs.
|
|
|
|
The password goes through the web login form (CSRF + session) only to read the account's
|
|
existing API token off the settings page, and to derive the book key (PBKDF2, as the web
|
|
client's "unlock with password"). The password is not stored; the session is logged out again.
|
|
A new token is never created here: that would sign out the apps using the current one.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import getpass
|
|
import html
|
|
import re
|
|
|
|
import requests
|
|
|
|
from . import api, crypto
|
|
from .config import RemoteConfig
|
|
|
|
DEFAULT_SERVER = "https://diora.creamfresh.xyz"
|
|
|
|
_CSRF = re.compile(r'name="csrfmiddlewaretoken" value="([^"]+)"')
|
|
_TOKEN = re.compile(r'<code style="user-select:all[^"]*">\s*([A-Za-z0-9_-]+)\s*</code>')
|
|
_USERNAME = re.compile(r'<meta name="username" content="([^"]*)">')
|
|
|
|
|
|
class LoginError(Exception):
|
|
pass
|
|
|
|
|
|
def web_login(server: str, username: str, password: str) -> tuple[str, str]:
|
|
"""(API token, canonical user name) via the web login."""
|
|
s = requests.Session()
|
|
s.headers["User-Agent"] = api.USER_AGENT
|
|
|
|
def get(path):
|
|
return s.get(server + path, headers={"Referer": server + path}, allow_redirects=False, timeout=30)
|
|
|
|
def post(path, form):
|
|
return s.post(server + path, data=form, headers={"Referer": server + path, "Origin": server},
|
|
allow_redirects=False, timeout=30)
|
|
|
|
try:
|
|
page = get("/accounts/login/")
|
|
m = _CSRF.search(page.text)
|
|
if page.status_code != 200 or not m:
|
|
raise LoginError(f"Login-Seite nicht lesbar ({page.status_code}).")
|
|
r = post("/accounts/login/", {"csrfmiddlewaretoken": m.group(1), "username": username, "password": password})
|
|
if r.status_code == 200:
|
|
raise LoginError("Benutzername oder Passwort falsch.")
|
|
if r.status_code != 302:
|
|
raise LoginError(f"Anmeldung fehlgeschlagen ({r.status_code}).")
|
|
try:
|
|
t = _TOKEN.search(get("/accounts/settings/").text)
|
|
if not t:
|
|
raise LoginError("Kein API-Token gefunden. Melde dich einmal in einer diora-App an, dann noch mal.")
|
|
name = _USERNAME.search(get("/").text)
|
|
return t.group(1), html.unescape(name.group(1)) if name else username
|
|
finally:
|
|
c = _CSRF.search(get("/").text)
|
|
if c:
|
|
post("/accounts/logout/", {"csrfmiddlewaretoken": c.group(1)})
|
|
except requests.RequestException as e:
|
|
raise LoginError(f"Verbindung zu {server} fehlgeschlagen: {e}") from e
|
|
|
|
|
|
def _books_open(server: str, token: str, key_b64: str) -> tuple[int, int]:
|
|
"""(books, books the key opens)."""
|
|
books, _, _ = api.fetch_books(server, token)
|
|
ok = 0
|
|
for b in books:
|
|
try:
|
|
crypto.decrypt(key_b64, b["meta_iv"], b["meta_ct"])
|
|
ok += 1
|
|
except crypto.DecryptError:
|
|
pass
|
|
return len(books), ok
|
|
|
|
|
|
def login(server: str | None = None, *, paste_key: bool = False) -> RemoteConfig:
|
|
server = (server or DEFAULT_SERVER).rstrip("/")
|
|
username = input("diora-Benutzername: ").strip()
|
|
password = getpass.getpass("Passwort (wird nicht gespeichert, nicht angezeigt): ")
|
|
if not username or not password:
|
|
raise LoginError("Abgebrochen.")
|
|
token, username = web_login(server, username, password)
|
|
key = "" if paste_key else crypto.derive_key_b64(username, password)
|
|
del password
|
|
|
|
if key:
|
|
total, ok = _books_open(server, token, key)
|
|
if total and not ok:
|
|
print("Der Schlüssel aus dem Passwort öffnet die Bücher nicht (Passwort seit dem Hochladen geändert?).")
|
|
key = ""
|
|
if not key:
|
|
key = getpass.getpass("Schlüssel einfügen (diora: Einstellungen → Schlüssel kopieren, Base64; leer = abbrechen): ").strip()
|
|
if not key:
|
|
raise LoginError("Abgebrochen, nichts gespeichert.")
|
|
try:
|
|
if len(base64.b64decode(key)) != 32:
|
|
raise ValueError
|
|
except ValueError:
|
|
raise LoginError("Das ist kein gültiger Schlüssel (32 Byte, Base64).") from None
|
|
total, ok = _books_open(server, token, key)
|
|
if total and not ok:
|
|
raise LoginError("Auch dieser Schlüssel passt nicht. Nichts gespeichert.")
|
|
print(f"Angemeldet als {username} ({total} Bücher).")
|
|
return RemoteConfig(server_url=server, api_token=token, enc_key_b64=key, username=username)
|