From 31bd274824d25bd28ea0f2bee133a52c87e5018a Mon Sep 17 00:00:00 2001 From: grymphen Date: Sun, 10 May 2026 16:00:58 +0200 Subject: [PATCH] Added test and small fixes --- main.py | 23 ++++++++++++++++++-- sftp.py | 47 +++++++++++++++++++++++++++++++++++++--- static/style.css | 9 ++++++++ templates/settings.html | 48 ++++++++++++++++++++++++++++++++++++----- uploader.py | 9 ++++++++ 5 files changed, 126 insertions(+), 10 deletions(-) diff --git a/main.py b/main.py index 4b3a5eb..a8677dc 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,9 @@ from fastapi.templating import Jinja2Templates import config import db +import sftp as sftp_module import sync +import uploader logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s — %(message)s") log = logging.getLogger(__name__) @@ -94,10 +96,11 @@ async def books_page(request: Request, page: int = 1): @app.get("/settings", response_class=HTMLResponse) async def settings_page(request: Request): s = db.get_all_settings() - has_key = bool(s.get("sftp_key", "").strip()) + key_pem = s.get("sftp_key", "") return templates.TemplateResponse(request, "settings.html", { "s": s, - "has_key": has_key, + "has_key": bool(key_pem.strip()), + "key_fingerprint": sftp_module.get_key_fingerprint(key_pem), }) @@ -155,6 +158,22 @@ async def trigger_test_sync(background_tasks: BackgroundTasks): return RedirectResponse("/?test_started=1", status_code=303) +# --- Connection tests --- + +@app.get("/api/test/ssh") +async def test_ssh(): + cfg = config.load() + ok, message = sftp_module.test_connection(cfg.sftp) + return {"ok": ok, "message": message} + + +@app.get("/api/test/calibre") +async def test_calibre(): + cfg = config.load() + ok, message = uploader.test_connection(cfg.calibre) + return {"ok": ok, "message": message} + + # --- JSON status API --- @app.get("/api/status") diff --git a/sftp.py b/sftp.py index 8038fb0..236d892 100644 --- a/sftp.py +++ b/sftp.py @@ -1,6 +1,7 @@ import io import logging import posixpath +import stat from dataclasses import dataclass from pathlib import Path @@ -11,6 +12,13 @@ from config import SFTPConfig log = logging.getLogger(__name__) +_KEY_CLASSES = [ + paramiko.Ed25519Key, + paramiko.RSAKey, + paramiko.ECDSAKey, + paramiko.DSSKey, +] + @dataclass class RemoteZip: @@ -18,16 +26,51 @@ class RemoteZip: file_size: int +def _load_private_key(pem: str) -> paramiko.PKey: + for cls in _KEY_CLASSES: + try: + return cls.from_private_key(io.StringIO(pem)) + except Exception: + continue + raise ValueError("Could not parse private key — unsupported format or bad PEM data") + + +def get_key_fingerprint(pem: str) -> str | None: + if not pem.strip(): + return None + try: + key = _load_private_key(pem) + fp = ":".join(f"{b:02x}" for b in key.get_fingerprint()) + return f"{key.get_name()} MD5:{fp}" + except Exception as e: + return f"Invalid key: {e}" + + def _make_transport(cfg: SFTPConfig) -> paramiko.Transport: transport = paramiko.Transport((cfg.host, cfg.port)) if cfg.auth_method == "key" and cfg.key: - key = paramiko.RSAKey.from_private_key(io.StringIO(cfg.key)) + key = _load_private_key(cfg.key) transport.connect(username=cfg.user, pkey=key) else: transport.connect(username=cfg.user, password=cfg.password) return transport +def test_connection(cfg: SFTPConfig) -> tuple[bool, str]: + try: + transport = _make_transport(cfg) + sftp = paramiko.SFTPClient.from_transport(transport) + try: + entries = sftp.listdir(cfg.remote_path) + zip_count = sum(1 for e in entries if e.lower().endswith(".zip")) + return True, f"Connected to {cfg.host}. {len(entries)} item(s) in {cfg.remote_path} ({zip_count} zip file(s) at top level)." + finally: + sftp.close() + transport.close() + except Exception as e: + return False, str(e) + + def list_new_zips(cfg: SFTPConfig) -> list[RemoteZip]: transport = _make_transport(cfg) sftp = paramiko.SFTPClient.from_transport(transport) @@ -44,7 +87,6 @@ def list_new_zips(cfg: SFTPConfig) -> list[RemoteZip]: def download(cfg: SFTPConfig, remote_zip: RemoteZip, dest_dir: str) -> Path: dest = Path(dest_dir) dest.mkdir(parents=True, exist_ok=True) - local_path = dest / Path(remote_zip.remote_path).name transport = _make_transport(cfg) sftp = paramiko.SFTPClient.from_transport(transport) @@ -67,7 +109,6 @@ def _walk_zips(sftp: paramiko.SFTPClient, remote_dir: str) -> list[RemoteZip]: for entry in entries: full_path = posixpath.join(remote_dir, entry.filename) - import stat if stat.S_ISDIR(entry.st_mode): results.extend(_walk_zips(sftp, full_path)) elif entry.filename.lower().endswith(".zip"): diff --git a/static/style.css b/static/style.css index 98a2204..809821a 100644 --- a/static/style.css +++ b/static/style.css @@ -204,6 +204,15 @@ textarea { font-family: monospace; resize: vertical; } padding: 0.3rem 0.8rem; } +/* Key fingerprint */ +.key-status { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.25rem; } +.key-fingerprint { font-family: monospace; font-size: 0.8rem; color: var(--muted); } + +/* Connection test result */ +.test-result { font-size: 0.85rem; margin-top: 0.5rem; min-height: 1.2em; } +.test-ok { color: var(--success); } +.test-fail { color: var(--error); } + /* Utilities */ .muted { color: var(--muted); } .small { font-size: 0.8rem; } diff --git a/templates/settings.html b/templates/settings.html index 545a4bf..d69b51e 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -55,11 +55,20 @@
- {% if has_key %} -

A key is already configured. Paste a new one above to replace it, or leave blank to keep the existing key.

+
+ Key configured + {{ key_fingerprint }} +
+
+ Replace key + +

Leave blank to keep the existing key.

+
+ {% else %} + {% endif %}
@@ -68,6 +77,11 @@ + +
+ +

+
@@ -89,6 +103,11 @@ + +
+ +

+
@@ -134,7 +153,26 @@ function toggleAuth(method) { document.getElementById("row-key").style.display = method === "key" ? "" : "none"; document.getElementById("row-password").style.display = method === "password" ? "" : "none"; } -// Init on load toggleAuth(document.querySelector('[name=sftp_auth_method]:checked')?.value || "key"); + +async function testConn(type, btn) { + const result = document.getElementById(`test-${type}-result`); + btn.disabled = true; + btn.textContent = "Testing…"; + result.className = "test-result"; + result.textContent = ""; + try { + const r = await fetch(`/api/test/${type}`); + const data = await r.json(); + result.textContent = data.message; + result.className = "test-result " + (data.ok ? "test-ok" : "test-fail"); + } catch (e) { + result.textContent = "Request failed: " + e; + result.className = "test-result test-fail"; + } finally { + btn.disabled = false; + btn.textContent = type === "ssh" ? "Test SSH connection" : "Test Calibre-Web connection"; + } +} {% endblock %} diff --git a/uploader.py b/uploader.py index 6d50ac6..4ffebe3 100644 --- a/uploader.py +++ b/uploader.py @@ -66,6 +66,15 @@ class CalibreClient: return "error" +def test_connection(cfg: CalibreConfig) -> tuple[bool, str]: + try: + client = CalibreClient(cfg) + client._ensure_auth() + return True, f"Authenticated to {cfg.url} as '{cfg.user}'." + except Exception as e: + return False, str(e) + + def _sha256(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as f: