Added test and small fixes
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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"):
|
||||||
|
|||||||
@@ -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
@@ -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----- ... -----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----- ... -----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 %}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user