Added test and small fixes

This commit is contained in:
2026-05-10 16:00:58 +02:00
parent e9ec445445
commit 31bd274824
5 changed files with 126 additions and 10 deletions
+21 -2
View File
@@ -10,7 +10,9 @@ from fastapi.templating import Jinja2Templates
import config import config
import db import db
import sftp as sftp_module
import sync import sync
import uploader
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s%(message)s") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s%(message)s")
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -94,10 +96,11 @@ async def books_page(request: Request, page: int = 1):
@app.get("/settings", response_class=HTMLResponse) @app.get("/settings", response_class=HTMLResponse)
async def settings_page(request: Request): async def settings_page(request: Request):
s = db.get_all_settings() 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", { return templates.TemplateResponse(request, "settings.html", {
"s": s, "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) 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 --- # --- JSON status API ---
@app.get("/api/status") @app.get("/api/status")
+44 -3
View File
@@ -1,6 +1,7 @@
import io import io
import logging import logging
import posixpath import posixpath
import stat
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
@@ -11,6 +12,13 @@ from config import SFTPConfig
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_KEY_CLASSES = [
paramiko.Ed25519Key,
paramiko.RSAKey,
paramiko.ECDSAKey,
paramiko.DSSKey,
]
@dataclass @dataclass
class RemoteZip: class RemoteZip:
@@ -18,16 +26,51 @@ class RemoteZip:
file_size: int 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: def _make_transport(cfg: SFTPConfig) -> paramiko.Transport:
transport = paramiko.Transport((cfg.host, cfg.port)) transport = paramiko.Transport((cfg.host, cfg.port))
if cfg.auth_method == "key" and cfg.key: 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) transport.connect(username=cfg.user, pkey=key)
else: else:
transport.connect(username=cfg.user, password=cfg.password) transport.connect(username=cfg.user, password=cfg.password)
return transport 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]: def list_new_zips(cfg: SFTPConfig) -> list[RemoteZip]:
transport = _make_transport(cfg) transport = _make_transport(cfg)
sftp = paramiko.SFTPClient.from_transport(transport) 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: def download(cfg: SFTPConfig, remote_zip: RemoteZip, dest_dir: str) -> Path:
dest = Path(dest_dir) dest = Path(dest_dir)
dest.mkdir(parents=True, exist_ok=True) dest.mkdir(parents=True, exist_ok=True)
local_path = dest / Path(remote_zip.remote_path).name local_path = dest / Path(remote_zip.remote_path).name
transport = _make_transport(cfg) transport = _make_transport(cfg)
sftp = paramiko.SFTPClient.from_transport(transport) 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: for entry in entries:
full_path = posixpath.join(remote_dir, entry.filename) full_path = posixpath.join(remote_dir, entry.filename)
import stat
if stat.S_ISDIR(entry.st_mode): if stat.S_ISDIR(entry.st_mode):
results.extend(_walk_zips(sftp, full_path)) results.extend(_walk_zips(sftp, full_path))
elif entry.filename.lower().endswith(".zip"): elif entry.filename.lower().endswith(".zip"):
+9
View File
@@ -204,6 +204,15 @@ textarea { font-family: monospace; resize: vertical; }
padding: 0.3rem 0.8rem; 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 */ /* Utilities */
.muted { color: var(--muted); } .muted { color: var(--muted); }
.small { font-size: 0.8rem; } .small { font-size: 0.8rem; }
+43 -5
View File
@@ -55,11 +55,20 @@
<div class="form-row" id="row-key"> <div class="form-row" id="row-key">
<label for="sftp_key">Private key (PEM)</label> <label for="sftp_key">Private key (PEM)</label>
<textarea id="sftp_key" name="sftp_key" rows="8" placeholder="-----BEGIN RSA PRIVATE KEY-----
...
-----END RSA PRIVATE KEY-----"></textarea>
{% if has_key %} {% if has_key %}
<p class="muted small">A key is already configured. Paste a new one above to replace it, or leave blank to keep the existing key.</p> <div class="key-status">
<span class="badge badge-uploaded">Key configured</span>
<code class="key-fingerprint">{{ key_fingerprint }}</code>
</div>
<details style="margin-top:0.5rem">
<summary class="muted small" style="cursor:pointer">Replace key</summary>
<textarea id="sftp_key" name="sftp_key" rows="8" style="margin-top:0.5rem"
placeholder="-----BEGIN RSA PRIVATE KEY-----&#10;...&#10;-----END RSA PRIVATE KEY-----"></textarea>
<p class="muted small">Leave blank to keep the existing key.</p>
</details>
{% else %}
<textarea id="sftp_key" name="sftp_key" rows="8"
placeholder="-----BEGIN RSA PRIVATE KEY-----&#10;...&#10;-----END RSA PRIVATE KEY-----"></textarea>
{% endif %} {% endif %}
</div> </div>
@@ -68,6 +77,11 @@
<input id="sftp_password" name="sftp_password" type="password" <input id="sftp_password" name="sftp_password" type="password"
value="{{ s.get('sftp_password','') }}"> value="{{ s.get('sftp_password','') }}">
</div> </div>
<div class="form-row">
<button type="button" class="btn btn-secondary" onclick="testConn('ssh', this)">Test SSH connection</button>
<p id="test-ssh-result" class="test-result"></p>
</div>
</section> </section>
<section class="form-section"> <section class="form-section">
@@ -89,6 +103,11 @@
<input id="calibre_pass" name="calibre_pass" type="password" <input id="calibre_pass" name="calibre_pass" type="password"
value="{{ s.get('calibre_pass','') }}"> value="{{ s.get('calibre_pass','') }}">
</div> </div>
<div class="form-row">
<button type="button" class="btn btn-secondary" onclick="testConn('calibre', this)">Test Calibre-Web connection</button>
<p id="test-calibre-result" class="test-result"></p>
</div>
</section> </section>
<section class="form-section"> <section class="form-section">
@@ -134,7 +153,26 @@ function toggleAuth(method) {
document.getElementById("row-key").style.display = method === "key" ? "" : "none"; document.getElementById("row-key").style.display = method === "key" ? "" : "none";
document.getElementById("row-password").style.display = method === "password" ? "" : "none"; document.getElementById("row-password").style.display = method === "password" ? "" : "none";
} }
// Init on load
toggleAuth(document.querySelector('[name=sftp_auth_method]:checked')?.value || "key"); 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";
}
}
</script> </script>
{% endblock %} {% endblock %}
+9
View File
@@ -66,6 +66,15 @@ class CalibreClient:
return "error" 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: def _sha256(path: Path) -> str:
h = hashlib.sha256() h = hashlib.sha256()
with path.open("rb") as f: with path.open("rb") as f: