feat: add authenticated qbittorrent reader
This commit is contained in:
@@ -19,6 +19,9 @@ derives canonical resource identities and selection ranges, builds lazy content
|
|||||||
trees, and reports only safe Syncthing folders below the configured root.
|
trees, and reports only safe Syncthing folders below the configured root.
|
||||||
Renamed/noncanonical torrent paths remain visible but are marked noncanonical
|
Renamed/noncanonical torrent paths remain visible but are marked noncanonical
|
||||||
so later job preflight can reject them without hiding the resource.
|
so later job preflight can reject them without hiding the resource.
|
||||||
|
The qBittorrent read adapter uses cookie authentication, bounded responses,
|
||||||
|
one reauthentication attempt on session expiry, hash-scoped file/metainfo
|
||||||
|
fetches, and never logs credentials, cookies, response bodies, or endpoints.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
archive-client --config /etc/archive-control/client.toml --check-config
|
archive-client --config /etc/archive-control/client.toml --check-config
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""Authenticated, bounded qBittorrent read adapter."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from http.cookiejar import CookieJar
|
||||||
|
from typing import Any
|
||||||
|
from urllib import error, parse, request
|
||||||
|
|
||||||
|
from archive_clients.config import ServiceConfig
|
||||||
|
from archive_clients.resources import NormalizedResource, normalize_resource
|
||||||
|
|
||||||
|
|
||||||
|
class QBittorrentError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _NoRedirect(request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, *_: Any, **__: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class QBittorrentReader:
|
||||||
|
def __init__(self, config: ServiceConfig, timeout: float = 15):
|
||||||
|
self.config = config
|
||||||
|
self.timeout = timeout
|
||||||
|
self._opener = request.build_opener(
|
||||||
|
request.HTTPCookieProcessor(CookieJar()), _NoRedirect()
|
||||||
|
)
|
||||||
|
self._authenticated = False
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
def list_resources(self, name_filter: str = "") -> list[NormalizedResource]:
|
||||||
|
torrents = self._json("/api/v2/torrents/info")
|
||||||
|
if not isinstance(torrents, list):
|
||||||
|
raise QBittorrentError("qBittorrent torrent list is not an array")
|
||||||
|
result = []
|
||||||
|
folded_filter = name_filter.casefold()
|
||||||
|
for torrent in torrents:
|
||||||
|
if not isinstance(torrent, dict):
|
||||||
|
raise QBittorrentError("qBittorrent torrent record is invalid")
|
||||||
|
name = torrent.get("name")
|
||||||
|
if folded_filter and (
|
||||||
|
not isinstance(name, str) or folded_filter not in name.casefold()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
torrent_hash = torrent.get("hash")
|
||||||
|
if not isinstance(torrent_hash, str):
|
||||||
|
raise QBittorrentError("qBittorrent torrent hash is invalid")
|
||||||
|
result.append(self._normalize(torrent, torrent_hash))
|
||||||
|
return result
|
||||||
|
|
||||||
|
def get_resource(self, torrent_hash: str) -> NormalizedResource | None:
|
||||||
|
torrents = self._json(
|
||||||
|
"/api/v2/torrents/info", {"hashes": torrent_hash.lower()}
|
||||||
|
)
|
||||||
|
if not isinstance(torrents, list):
|
||||||
|
raise QBittorrentError("qBittorrent lookup response is invalid")
|
||||||
|
exact = [
|
||||||
|
torrent for torrent in torrents
|
||||||
|
if isinstance(torrent, dict)
|
||||||
|
and str(torrent.get("hash", "")).lower() == torrent_hash.lower()
|
||||||
|
]
|
||||||
|
if not exact:
|
||||||
|
return None
|
||||||
|
if len(exact) != 1:
|
||||||
|
raise QBittorrentError("qBittorrent lookup is ambiguous")
|
||||||
|
return self._normalize(exact[0], torrent_hash)
|
||||||
|
|
||||||
|
def _normalize(
|
||||||
|
self, torrent: dict[str, Any], torrent_hash: str
|
||||||
|
) -> NormalizedResource:
|
||||||
|
files = self._json(
|
||||||
|
"/api/v2/torrents/files", {"hash": torrent_hash}
|
||||||
|
)
|
||||||
|
if not isinstance(files, list) or not all(
|
||||||
|
isinstance(item, dict) for item in files
|
||||||
|
):
|
||||||
|
raise QBittorrentError("qBittorrent file list is invalid")
|
||||||
|
metainfo = self._bytes(
|
||||||
|
"/api/v2/torrents/export", {"hash": torrent_hash},
|
||||||
|
maximum=128 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
return normalize_resource(torrent, files, metainfo)
|
||||||
|
|
||||||
|
def _json(
|
||||||
|
self, path: str, parameters: dict[str, str] | None = None
|
||||||
|
) -> Any:
|
||||||
|
try:
|
||||||
|
return json.loads(self._bytes(path, parameters).decode("utf-8"))
|
||||||
|
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||||
|
raise QBittorrentError("qBittorrent returned invalid JSON") from exc
|
||||||
|
|
||||||
|
def _bytes(
|
||||||
|
self,
|
||||||
|
path: str,
|
||||||
|
parameters: dict[str, str] | None = None,
|
||||||
|
maximum: int = 8 * 1024 * 1024,
|
||||||
|
) -> bytes:
|
||||||
|
with self._lock:
|
||||||
|
if not self._authenticated:
|
||||||
|
self._login()
|
||||||
|
try:
|
||||||
|
return self._request(path, parameters, maximum)
|
||||||
|
except error.HTTPError as exc:
|
||||||
|
if exc.code not in {401, 403}:
|
||||||
|
raise QBittorrentError("qBittorrent HTTP request failed") from exc
|
||||||
|
self._authenticated = False
|
||||||
|
self._login()
|
||||||
|
try:
|
||||||
|
return self._request(path, parameters, maximum)
|
||||||
|
except error.HTTPError as retry_exc:
|
||||||
|
raise QBittorrentError(
|
||||||
|
"qBittorrent request failed after reauthentication"
|
||||||
|
) from retry_exc
|
||||||
|
|
||||||
|
def _login(self) -> None:
|
||||||
|
body = parse.urlencode({
|
||||||
|
"username": self.config.username,
|
||||||
|
"password": self.config.read_password(),
|
||||||
|
}).encode("utf-8")
|
||||||
|
call = request.Request(
|
||||||
|
self._url("/api/v2/auth/login"), data=body, method="POST",
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = self._read(self._opener.open(call, timeout=self.timeout), 64)
|
||||||
|
except (error.URLError, OSError) as exc:
|
||||||
|
raise QBittorrentError("qBittorrent authentication failed") from exc
|
||||||
|
if response.strip() != b"Ok.":
|
||||||
|
raise QBittorrentError("qBittorrent authentication failed")
|
||||||
|
self._authenticated = True
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
self, path: str, parameters: dict[str, str] | None, maximum: int
|
||||||
|
) -> bytes:
|
||||||
|
url = self._url(path)
|
||||||
|
if parameters:
|
||||||
|
url = f"{url}?{parse.urlencode(parameters)}"
|
||||||
|
try:
|
||||||
|
return self._read(
|
||||||
|
self._opener.open(url, timeout=self.timeout), maximum
|
||||||
|
)
|
||||||
|
except error.HTTPError:
|
||||||
|
raise
|
||||||
|
except (error.URLError, OSError) as exc:
|
||||||
|
raise QBittorrentError("qBittorrent service is unavailable") from exc
|
||||||
|
|
||||||
|
def _url(self, path: str) -> str:
|
||||||
|
return f"{self.config.endpoint.rstrip('/')}{path}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read(response: Any, maximum: int) -> bytes:
|
||||||
|
with response:
|
||||||
|
data = response.read(maximum + 1)
|
||||||
|
if len(data) > maximum:
|
||||||
|
raise QBittorrentError("qBittorrent response exceeds limit")
|
||||||
|
return data
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path, PurePosixPath
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from archive_clients.bencode import encode
|
||||||
|
from archive_clients.config import ServiceConfig
|
||||||
|
from archive_clients.qbittorrent import QBittorrentReader
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
def __init__(self, value: bytes):
|
||||||
|
self.value = io.BytesIO(value)
|
||||||
|
|
||||||
|
def read(self, size=-1):
|
||||||
|
return self.value.read(size)
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _Opener:
|
||||||
|
def __init__(self, values):
|
||||||
|
self.values = iter(values)
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def open(self, call, timeout):
|
||||||
|
self.calls.append(call)
|
||||||
|
return _Response(next(self.values))
|
||||||
|
|
||||||
|
|
||||||
|
class QBittorrentReaderTests(unittest.TestCase):
|
||||||
|
def test_hash_scoped_lookup_exports_and_normalizes(self):
|
||||||
|
info = {
|
||||||
|
b"length": 3, b"name": b"a.txt", b"piece length": 16384,
|
||||||
|
b"pieces": b"x" * 20,
|
||||||
|
}
|
||||||
|
torrent_bytes = encode({b"info": info})
|
||||||
|
torrent_hash = hashlib.sha1(encode(info)).hexdigest()
|
||||||
|
responses = [
|
||||||
|
b"Ok.",
|
||||||
|
json.dumps([{
|
||||||
|
"hash": torrent_hash, "name": "a.txt", "state": "uploading",
|
||||||
|
}]).encode(),
|
||||||
|
b'[{"index":0,"name":"a.txt","size":3,"progress":1,"priority":1}]',
|
||||||
|
torrent_bytes,
|
||||||
|
]
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
password = root / "password"
|
||||||
|
password.write_text("secret", encoding="utf-8")
|
||||||
|
os.chmod(password, 0o600)
|
||||||
|
config = ServiceConfig(
|
||||||
|
"http://qb", PurePosixPath("/downloads"), root,
|
||||||
|
username="admin", password_file=password,
|
||||||
|
)
|
||||||
|
opener = _Opener(responses)
|
||||||
|
with patch(
|
||||||
|
"archive_clients.qbittorrent.request.build_opener",
|
||||||
|
return_value=opener,
|
||||||
|
):
|
||||||
|
resource = QBittorrentReader(config).get_resource(torrent_hash)
|
||||||
|
self.assertIsNotNone(resource)
|
||||||
|
self.assertEqual(resource.summary.resource_id.info_hash_v1_hex, torrent_hash)
|
||||||
|
self.assertIn("hashes=", opener.calls[1])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user