fix: move dns lookup to thread
This commit is contained in:
parent
5951837ab9
commit
691e37d279
6 changed files with 228 additions and 6 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -85,7 +86,7 @@ async def conditions_test(request: Request, encoder: Encoder, cache: Cache, conf
|
||||||
return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code)
|
return web.json_response({"error": "condition is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
|
@ -469,7 +470,7 @@ async def task_handler_inspect(request: Request, handler: TaskHandle, encoder: E
|
||||||
static_only: bool = data.get("static_only", False) if isinstance(data, dict) else False
|
static_only: bool = data.get("static_only", False) if isinstance(data, dict) else False
|
||||||
if not static_only:
|
if not static_only:
|
||||||
try:
|
try:
|
||||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
|
@ -710,7 +711,7 @@ async def task_metadata(request: Request, repo: TasksRepository, config: Config,
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
LOG.warning(f"Invalid thumbnail url '{url}'")
|
LOG.warning(f"Invalid thumbnail url '{url}'")
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
|
@ -301,7 +302,7 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
data={"status": False, "message": str(e), "error": str(e)},
|
data={"status": False, "message": str(e), "error": str(e)},
|
||||||
|
|
@ -453,7 +454,7 @@ async def get_archive_ids(request: Request, config: Config) -> Response:
|
||||||
for i, url in enumerate(data):
|
for i, url in enumerate(data):
|
||||||
dct = {"index": i, "url": url}
|
dct = {"index": i, "url": url}
|
||||||
try:
|
try:
|
||||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
||||||
dct.update(get_archive_id(url))
|
dct.update(get_archive_id(url))
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
dct.update({"id": None, "ie_key": None, "archive_id": None, "error": str(e)})
|
dct.update({"id": None, "ie_key": None, "archive_id": None, "error": str(e)})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
import random
|
||||||
import time
|
import time
|
||||||
|
|
@ -39,7 +40,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
|
||||||
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
|
return web.json_response(data={"error": "URL is required."}, status=web.HTTPForbidden.status_code)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
await asyncio.to_thread(validate_url, url, config.allow_internal_urls)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
|
return web.json_response(data={"error": str(e)}, status=web.HTTPForbidden.status_code)
|
||||||
|
|
||||||
|
|
@ -59,6 +60,7 @@ async def get_thumbnail(request: Request, config: Config) -> Response:
|
||||||
url=url,
|
url=url,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
headers=request_headers,
|
headers=request_headers,
|
||||||
|
timeout=10.0,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response.status_code != web.HTTPOk.status_code:
|
if response.status_code != web.HTTPOk.status_code:
|
||||||
|
|
|
||||||
120
app/tests/test_async_url_validation_routes.py
Normal file
120
app/tests/test_async_url_validation_routes.py
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from typing import Any, Generator
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.features.conditions import router as conditions_router
|
||||||
|
from app.features.tasks import router as tasks_router
|
||||||
|
from app.features.ytdlp import router as ytdlp_router
|
||||||
|
from app.library.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_config() -> Generator[None, None, None]:
|
||||||
|
Config._reset_singleton()
|
||||||
|
yield
|
||||||
|
Config._reset_singleton()
|
||||||
|
|
||||||
|
|
||||||
|
class _JsonRequest:
|
||||||
|
def __init__(self, payload: Any) -> None:
|
||||||
|
self._payload = payload
|
||||||
|
self.body_exists = payload is not None
|
||||||
|
|
||||||
|
async def json(self) -> Any:
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
class _InspectRequest(_JsonRequest):
|
||||||
|
query: dict[str, str] = {}
|
||||||
|
match_info: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class _QueryRequest:
|
||||||
|
def __init__(self, query: dict[str, str]) -> None:
|
||||||
|
self.query = query
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_to_thread(monkeypatch: pytest.MonkeyPatch, module: Any, config: Config, url: str) -> dict[str, bool]:
|
||||||
|
called = {"to_thread": False, "validate": False}
|
||||||
|
|
||||||
|
def fake_validate_url(next_url: str, allow_internal: bool = False) -> bool:
|
||||||
|
called["validate"] = True
|
||||||
|
assert next_url == url
|
||||||
|
assert allow_internal is config.allow_internal_urls
|
||||||
|
raise ValueError("Invalid hostname.")
|
||||||
|
|
||||||
|
async def fake_to_thread(func, *args, **kwargs):
|
||||||
|
called["to_thread"] = True
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(module, "validate_url", fake_validate_url)
|
||||||
|
monkeypatch.setattr(module.asyncio, "to_thread", fake_to_thread)
|
||||||
|
return called
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_inspect_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
config = Config.get_instance()
|
||||||
|
request = _InspectRequest({"url": "https://bad.example/task"})
|
||||||
|
called = _patch_to_thread(monkeypatch, tasks_router, config, "https://bad.example/task")
|
||||||
|
|
||||||
|
response = await tasks_router.task_handler_inspect(request, handler=None, encoder=None, config=config)
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert json.loads(response.body.decode("utf-8")) == {"error": "Invalid hostname."}
|
||||||
|
assert called == {"to_thread": True, "validate": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_conditions_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
config = Config.get_instance()
|
||||||
|
request = _JsonRequest({"url": "https://bad.example/cond", "condition": "title ~= 'x'"})
|
||||||
|
called = _patch_to_thread(monkeypatch, conditions_router, config, "https://bad.example/cond")
|
||||||
|
|
||||||
|
response = await conditions_router.conditions_test(request, encoder=None, cache=None, config=config)
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert json.loads(response.body.decode("utf-8")) == {"error": "Invalid hostname."}
|
||||||
|
assert called == {"to_thread": True, "validate": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_info_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
config = Config.get_instance()
|
||||||
|
request = _QueryRequest({"url": "https://bad.example/info"})
|
||||||
|
called = _patch_to_thread(monkeypatch, ytdlp_router, config, "https://bad.example/info")
|
||||||
|
|
||||||
|
response = await ytdlp_router.get_info(request, cache=None, config=config)
|
||||||
|
|
||||||
|
assert response.status == 400
|
||||||
|
assert json.loads(response.body.decode("utf-8")) == {
|
||||||
|
"status": False,
|
||||||
|
"message": "Invalid hostname.",
|
||||||
|
"error": "Invalid hostname.",
|
||||||
|
}
|
||||||
|
assert called == {"to_thread": True, "validate": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_archive_ids_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
config = Config.get_instance()
|
||||||
|
request = _JsonRequest(["https://bad.example/archive"])
|
||||||
|
called = _patch_to_thread(monkeypatch, ytdlp_router, config, "https://bad.example/archive")
|
||||||
|
|
||||||
|
response = await ytdlp_router.get_archive_ids(request, config)
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert json.loads(response.body.decode("utf-8")) == [
|
||||||
|
{
|
||||||
|
"index": 0,
|
||||||
|
"url": "https://bad.example/archive",
|
||||||
|
"id": None,
|
||||||
|
"ie_key": None,
|
||||||
|
"archive_id": None,
|
||||||
|
"error": "Invalid hostname.",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
assert called == {"to_thread": True, "validate": True}
|
||||||
97
app/tests/test_images_routes.py
Normal file
97
app/tests/test_images_routes.py
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Generator
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.test_utils import make_mocked_request
|
||||||
|
|
||||||
|
from app.library.config import Config
|
||||||
|
from app.routes.api import images
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_config() -> Generator[None, None, None]:
|
||||||
|
Config._reset_singleton()
|
||||||
|
yield
|
||||||
|
Config._reset_singleton()
|
||||||
|
|
||||||
|
|
||||||
|
class _Response:
|
||||||
|
def __init__(self, *, status_code: int = 200, content: bytes = b"img", content_type: str = "image/jpeg") -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self.content = content
|
||||||
|
self.headers = {"Content-Type": content_type}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_thumbnail_validate_off_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
config = Config.get_instance()
|
||||||
|
req = make_mocked_request("GET", "/api/thumbnail?url=https://example.com/a.jpg")
|
||||||
|
req._rel_url = req._rel_url.with_query({"url": "https://example.com/a.jpg"})
|
||||||
|
|
||||||
|
called = {"to_thread": False, "validate": False}
|
||||||
|
|
||||||
|
def fake_validate_url(url: str, allow_internal: bool = False) -> bool:
|
||||||
|
called["validate"] = True
|
||||||
|
assert url == "https://example.com/a.jpg"
|
||||||
|
assert allow_internal is config.allow_internal_urls
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def fake_to_thread(func, *args, **kwargs):
|
||||||
|
called["to_thread"] = True
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
client = AsyncMock()
|
||||||
|
client.request.return_value = _Response()
|
||||||
|
|
||||||
|
monkeypatch.setattr(images, "validate_url", fake_validate_url)
|
||||||
|
monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread)
|
||||||
|
monkeypatch.setattr(images, "get_async_client", lambda **_kwargs: client)
|
||||||
|
monkeypatch.setattr(images, "resolve_curl_transport", lambda: False)
|
||||||
|
monkeypatch.setattr(images, "build_request_headers", lambda **_kwargs: {})
|
||||||
|
monkeypatch.setattr(images.Globals, "get_random_agent", staticmethod(lambda: "agent"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
images.YTDLPOpts,
|
||||||
|
"get_instance",
|
||||||
|
staticmethod(
|
||||||
|
lambda: type(
|
||||||
|
"Opts",
|
||||||
|
(),
|
||||||
|
{
|
||||||
|
"preset": lambda self, name: self,
|
||||||
|
"get_all": lambda self: {},
|
||||||
|
},
|
||||||
|
)()
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await images.get_thumbnail(req, config)
|
||||||
|
|
||||||
|
assert response.status == web.HTTPOk.status_code
|
||||||
|
assert called["to_thread"] is True
|
||||||
|
assert called["validate"] is True
|
||||||
|
client.request.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_thumbnail_validate_rejects(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
config = Config.get_instance()
|
||||||
|
req = make_mocked_request("GET", "/api/thumbnail?url=https://bad.example/a.jpg")
|
||||||
|
req._rel_url = req._rel_url.with_query({"url": "https://bad.example/a.jpg"})
|
||||||
|
|
||||||
|
def fake_validate_url(_url: str, allow_internal: bool = False) -> bool:
|
||||||
|
assert allow_internal is config.allow_internal_urls
|
||||||
|
raise ValueError("Invalid hostname.")
|
||||||
|
|
||||||
|
async def fake_to_thread(func, *args, **kwargs):
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr(images, "validate_url", fake_validate_url)
|
||||||
|
monkeypatch.setattr(images.asyncio, "to_thread", fake_to_thread)
|
||||||
|
|
||||||
|
response = await images.get_thumbnail(req, config)
|
||||||
|
|
||||||
|
assert response.status == web.HTTPForbidden.status_code
|
||||||
|
assert response.text == '{"error": "Invalid hostname."}'
|
||||||
Loading…
Reference in a new issue