diff --git a/backend/src/main.py b/backend/src/main.py index cd2b6bc1..25d33ed2 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -83,10 +83,13 @@ if VERSION != "DEV_VERSION": # app.mount("/icons", StaticFiles(directory="dist/icons"), name="icons") templates = Jinja2Templates(directory="dist") + # dist/ is immutable inside the container — snapshot once instead of + # hitting the filesystem on every request. + _DIST_FILES = frozenset(os.listdir("dist")) + @app.get("/{path:path}") def html(request: Request, path: str): - files = os.listdir("dist") - if path in files: + if path in _DIST_FILES: return FileResponse(f"dist/{path}") else: context = {"request": request} diff --git a/backend/src/module/api/downloader.py b/backend/src/module/api/downloader.py index 88712355..ac7a90dd 100644 --- a/backend/src/module/api/downloader.py +++ b/backend/src/module/api/downloader.py @@ -53,8 +53,14 @@ async def resume_torrents(req: TorrentHashesRequest): async def delete_torrents(req: TorrentDeleteRequest): hashes = "|".join(req.hashes) async with DownloadClient() as client: - await client.delete_torrent(hashes, delete_files=req.delete_files) - return {"msg_en": "Torrents deleted", "msg_zh": "种子已删除"} + ok = await client.delete_torrent(hashes, delete_files=req.delete_files) + if not ok: + return { + "status": False, + "msg_en": "Failed to delete torrents", + "msg_zh": "删除种子失败", + } + return {"status": True, "msg_en": "Torrents deleted", "msg_zh": "种子已删除"} @router.post("/torrents/tag", dependencies=[Depends(get_current_user)]) diff --git a/backend/src/module/api/setup.py b/backend/src/module/api/setup.py index cac73d27..fec53455 100644 --- a/backend/src/module/api/setup.py +++ b/backend/src/module/api/setup.py @@ -31,14 +31,19 @@ def _require_setup_needed(): raise HTTPException(status_code=403, detail="Setup already completed.") -def _validate_url(url: str) -> None: - """Reject non-HTTP schemes and private/reserved/loopback IPs.""" +def _validate_scheme(url: str) -> None: + """Reject non-HTTP schemes and URLs without a hostname.""" parsed = urlparse(url) if parsed.scheme not in ("http", "https"): raise HTTPException(status_code=400, detail="Only http/https URLs are allowed.") - hostname = parsed.hostname - if not hostname: + if not parsed.hostname: raise HTTPException(status_code=400, detail="Invalid URL: no hostname.") + + +def _validate_url(url: str) -> None: + """Reject non-HTTP schemes and private/reserved/loopback IPs.""" + _validate_scheme(url) + hostname = urlparse(url).hostname try: addrs = socket.getaddrinfo(hostname, None) except socket.gaierror: @@ -132,6 +137,9 @@ async def test_downloader(req: TestDownloaderRequest): scheme = "https" if req.ssl else "http" host = req.host if "://" in req.host else f"{scheme}://{req.host}" + # Private/loopback IPs stay allowed (a LAN NAS is the normal case), but + # only http/https schemes may be probed from this pre-auth endpoint (#1041). + _validate_scheme(host) try: async with httpx.AsyncClient(timeout=5.0) as client: @@ -153,7 +161,13 @@ async def test_downloader(req: TestDownloaderRequest): login_url, data={"username": req.username, "password": req.password}, ) - if login_resp.status_code == 200 and "ok" in login_resp.text.lower(): + # qBittorrent < 5.2 answers 200 + "Ok."; >= 5.2 answers 204 with + # an empty body on success (#1044). Keep the positive body check + # for 200 so a proxy answering 200 + HTML is not reported as a + # working login. + if login_resp.status_code == 204 or ( + login_resp.status_code == 200 and "ok" in login_resp.text.lower() + ): return TestResultResponse( success=True, message_en="Connection successful.", @@ -184,11 +198,13 @@ async def test_downloader(req: TestDownloaderRequest): message_zh="无法连接到主机。", ) except Exception as e: + # Log the detail server-side only — this endpoint is reachable before + # authentication, so raw errors must not be echoed back (#1041). logger.error(f"[Setup] Downloader test failed: {e}") return TestResultResponse( success=False, - message_en=f"Connection failed: {e}", - message_zh=f"连接失败:{e}", + message_en="Connection failed.", + message_zh="连接失败。", ) @@ -221,8 +237,8 @@ async def test_rss(req: TestRSSRequest): logger.error(f"[Setup] RSS test failed: {e}") return TestResultResponse( success=False, - message_en=f"Failed to fetch RSS feed: {e}", - message_zh=f"获取 RSS 源失败:{e}", + message_en="Failed to fetch RSS feed.", + message_zh="获取 RSS 源失败。", ) @@ -266,8 +282,8 @@ async def test_notification(req: TestNotificationRequest): logger.error(f"[Setup] Notification test failed: {e}") return TestResultResponse( success=False, - message_en=f"Notification test failed: {e}", - message_zh=f"通知测试失败:{e}", + message_en="Notification test failed.", + message_zh="通知测试失败。", ) @@ -338,6 +354,6 @@ async def complete_setup(req: SetupCompleteRequest): return ResponseModel( status=False, status_code=500, - msg_en=f"Setup failed: {e}", - msg_zh=f"设置失败:{e}", + msg_en="Setup failed. Check the server log for details.", + msg_zh="设置失败,请查看服务器日志。", ) diff --git a/backend/src/module/database/bangumi.py b/backend/src/module/database/bangumi.py index 07649122..30998340 100644 --- a/backend/src/module/database/bangumi.py +++ b/backend/src/module/database/bangumi.py @@ -1,6 +1,7 @@ import json import logging import re +import threading import time from typing import Optional @@ -64,16 +65,24 @@ def _set_aliases_list(bangumi: Bangumi, aliases: list[str]) -> None: bangumi.title_aliases = json.dumps(unique_aliases, ensure_ascii=False) -# Module-level TTL cache for search_all results +# Module-level TTL cache for search_all results. +# Guarded by a lock: notification paths read/write it from asyncio.to_thread +# worker threads while the event loop uses it concurrently. _bangumi_cache: list[Bangumi] | None = None _bangumi_cache_time: float = 0 _BANGUMI_CACHE_TTL: float = 300.0 # 5 minutes - extended from 60s to reduce DB queries +_bangumi_cache_lock = threading.Lock() +# Bumped on every invalidation so a search_all() that was already past the +# cache check cannot overwrite a newer invalidation with its stale snapshot. +_bangumi_cache_gen = 0 def _invalidate_bangumi_cache(): - global _bangumi_cache, _bangumi_cache_time - _bangumi_cache = None - _bangumi_cache_time = 0 + global _bangumi_cache, _bangumi_cache_time, _bangumi_cache_gen + with _bangumi_cache_lock: + _bangumi_cache = None + _bangumi_cache_time = 0 + _bangumi_cache_gen += 1 class BangumiDatabase: @@ -365,11 +374,13 @@ class BangumiDatabase: def search_all(self) -> list[Bangumi]: global _bangumi_cache, _bangumi_cache_time now = time.time() - if ( - _bangumi_cache is not None - and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL - ): - return _bangumi_cache + with _bangumi_cache_lock: + if ( + _bangumi_cache is not None + and (now - _bangumi_cache_time) < _BANGUMI_CACHE_TTL + ): + return _bangumi_cache + gen_at_query = _bangumi_cache_gen statement = select(Bangumi) result = self.session.execute(statement) bangumis = list(result.scalars().all()) @@ -377,9 +388,11 @@ class BangumiDatabase: # cached objects are accessed from a different session/request context for b in bangumis: self.session.expunge(b) - _bangumi_cache = bangumis - _bangumi_cache_time = now - return _bangumi_cache + with _bangumi_cache_lock: + if _bangumi_cache_gen == gen_at_query: + _bangumi_cache = bangumis + _bangumi_cache_time = now + return bangumis def search_id(self, _id: int) -> Optional[Bangumi]: statement = select(Bangumi).where(Bangumi.id == _id) diff --git a/backend/src/module/downloader/client/mock_downloader.py b/backend/src/module/downloader/client/mock_downloader.py index e900242d..49428031 100644 --- a/backend/src/module/downloader/client/mock_downloader.py +++ b/backend/src/module/downloader/client/mock_downloader.py @@ -110,13 +110,19 @@ class MockDownloader: ) return True - async def torrents_delete(self, hash: str, delete_files: bool = True): - hashes = hash.split("|") if "|" in hash else [hash] + async def torrents_delete( + self, hash: str | list, delete_files: bool = True + ) -> bool: + if isinstance(hash, (list, tuple)): + hashes = list(hash) + else: + hashes = hash.split("|") if "|" in hash else [hash] for h in hashes: self._torrents.pop(h, None) logger.debug( "[MockDownloader] torrents_delete(%s, delete_files=%s)", hash, delete_files ) + return True async def torrents_pause(self, hashes: str): for h in hashes.split("|"): diff --git a/backend/src/module/downloader/client/qb_downloader.py b/backend/src/module/downloader/client/qb_downloader.py index fc08bde7..cff3c6ee 100644 --- a/backend/src/module/downloader/client/qb_downloader.py +++ b/backend/src/module/downloader/client/qb_downloader.py @@ -47,7 +47,14 @@ class QbDownloader: self._url("auth/login"), data={"username": self.username, "password": self.password}, ) - if resp.status_code == 200 and resp.text == "Ok.": + # qBittorrent < 5.2 answers 200 + "Ok." / "Fails."; + # qBittorrent >= 5.2 answers 204 with an empty body on success + # (#1044). Keep the positive body check for 200 so a proxy or + # non-qB service answering 200 + HTML is not mistaken for a + # successful login. + if ( + resp.status_code == 200 and resp.text.startswith("Ok") + ) or resp.status_code == 204: return True elif resp.status_code == 403: logger.error("Login refused by qBittorrent Server") @@ -201,11 +208,22 @@ class QbDownloader: resp = await self._client.get(self._url("torrents/info"), params={"tag": tag}) return resp.json() - async def torrents_delete(self, hash, delete_files: bool = True): - await self._client.post( + async def torrents_delete(self, hash, delete_files: bool = True) -> bool: + # qBittorrent expects one pipe-joined "hashes" field; a Python list would + # be form-encoded as repeated fields and silently ignored (#1046). + hashes = "|".join(hash) if isinstance(hash, (list, tuple)) else hash + resp = await self._client.post( self._url("torrents/delete"), - data={"hashes": hash, "deleteFiles": str(delete_files).lower()}, + data={"hashes": hashes, "deleteFiles": str(delete_files).lower()}, ) + if resp.status_code != 200: + logger.error( + "[Downloader] Failed to delete torrents %s: HTTP %s", + hashes, + resp.status_code, + ) + return False + return True async def torrents_pause(self, hashes: str): await self._client.post( diff --git a/backend/src/module/downloader/download_client.py b/backend/src/module/downloader/download_client.py index a587eec9..7278ed95 100644 --- a/backend/src/module/downloader/download_client.py +++ b/backend/src/module/downloader/download_client.py @@ -52,6 +52,10 @@ class DownloadClient(TorrentPath): if not self.authed: await self.auth() if not self.authed: + # __aexit__ never runs when we raise here, so close the + # concrete client's connection pool now or it leaks on every + # failed connect (#1043). + await self.client.logout() raise ConnectionError("Download client authentication failed") else: logger.error("[Downloader] Already authed.") @@ -144,9 +148,13 @@ class DownloadClient(TorrentPath): logger.debug("[Downloader] Rename failed: %s >> %s", old_path, new_path) return result - async def delete_torrent(self, hashes, delete_files: bool = True): - await self.client.torrents_delete(hashes, delete_files=delete_files) - logger.info("[Downloader] Remove torrents.") + async def delete_torrent(self, hashes, delete_files: bool = True) -> bool: + ok = await self.client.torrents_delete(hashes, delete_files=delete_files) + if ok: + logger.info("[Downloader] Remove torrents.") + else: + logger.error("[Downloader] Failed to remove torrents.") + return ok async def pause_torrent(self, hashes: str): await self.client.torrents_pause(hashes) diff --git a/backend/src/module/manager/torrent.py b/backend/src/module/manager/torrent.py index 1bd28f94..219ccd1f 100644 --- a/backend/src/module/manager/torrent.py +++ b/backend/src/module/manager/torrent.py @@ -25,7 +25,13 @@ class TorrentManager(Database): async def delete_torrents(self, data: Bangumi, client: DownloadClient): hash_list = await self.__match_torrents_list(data) if hash_list: - await client.delete_torrent(hash_list) + if not await client.delete_torrent(hash_list): + return ResponseModel( + status_code=500, + status=False, + msg_en=f"Failed to delete torrents for {data.official_title}", + msg_zh=f"删除 {data.official_title} 种子失败", + ) logger.info(f"Delete rule and torrents for {data.official_title}") return ResponseModel( status_code=200, @@ -52,6 +58,14 @@ class TorrentManager(Database): torrent_message = None if file: torrent_message = await self.delete_torrents(data, client) + if torrent_message.status_code == 500: + return ResponseModel( + status_code=500, + status=False, + msg_en=f"Deleted rule for {data.official_title}, " + "but deleting its torrents failed.", + msg_zh=f"已删除 {data.official_title} 规则,但删除种子失败。", + ) logger.info(f"[Manager] Delete rule for {data.official_title}") return ResponseModel( status_code=200, diff --git a/backend/src/module/parser/analyser/openai.py b/backend/src/module/parser/analyser/openai.py index 5aa7a981..eb31b595 100644 --- a/backend/src/module/parser/analyser/openai.py +++ b/backend/src/module/parser/analyser/openai.py @@ -1,9 +1,8 @@ import json import logging -from concurrent.futures import ThreadPoolExecutor from typing import Any, Optional -from openai import AzureOpenAI, OpenAI +from openai import AsyncAzureOpenAI, AsyncOpenAI from pydantic import BaseModel from module.models import Bangumi @@ -62,19 +61,19 @@ class OpenAIParser: if not api_key: raise ValueError("API key is required.") if api_type == "azure": - self.client = AzureOpenAI( + self.client = AsyncAzureOpenAI( api_key=api_key, base_url=api_base, azure_deployment=kwargs.get("deployment_id", ""), api_version=kwargs.get("api_version", "2023-05-15"), ) else: - self.client = OpenAI(api_key=api_key, base_url=api_base) + self.client = AsyncOpenAI(api_key=api_key, base_url=api_base) self.model = model self.openai_kwargs = kwargs - def parse( + async def parse( self, text: str, prompt: str | None = None, asdict: bool = True ) -> dict | str: """parse text with openai @@ -96,11 +95,8 @@ class OpenAIParser: params = self._prepare_params(text, prompt) - with ThreadPoolExecutor(max_workers=1) as worker: - future = worker.submit(self.client.beta.chat.completions.parse, **params) - resp = future.result() - - result = resp.choices[0].message.parsed + resp = await self.client.beta.chat.completions.parse(**params) + result = resp.choices[0].message.parsed if asdict: if hasattr(result, "model_dump"): diff --git a/backend/src/module/parser/title_parser.py b/backend/src/module/parser/title_parser.py index b56b23fb..e19fc86e 100644 --- a/backend/src/module/parser/title_parser.py +++ b/backend/src/module/parser/title_parser.py @@ -57,14 +57,14 @@ class TitleParser: logger.warning("Please change bangumi info manually.") @staticmethod - def raw_parser(raw: str) -> Bangumi | None: + async def raw_parser(raw: str) -> Bangumi | None: language = settings.rss_parser.language try: # use OpenAI ChatGPT to parse raw title and get structured data if settings.experimental_openai.enable: kwargs = settings.experimental_openai.dict(exclude={"enable"}) gpt = OpenAIParser(**kwargs) - episode_dict = gpt.parse(raw, asdict=True) + episode_dict = await gpt.parse(raw, asdict=True) episode = Episode(**episode_dict) else: episode = raw_parser(raw) diff --git a/backend/src/module/rss/analyser.py b/backend/src/module/rss/analyser.py index cbd7a9af..d4279b26 100644 --- a/backend/src/module/rss/analyser.py +++ b/backend/src/module/rss/analyser.py @@ -49,7 +49,7 @@ class RSSAnalyser(TitleParser): new_data = [] seen_titles: set[str] = set() for torrent in torrents: - bangumi = self.raw_parser(raw=torrent.name) + bangumi = await self.raw_parser(raw=torrent.name) if bangumi and bangumi.title_raw not in seen_titles: await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent) if not full_parse: @@ -60,7 +60,7 @@ class RSSAnalyser(TitleParser): return new_data async def torrent_to_data(self, torrent: Torrent, rss: RSSItem) -> Bangumi: - bangumi = self.raw_parser(raw=torrent.name) + bangumi = await self.raw_parser(raw=torrent.name) if bangumi: await self.official_title_parser(bangumi=bangumi, rss=rss, torrent=torrent) bangumi.rss_link = rss.url diff --git a/backend/src/module/rss/engine.py b/backend/src/module/rss/engine.py index e126b5ef..9f2b6d13 100644 --- a/backend/src/module/rss/engine.py +++ b/backend/src/module/rss/engine.py @@ -1,8 +1,10 @@ import asyncio import logging import re +from collections import defaultdict from datetime import datetime, timezone from typing import Optional +from urllib.parse import urlparse from module.database import Database, engine from module.downloader import DownloadClient @@ -11,6 +13,10 @@ from module.network import RequestContent logger = logging.getLogger(__name__) +# Delay between consecutive requests to the same host. Firing all feeds of one +# site at once gets the whole batch rate-limited with HTTP 429 (#1026). +RSS_PER_HOST_DELAY = 2.0 + class RSSEngine(Database): def __init__(self, _engine=engine): @@ -149,18 +155,35 @@ class RSSEngine(Database): else: rss_item = self.rss.search_id(rss_id) rss_items = [rss_item] if rss_item else [] - # From RSS Items, fetch all torrents with concurrency limit + # From RSS Items, fetch all torrents: parallel across hosts, serial + # (with a delay) within one host so the site never sees a burst (#1026). logger.debug("[Engine] Get %s RSS items", len(rss_items)) semaphore = asyncio.Semaphore(5) - async def _limited_pull(item): - async with semaphore: - return await self._pull_rss_with_status(item) + async def _pull_host_group(items: list[RSSItem]): + group_results = [] + for i, item in enumerate(items): + if i and RSS_PER_HOST_DELAY: + await asyncio.sleep(RSS_PER_HOST_DELAY) + async with semaphore: + group_results.append(await self._pull_rss_with_status(item)) + return group_results - results = await asyncio.gather(*[_limited_pull(item) for item in rss_items]) + host_groups: dict[str, list[RSSItem]] = defaultdict(list) + for item in rss_items: + host_groups[urlparse(item.url).netloc].append(item) + group_lists = list(host_groups.values()) + grouped_results = await asyncio.gather( + *[_pull_host_group(items) for items in group_lists] + ) + item_results = [ + (item, result) + for items, results in zip(group_lists, grouped_results) + for item, result in zip(items, results) + ] now = datetime.now(timezone.utc).isoformat() # Process results sequentially (DB operations) - for rss_item, (new_torrents, error) in zip(rss_items, results): + for rss_item, (new_torrents, error) in item_results: # Update connection status rss_item.connection_status = "error" if error else "healthy" rss_item.last_checked_at = now diff --git a/backend/src/test/conftest.py b/backend/src/test/conftest.py index fa856fd7..61c13c73 100644 --- a/backend/src/test/conftest.py +++ b/backend/src/test/conftest.py @@ -222,5 +222,5 @@ def mock_download_client(): ] client.pause_torrent.return_value = None client.resume_torrent.return_value = None - client.delete_torrent.return_value = None + client.delete_torrent.return_value = True return client diff --git a/backend/src/test/test_api_downloader.py b/backend/src/test/test_api_downloader.py index 44d4f512..92680851 100644 --- a/backend/src/test/test_api_downloader.py +++ b/backend/src/test/test_api_downloader.py @@ -62,7 +62,7 @@ def mock_download_client(): ] client.pause_torrent.return_value = None client.resume_torrent.return_value = None - client.delete_torrent.return_value = None + client.delete_torrent.return_value = True return client @@ -433,3 +433,24 @@ class TestAutoTagTorrents: assert data["unmatched_count"] == 1 assert len(data["unmatched"]) == 1 mock_download_client.add_tag.assert_not_called() + + +class TestDeleteTorrentsFailure: + def test_delete_failure_is_reported(self, authed_client, mock_download_client): + """A rejected qB delete must not be reported as success (#1046).""" + mock_download_client.delete_torrent.return_value = False + with patch("module.api.downloader.DownloadClient") as MockClient: + MockClient.return_value.__aenter__ = AsyncMock( + return_value=mock_download_client + ) + MockClient.return_value.__aexit__ = AsyncMock(return_value=False) + + response = authed_client.post( + "/api/v1/downloader/torrents/delete", + json={"hashes": ["abc123"], "delete_files": True}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] is False + assert data["msg_en"] == "Failed to delete torrents" diff --git a/backend/src/test/test_download_client.py b/backend/src/test/test_download_client.py index 2ce43af9..bc10e2e5 100644 --- a/backend/src/test/test_download_client.py +++ b/backend/src/test/test_download_client.py @@ -48,6 +48,19 @@ class TestAuth: await download_client.auth() assert download_client.authed is False + async def test_aenter_closes_client_on_failed_auth( + self, download_client, mock_qb_client + ): + """__aenter__ must close the concrete client's pool before raising, + because __aexit__ never runs on a failed connect (leak fix, #1043).""" + mock_qb_client.auth.return_value = False + + with pytest.raises(ConnectionError): + async with download_client: + pass + + mock_qb_client.logout.assert_awaited_once() + # --------------------------------------------------------------------------- # init_downloader diff --git a/backend/src/test/test_issue_bugs.py b/backend/src/test/test_issue_bugs.py index 80f3114a..71d6a40d 100644 --- a/backend/src/test/test_issue_bugs.py +++ b/backend/src/test/test_issue_bugs.py @@ -340,11 +340,11 @@ class TestIssue990NumberPrefixTitle: assert result.resolution == "1080P" assert result.group == "ANi" - def test_title_parser_returns_bangumi_for_number_prefix_title(self): + async def test_title_parser_returns_bangumi_for_number_prefix_title(self): """TitleParser.raw_parser returns a valid Bangumi for number-prefixed titles.""" from module.parser.title_parser import TitleParser - result = TitleParser.raw_parser(self.PROBLEM_TITLE) + result = await TitleParser.raw_parser(self.PROBLEM_TITLE) assert result is not None assert result.official_title == "29 岁单身中坚冒险家的日常" assert result.title_raw == "29 岁单身中坚冒险家的日常" @@ -504,11 +504,11 @@ class TestIssue992NonEpisodicAttributeError: ] @pytest.mark.parametrize("title", NON_EPISODIC_TITLES) - def test_title_parser_returns_none_for_non_episodic(self, title): + async def test_title_parser_returns_none_for_non_episodic(self, title): """TitleParser.raw_parser should return None instead of crashing.""" from module.parser.title_parser import TitleParser - result = TitleParser.raw_parser(title) + result = await TitleParser.raw_parser(title) assert result is None def test_raw_parser_returns_none_for_unparseable(self): diff --git a/backend/src/test/test_openai.py b/backend/src/test/test_openai.py index db25cf0c..4d2ee08e 100644 --- a/backend/src/test/test_openai.py +++ b/backend/src/test/test_openai.py @@ -51,7 +51,7 @@ class TestOpenAIParser: params = azure_parser._prepare_params(text, DEFAULT_PROMPT) assert expected == params - def test_parse(self): + async def test_parse(self): text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" expected = { "group": "梦蓝字幕组", @@ -69,5 +69,5 @@ class TestOpenAIParser: with mock.patch("module.parser.analyser.OpenAIParser.parse") as mocker: mocker.return_value = json.dumps(expected) - result = self.parser.parse(text=text, asdict=False) + result = await self.parser.parse(text=text, asdict=False) assert json.loads(result) == expected diff --git a/backend/src/test/test_qb_downloader.py b/backend/src/test/test_qb_downloader.py index f90a370c..40d7ed72 100644 --- a/backend/src/test/test_qb_downloader.py +++ b/backend/src/test/test_qb_downloader.py @@ -528,3 +528,120 @@ class TestUrlHelper: host="http://nas.local:8080", username="u", password="p", ssl=True ) assert qb._url("torrents/info") == "http://nas.local:8080/api/v2/torrents/info" + + +# --------------------------------------------------------------------------- +# qBittorrent 5.2 login compatibility (#1044, #1034, #1043) +# --------------------------------------------------------------------------- + + +class TestAuthQb52Compat: + """qBittorrent >= 5.2 returns HTTP 204 with an empty body on success.""" + + async def test_auth_returns_true_on_204_empty_body(self): + """Returns True when server responds 204 + empty body (qB >= 5.2).""" + qb = QbDownloader( + host="localhost:8080", username="admin", password="pass", ssl=False + ) + + mock_client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 204 + mock_resp.text = "" + mock_client.post = AsyncMock(return_value=mock_resp) + + with patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=mock_client, + ): + result = await qb.auth() + + assert result is True + + async def test_auth_returns_false_on_200_fails_body(self): + """Returns False on 200 + 'Fails.' (bad credentials, qB < 5.2).""" + qb = QbDownloader( + host="localhost:8080", username="admin", password="wrong", ssl=False + ) + + mock_client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.text = "Fails." + mock_client.post = AsyncMock(return_value=mock_resp) + + with ( + patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=mock_client, + ), + patch( + "module.downloader.client.qb_downloader.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + result = await qb.auth(retry=1) + + assert result is False + + async def test_auth_returns_false_on_200_html_body(self): + """A 200 + HTML page (proxy, wrong service) is not a successful login.""" + qb = QbDownloader( + host="localhost:8080", username="admin", password="pass", ssl=False + ) + + mock_client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.text = "Sign in" + mock_client.post = AsyncMock(return_value=mock_resp) + + with ( + patch( + "module.downloader.client.qb_downloader.httpx.AsyncClient", + return_value=mock_client, + ), + patch( + "module.downloader.client.qb_downloader.asyncio.sleep", + new_callable=AsyncMock, + ), + ): + result = await qb.auth(retry=1) + + assert result is False + + +# --------------------------------------------------------------------------- +# torrents_delete (#1046) +# --------------------------------------------------------------------------- + + +class TestTorrentsDelete: + """torrents_delete must pipe-join hash lists and report failures.""" + + async def test_delete_joins_hash_list_with_pipe(self): + """A list of hashes is sent as a single pipe-joined string.""" + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + qb._client.post = AsyncMock(return_value=mock_resp) + + result = await qb.torrents_delete(["aaa", "bbb"], delete_files=True) + + assert result is True + sent = qb._client.post.call_args.kwargs["data"] + assert sent["hashes"] == "aaa|bbb" + assert sent["deleteFiles"] == "true" + + async def test_delete_returns_false_on_error_status(self): + """Non-200 response returns False instead of silently succeeding.""" + qb = QbDownloader(host="localhost:8080", username="u", password="p", ssl=False) + qb._client = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 403 + qb._client.post = AsyncMock(return_value=mock_resp) + + result = await qb.torrents_delete("aaa", delete_files=True) + + assert result is False diff --git a/backend/src/test/test_rss_engine_new.py b/backend/src/test/test_rss_engine_new.py index 14ea73c2..3512c756 100644 --- a/backend/src/test/test_rss_engine_new.py +++ b/backend/src/test/test_rss_engine_new.py @@ -369,3 +369,66 @@ class TestRefreshRssConcurrency: await rss_engine.refresh_rss(client) assert max_active <= 5 + + +# --------------------------------------------------------------------------- +# refresh_rss per-host throttling (#1026) +# --------------------------------------------------------------------------- + + +class TestRefreshRssPerHostThrottle: + async def test_same_host_requests_never_overlap(self, rss_engine): + """Feeds on the same host are fetched serially; other hosts stay parallel.""" + for i in range(3): + rss_engine.rss.add( + make_rss_item(url=f"https://nyaa.example/rss/{i}", name=f"nyaa{i}") + ) + rss_engine.rss.add(make_rss_item(url="https://mikan.example/rss", name="mikan")) + + from urllib.parse import urlparse + + active: dict[str, int] = {} + max_active: dict[str, int] = {} + + async def fake_pull(item): + host = urlparse(item.url).netloc + active[host] = active.get(host, 0) + 1 + max_active[host] = max(max_active.get(host, 0), active[host]) + # Give concurrently-scheduled pulls a chance to overlap. + await asyncio.sleep(0.01) + active[host] -= 1 + return [], None + + client = AsyncMock() + with ( + patch.object( + RSSEngine, + "_pull_rss_with_status", + new_callable=lambda: AsyncMock(side_effect=fake_pull), + ), + patch("module.rss.engine.RSS_PER_HOST_DELAY", 0), + ): + await rss_engine.refresh_rss(client) + + assert max_active["nyaa.example"] == 1 + assert max_active["mikan.example"] == 1 + + async def test_all_feeds_still_processed(self, rss_engine): + """Grouping by host must not drop any feed's status update.""" + rss_engine.rss.add(make_rss_item(url="https://a.example/rss", name="a")) + rss_engine.rss.add(make_rss_item(url="https://b.example/rss", name="b")) + + client = AsyncMock() + with ( + patch.object( + RSSEngine, + "_pull_rss_with_status", + new_callable=lambda: AsyncMock(return_value=([], None)), + ), + patch("module.rss.engine.RSS_PER_HOST_DELAY", 0), + ): + await rss_engine.refresh_rss(client) + + for rss_id in (1, 2): + item = rss_engine.rss.search_id(rss_id) + assert item.connection_status == "healthy" diff --git a/backend/src/test/test_setup.py b/backend/src/test/test_setup.py index 20e29e11..d342a21a 100644 --- a/backend/src/test/test_setup.py +++ b/backend/src/test/test_setup.py @@ -297,3 +297,95 @@ class TestSentinelPath: def test_sentinel_path_is_in_config_dir(self): assert str(SENTINEL_PATH) == "config/.setup_complete" assert SENTINEL_PATH.parent == Path("config") + + +class TestTestDownloaderHardening: + """qB 5.2 login compat (#1044) and SSRF hardening (#1041).""" + + @staticmethod + def _mock_client(get_resp=None, login_resp=None, get_exc=None): + mock_instance = AsyncMock() + if get_exc is not None: + mock_instance.get.side_effect = get_exc + else: + mock_instance.get.return_value = get_resp + mock_instance.post.return_value = login_resp + cls_patch = patch("module.api.setup.httpx.AsyncClient") + mock_cls = cls_patch.start() + mock_cls.return_value.__aenter__ = AsyncMock(return_value=mock_instance) + mock_cls.return_value.__aexit__ = AsyncMock(return_value=False) + return cls_patch + + def _post(self, client, host="192.168.1.100:8080"): + return client.post( + "/api/v1/setup/test-downloader", + json={ + "type": "qbittorrent", + "host": host, + "username": "admin", + "password": "admin", + "ssl": False, + }, + ) + + def test_login_accepts_204_empty_body(self, client, mock_first_run): + """qBittorrent >= 5.2 returns 204 + empty body on successful login.""" + from unittest.mock import MagicMock + + get_resp = MagicMock(text="qBittorrent WebUI") + login_resp = MagicMock(status_code=204, text="") + cls_patch = self._mock_client(get_resp=get_resp, login_resp=login_resp) + try: + response = self._post(client) + finally: + cls_patch.stop() + assert response.status_code == 200 + assert response.json()["success"] is True + + def test_login_rejects_200_fails_body(self, client, mock_first_run): + """200 + 'Fails.' (bad credentials) is still a failure.""" + from unittest.mock import MagicMock + + get_resp = MagicMock(text="qBittorrent WebUI") + login_resp = MagicMock(status_code=200, text="Fails.") + cls_patch = self._mock_client(get_resp=get_resp, login_resp=login_resp) + try: + response = self._post(client) + finally: + cls_patch.stop() + assert response.status_code == 200 + assert response.json()["success"] is False + + def test_non_http_scheme_rejected(self, client, mock_first_run): + """Non-http(s) schemes must be rejected before any request is made.""" + response = self._post(client, host="ftp://internal-server:21") + assert response.status_code == 400 + + def test_exception_detail_not_echoed(self, client, mock_first_run): + """Raw exception text must not leak into the API response (#1041).""" + cls_patch = self._mock_client(get_exc=Exception("secret-detail-xyz")) + try: + response = self._post(client) + finally: + cls_patch.stop() + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "secret-detail-xyz" not in data["message_en"] + assert "secret-detail-xyz" not in data["message_zh"] + + def test_login_rejects_200_html_body(self, client, mock_first_run): + """A proxy answering 200 + HTML to the login POST is not a success.""" + from unittest.mock import MagicMock + + get_resp = MagicMock(text="qBittorrent WebUI") + login_resp = MagicMock( + status_code=200, text="portal" + ) + cls_patch = self._mock_client(get_resp=get_resp, login_resp=login_resp) + try: + response = self._post(client) + finally: + cls_patch.stop() + assert response.status_code == 200 + assert response.json()["success"] is False diff --git a/backend/src/test/test_title_parser.py b/backend/src/test/test_title_parser.py index b23ab4a2..012ac25f 100644 --- a/backend/src/test/test_title_parser.py +++ b/backend/src/test/test_title_parser.py @@ -4,9 +4,9 @@ from module.parser.title_parser import TitleParser class TestTitleParser: - def test_parse_without_openai(self): + async def test_parse_without_openai(self): text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" - result = TitleParser.raw_parser(text) + result = await TitleParser.raw_parser(text) assert result.group_name == "梦蓝字幕组" assert result.title_raw == "New Doraemon" assert result.dpi == "1080P" @@ -17,9 +17,9 @@ class TestTitleParser: not settings.experimental_openai.enable, reason="OpenAI is not enabled in settings", ) - def test_parse_with_openai(self): + async def test_parse_with_openai(self): text = "[梦蓝字幕组]New Doraemon 哆啦A梦新番[747][2023.02.25][AVC][1080P][GB_JP][MP4]" - result = TitleParser.raw_parser(text) + result = await TitleParser.raw_parser(text) assert result.group_name == "梦蓝字幕组" assert result.title_raw == "New Doraemon" assert result.dpi == "1080P"