diff --git a/README.md b/README.md index 23f7beb..c741600 100644 --- a/README.md +++ b/README.md @@ -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. Renamed/noncanonical torrent paths remain visible but are marked noncanonical 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 archive-client --config /etc/archive-control/client.toml --check-config diff --git a/src/archive_clients/qbittorrent.py b/src/archive_clients/qbittorrent.py new file mode 100644 index 0000000..0b7ba5c --- /dev/null +++ b/src/archive_clients/qbittorrent.py @@ -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 diff --git a/tests/test_qbittorrent.py b/tests/test_qbittorrent.py new file mode 100644 index 0000000..ed46c17 --- /dev/null +++ b/tests/test_qbittorrent.py @@ -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()