Patch: Various fixes (#660)

Various fixes from the last couple days: 

- Add manual approval option for book/audiobook requests (#651)
- Add flagged HTTP headers 
- Add filesystem fallback - copy + delete when hardlink/move fails
across filesystems (#647)
- Dependency updates
- Tweak frontend test config (simplified tsconfig for tests)
- Fix overlapping sort scoring in release modal - duplicate scoring keys
caused incorrect release ordering (#654 )
- Fix stale search session after download - search state was not
refreshed when returning from a download (#659)
- Fix multi-format release filtering - releases with multiple formats
were incorrectly excluded by the format filter (#658)
- Fix config persistence when action button is used - clicking "Test
connection" reset unsaved settings (#657)
- Fix request grid text positioning in admin request policy panel 
- Fix rTorrent path discovery (#653)
- Fix `/login` API check (#650)
This commit is contained in:
Alex
2026-02-25 18:44:46 +00:00
committed by GitHub
parent 91cbd51b67
commit ac36d539c8
33 changed files with 789 additions and 611 deletions
+1
View File
@@ -3,6 +3,7 @@ flask-cors
flask-socketio
python-socketio
requests[socks]
defusedxml
beautifulsoup4
tqdm
dnspython
+1
View File
@@ -708,6 +708,7 @@ def register_request_routes(
queue_release=queue_release,
release_data=data.get("release_data"),
admin_note=data.get("admin_note"),
manual_approval=data.get("manual_approval", False),
)
except RequestServiceError as exc:
return _error_response(str(exc), exc.status_code, code=exc.code)
+21
View File
@@ -413,6 +413,7 @@ def fulfil_request(
queue_release: Callable[..., tuple[bool, str | None]],
release_data: Any = None,
admin_note: Any = None,
manual_approval: Any = False,
) -> dict[str, Any]:
"""Fulfil a pending request and queue the release under requesting-user identity."""
request_row = ensure_request_access(
@@ -434,10 +435,30 @@ def fulfil_request(
raise RequestServiceError("admin_note must be a string", status_code=400)
normalized_admin_note = admin_note.strip() or None
if not isinstance(manual_approval, bool):
raise RequestServiceError("manual_approval must be a boolean", status_code=400)
selected_release_data = release_data if release_data is not None else request_row.get("release_data")
if selected_release_data is not None and not isinstance(selected_release_data, dict):
raise RequestServiceError("release_data must be an object", status_code=400)
if selected_release_data is None and manual_approval:
try:
return user_db.update_request(
request_id,
expected_current_status="pending",
status="fulfilled",
release_data=None,
delivery_state="complete",
delivery_updated_at=_now_timestamp(),
last_failure_reason=None,
admin_note=normalized_admin_note,
reviewed_by=admin_user_id,
reviewed_at=_now_timestamp(),
)
except ValueError as exc:
raise RequestServiceError(str(exc), status_code=409, code="stale_transition") from exc
if request_row["request_level"] == "book" and selected_release_data is None:
raise RequestServiceError(
"release_data is required to fulfil book-level requests",
+25
View File
@@ -1,8 +1,11 @@
"""Shared utility functions for the Shelfmark."""
import base64
import importlib
import os
import re
from threading import Lock
from types import ModuleType
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
@@ -54,6 +57,28 @@ def normalize_http_url(
return normalized
_xmlrpc_patch_lock = Lock()
_xmlrpc_patch_applied = False
def get_hardened_xmlrpc_client() -> ModuleType:
"""Return ``xmlrpc.client`` after best-effort defusedxml monkey patching."""
global _xmlrpc_patch_applied
if not _xmlrpc_patch_applied:
with _xmlrpc_patch_lock:
if not _xmlrpc_patch_applied:
try:
from defusedxml.xmlrpc import monkey_patch
monkey_patch()
_xmlrpc_patch_applied = True
except Exception:
# Keep runtime behavior unchanged if defusedxml is unavailable.
_xmlrpc_patch_applied = False
return importlib.import_module("xmlrpc.client")
def normalize_base_path(value: Optional[str]) -> str:
"""Normalize a URL base path for reverse proxy subpath deployments."""
if not isinstance(value, str):
+13 -12
View File
@@ -10,7 +10,7 @@ from urllib.parse import urlparse
from shelfmark.core.config import config
from shelfmark.core.logger import setup_logger
from shelfmark.core.utils import normalize_http_url
from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client
from shelfmark.download.network import get_ssl_verify
from shelfmark.download.clients import (
DownloadClient,
@@ -26,17 +26,17 @@ logger = setup_logger(__name__)
def _create_rtorrent_server_proxy(url: str) -> Any:
"""Create an XML-RPC ServerProxy honoring certificate validation mode."""
from xmlrpc.client import SafeTransport, ServerProxy
xmlrpc_client = get_hardened_xmlrpc_client()
verify = get_ssl_verify(url)
if url.startswith("https://") and not verify:
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
transport = SafeTransport(context=ssl_context)
return ServerProxy(url, transport=transport)
transport = xmlrpc_client.SafeTransport(context=ssl_context)
return xmlrpc_client.ServerProxy(url, transport=transport)
return ServerProxy(url)
return xmlrpc_client.ServerProxy(url)
@register_client("torrent")
@@ -120,7 +120,7 @@ class RTorrentClient(DownloadClient):
download_dir = self._download_dir or self._get_download_dir()
if download_dir:
logger.debug(f"Setting rTorrent download directory: {download_dir}")
commands.append(f"d.directory_base.set={download_dir}")
commands.append(f"d.directory.set={download_dir}")
if torrent_info.torrent_data:
logger.debug(f"Adding torrent data directly to rTorrent for: {name} with commands: {commands} with data size: {len(torrent_info.torrent_data)}")
@@ -156,10 +156,9 @@ class RTorrentClient(DownloadClient):
try:
# rtorrent is somehow case sensitive and requires uppercase hashes for look
download_id = download_id.upper()
torrent_list = self._rpc.d.multicall.filtered(
all_torrents = self._rpc.d.multicall2(
"",
"",
"default",
f"equal={{d.hash=,cat={download_id}}}",
"d.hash=",
"d.state=",
"d.completed_bytes=",
@@ -169,6 +168,7 @@ class RTorrentClient(DownloadClient):
"d.custom1=",
"d.complete=",
)
torrent_list = [t for t in all_torrents if t and t[0] == download_id]
logger.debug(f"Fetched torrent status from rTorrent for: {download_id} - {torrent_list}")
if not torrent_list:
logger.warning(f"Torrent not found in rTorrent: {download_id}")
@@ -327,12 +327,13 @@ class RTorrentClient(DownloadClient):
try:
# rTorrent is case sensitive for hashes; use uppercase as in get_status()
download_hash = download_id.upper()
details = self._rpc.d.multicall.filtered(
all_torrents = self._rpc.d.multicall2(
"",
"default",
f"equal={{d.hash=,cat={download_hash}}}",
"",
"d.hash=",
"d.base_path=",
)
details = [t[1:] for t in all_torrents if t and t[0] == download_hash]
if not details:
return None
path = details[0][0]
+8 -4
View File
@@ -12,7 +12,7 @@ from shelfmark.core.settings_registry import (
SelectField,
TagListField,
)
from shelfmark.core.utils import normalize_http_url
from shelfmark.core.utils import normalize_http_url, get_hardened_xmlrpc_client
from shelfmark.download.network import get_ssl_verify
@@ -254,7 +254,6 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
from shelfmark.core.config import config
import ssl
from urllib.parse import urlparse
from xmlrpc.client import SafeTransport, ServerProxy
current_values = current_values or {}
@@ -270,6 +269,8 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
return {"success": False, "message": "rTorrent URL is invalid"}
try:
xmlrpc_client = get_hardened_xmlrpc_client()
# Add HTTP auth to URL if credentials provided
if username and password:
parsed = urlparse(url)
@@ -281,9 +282,12 @@ def _test_rtorrent_connection(current_values: Optional[Dict[str, Any]] = None) -
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
rpc = ServerProxy(rpc_url, transport=SafeTransport(context=ssl_context))
rpc = xmlrpc_client.ServerProxy(
rpc_url,
transport=xmlrpc_client.SafeTransport(context=ssl_context),
)
else:
rpc = ServerProxy(rpc_url)
rpc = xmlrpc_client.ServerProxy(rpc_url)
version = rpc.system.client_version()
return {"success": True, "message": f"Connected to rTorrent {version}"}
+42
View File
@@ -200,6 +200,28 @@ def _perform_nfs_fallback(source: Path, dest: Path, is_move: bool) -> None:
raise
def _is_enoent_error(error: Exception) -> bool:
return isinstance(error, FileNotFoundError) or (
isinstance(error, OSError) and error.errno == errno.ENOENT
)
def _can_use_partial_copy_after_enoent(
temp_path: Optional[Path],
expected_size: int,
action: str,
) -> bool:
"""Recover when copy2 writes bytes but fails while copying source metadata."""
if not temp_path or not run_blocking_io(temp_path.exists):
return False
try:
_verify_transfer_size(temp_path, expected_size, action)
return True
except Exception:
return False
def _claim_destination(path: Path) -> bool:
"""Atomically claim a destination path by creating a placeholder file.
@@ -359,6 +381,16 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
copy_error,
)
_perform_nfs_fallback(source_path, temp_path, is_move=False)
elif _is_enoent_error(copy_error) and _can_use_partial_copy_after_enoent(
temp_path,
expected_size,
"move",
):
logger.warning(
"Source vanished during move-copy metadata step; preserving copied data: %s -> %s",
source_path,
temp_path,
)
else:
raise
@@ -525,6 +557,16 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
fallback_error,
)
raise e from fallback_error
elif _is_enoent_error(e) and _can_use_partial_copy_after_enoent(
temp_path,
expected_size,
"copy",
):
logger.warning(
"Source vanished during copy2 metadata step; preserving copied data: %s -> %s",
source_path,
temp_path,
)
else:
raise
+14
View File
@@ -590,6 +590,20 @@ def proxy_auth_middleware():
logger.error(f"Proxy auth middleware error: {e}")
return jsonify({"error": "Authentication error"}), 500
@app.after_request
def set_security_headers(response: Response) -> Response:
"""Add baseline security headers to every response."""
response.headers.setdefault("X-Content-Type-Options", "nosniff")
response.headers.setdefault("X-Frame-Options", "DENY")
response.headers.setdefault(
"Content-Security-Policy",
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' ws: wss:; frame-ancestors 'none'",
)
response.headers.setdefault("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
response.headers.setdefault("Cross-Origin-Embedder-Policy", "credentialless")
return response
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
@@ -8,7 +8,9 @@ isn't available via Prowlarr's JSON search endpoint.
from __future__ import annotations
from typing import Any, Dict, List, Optional
from xml.etree import ElementTree as ET
from defusedxml import ElementTree as DefusedElementTree
from defusedxml.common import DefusedXmlException
def _local_name(tag: str) -> str:
@@ -67,8 +69,8 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]:
return []
try:
root = ET.fromstring(xml_text)
except ET.ParseError:
root = DefusedElementTree.fromstring(xml_text)
except (DefusedElementTree.ParseError, DefusedXmlException):
return []
items = root.findall(".//item")
@@ -168,4 +170,3 @@ def parse_torznab_xml(xml_text: str) -> List[Dict[str, Any]]:
})
return results
+75 -530
View File
@@ -10,7 +10,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.2",
"react-router-dom": "^6.30.3",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
@@ -69,6 +69,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -711,24 +712,6 @@
"node": ">=12"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -817,21 +800,10 @@
"node": ">= 8"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.1",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
"integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
"version": "1.23.2",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -1216,6 +1188,7 @@
"integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.16.0"
}
@@ -1233,6 +1206,7 @@
"integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
@@ -1269,32 +1243,6 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/any-promise": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
@@ -1361,13 +1309,6 @@
"postcss": "^8.1.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.25",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.25.tgz",
@@ -1391,16 +1332,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -1434,6 +1365,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.19",
"caniuse-lite": "^1.0.30001751",
@@ -1517,26 +1449,6 @@
"node": ">= 6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
@@ -1554,21 +1466,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
"which": "^2.0.1"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -1621,13 +1518,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.249",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.249.tgz",
@@ -1635,13 +1525,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/engine.io-client": {
"version": "6.6.3",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
@@ -1783,23 +1666,6 @@
"node": ">=8"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -1849,27 +1715,6 @@
"node": ">=6.9.0"
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -1935,16 +1780,6 @@
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -1968,35 +1803,13 @@
"node": ">=0.12.0"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@@ -2099,32 +1912,6 @@
"node": ">=8.6"
}
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2209,23 +1996,6 @@
"node": ">= 6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
@@ -2233,30 +2003,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2317,6 +2063,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -2486,6 +2233,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -2498,6 +2246,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -2517,12 +2266,12 @@
}
},
"node_modules/react-router": {
"version": "6.30.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
"integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.1"
"@remix-run/router": "1.23.2"
},
"engines": {
"node": ">=14.0.0"
@@ -2532,13 +2281,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
"integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
"version": "6.30.3",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.1",
"react-router": "6.30.2"
"@remix-run/router": "1.23.2",
"react-router": "6.30.3"
},
"engines": {
"node": ">=14.0.0"
@@ -2688,42 +2437,6 @@
"semver": "bin/semver.js"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/shebang-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/socket.io-client": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
@@ -2796,123 +2509,19 @@
"node": ">=0.10.0"
}
},
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/sucrase": {
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
"version": "3.35.1",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
"integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
"commander": "^4.0.0",
"glob": "^10.3.10",
"lines-and-columns": "^1.1.6",
"mz": "^2.7.0",
"pirates": "^4.0.1",
"tinyglobby": "^0.2.11",
"ts-interface-checker": "^0.1.9"
},
"bin": {
@@ -2997,6 +2606,55 @@
"node": ">=0.8"
}
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyglobby/node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@@ -3082,6 +2740,7 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.43",
@@ -3136,120 +2795,6 @@
}
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
},
"bin": {
"node-which": "bin/node-which"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+1 -1
View File
@@ -14,7 +14,7 @@
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.30.2",
"react-router-dom": "^6.30.3",
"socket.io-client": "^4.7.5"
},
"devDependencies": {
+13
View File
@@ -1039,12 +1039,25 @@ function App() {
record: RequestRecord,
options?: {
browseOnly?: boolean;
manualApproval?: boolean;
}
) => {
if (!requestRoleIsAdmin) {
return;
}
if (options?.manualApproval) {
try {
await fulfilSidebarRequest(requestId, undefined, undefined, true);
await refreshActivitySnapshot();
showToast('Request approved', 'success');
await fetchStatus();
} catch (error) {
showToast(getErrorMessage(error, 'Failed to approve request'), 'error');
}
return;
}
const shouldBrowse = Boolean(options?.browseOnly) || record.request_level === 'book';
if (!shouldBrowse && record.request_level === 'release') {
+16 -16
View File
@@ -31,6 +31,7 @@ import {
releaseLanguageMatchesFilter,
buildLanguageNormalizer,
} from '../utils/languageFilters';
import { getReleaseFormats } from '../utils/releaseFormats';
// Module-level cache for release search results
// Key format: `${provider}:${provider_id}:${source}:${contentType}`
@@ -266,9 +267,7 @@ function getTitleMatchScore(title: string, titleCandidate: string): number {
if (normalizedTitle.startsWith(titleCandidate)) {
score += 6000;
}
if (normalizedTitle.includes(titleCandidate)) {
} else if (normalizedTitle.includes(titleCandidate)) {
score += 3000;
}
@@ -1247,16 +1246,16 @@ export const ReleaseModal = ({
const availableFormats = useMemo(() => {
const releases = releasesBySource[activeTab]?.releases || [];
const formats = new Set<string>();
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
const effectiveLower = new Set(effectiveFormats.map((f) => f.toLowerCase()));
releases.forEach((r) => {
if (r.format) {
const fmt = r.format.toLowerCase();
const releaseFormats = getReleaseFormats(r);
releaseFormats.forEach((fmt) => {
// Only include formats that are in the supported list
if (effectiveLower.includes(fmt)) {
if (effectiveLower.has(fmt)) {
formats.add(fmt);
}
}
});
});
return Array.from(formats).sort();
}, [releasesBySource, activeTab, effectiveFormats]);
@@ -1390,22 +1389,23 @@ export const ReleaseModal = ({
// Filter and sort releases based on settings and user selection
const filteredReleases = useMemo(() => {
const releases = releasesBySource[activeTab]?.releases || [];
const effectiveLower = effectiveFormats.map((f) => f.toLowerCase());
const effectiveLower = new Set(effectiveFormats.map((f) => f.toLowerCase()));
const supportsIndexerFilter = columnConfig.supported_filters?.includes('indexer');
const selectedFormat = formatFilter.toLowerCase();
// First, filter
let filtered = releases.filter((r) => {
// Format filtering
const fmt = r.format?.toLowerCase();
const releaseFormats = getReleaseFormats(r);
if (formatFilter) {
// User selected a specific format - must match exactly
if (!fmt || fmt !== formatFilter.toLowerCase()) return false;
} else if (fmt) {
// No specific filter - show only supported formats
if (!effectiveLower.includes(fmt)) return false;
// User selected a specific format - match if any format on the release matches
if (!releaseFormats.includes(selectedFormat)) return false;
} else if (releaseFormats.length > 0) {
// No specific filter - show only releases that include at least one supported format
if (!releaseFormats.some((fmt) => effectiveLower.has(fmt))) return false;
}
// Releases with no format pass through when no filter is set (show all)
// Releases with no format info pass through when no filter is set (show all)
// Language filtering - use r.language when provided by enriched indexers
// Releases with no language (null/undefined) always pass
@@ -12,6 +12,7 @@ import {
interface RequestApproveOptions {
browseOnly?: boolean;
manualApproval?: boolean;
}
type RequestApproveHandler = (
@@ -431,6 +432,7 @@ export const ActivityCard = ({
? 'Browse Releases To Retry'
: 'Browse Releases To Approve'
: 'Approve Attached File';
const canMarkAsApprovedWithoutRelease = requiresBrowseBeforeApprove && !hasAttachedRelease;
const provider = toOptionalText(bookData.provider)?.toLowerCase();
const providerId = toOptionalText(bookData.provider_id);
@@ -467,6 +469,19 @@ export const ActivityCard = ({
}
};
const handleReviewManualApproval = async () => {
if (!reviewRecord || !reviewApproveHandler || isReviewSubmitting) {
return;
}
setIsReviewSubmitting(true);
try {
await reviewApproveHandler(reviewRecord.id, reviewRecord, { manualApproval: true });
} finally {
setIsReviewSubmitting(false);
}
};
const canShowInlineReview = Boolean(isRequestDetailsOpen && reviewRecord && reviewApproveHandler);
const rejectConfirmHandler = onRequestRejectConfirm || onRequestReject;
const canShowInlineReject = Boolean(
@@ -704,6 +719,16 @@ export const ActivityCard = ({
>
{isReviewSubmitting ? 'Working...' : approveLabel}
</button>
{canMarkAsApprovedWithoutRelease && (
<button
type="button"
onClick={handleReviewManualApproval}
disabled={isReviewSubmitting}
className="px-2.5 py-1.5 rounded-md text-xs border border-[var(--border-muted)] hover:bg-[var(--hover-surface)] transition-colors disabled:opacity-50"
>
{isReviewSubmitting ? 'Working...' : 'Manually Mark as Approved'}
</button>
)}
{canBrowseAlternatives && hasAttachedRelease && (
<button
type="button"
@@ -30,6 +30,7 @@ interface ActivitySidebarProps {
record: RequestRecord,
options?: {
browseOnly?: boolean;
manualApproval?: boolean;
}
) => Promise<void> | void;
onRequestReject?: (requestId: number, adminNote?: string) => Promise<void> | void;
@@ -260,9 +260,9 @@ export const RequestPolicyGrid = ({
if (!isConfigurable) {
return (
<div key={key} className="flex items-center min-h-[36px]">
<div key={key} className="flex items-center justify-center min-h-[36px]">
{mobileLabel}
<span className="text-sm opacity-50">
<span className="text-xs opacity-40">
{REQUEST_POLICY_MODE_LABELS[effectiveMode]}
</span>
</div>
+11 -8
View File
@@ -154,21 +154,24 @@ export function useAuth(options: UseAuthOptions = {}): UseAuthReturn {
window.location.href = logout_url;
return;
}
try {
// Reload auth settings after session clear so /login has current auth-mode UI config.
applyAuthResponse(await checkAuth());
} catch (error) {
console.error('Post-logout auth refresh failed:', error);
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setDisplayName(null);
}
refreshSocketSession();
setIsAuthenticated(false);
setIsAdmin(false);
setUsername(null);
setDisplayName(null);
setOidcButtonLabel(null);
setHideLocalAuth(false);
setOidcAutoRedirect(false);
onLogoutSuccess?.();
navigate('/login', { replace: true });
} catch (error) {
console.error('Logout failed:', error);
showToast?.('Logout failed', 'error');
}
}, [navigate, onLogoutSuccess, refreshSocketSession, showToast]);
}, [navigate, onLogoutSuccess, refreshSocketSession, showToast, applyAuthResponse]);
return {
isAuthenticated,
+9 -2
View File
@@ -32,7 +32,8 @@ export interface UseRequestsReturn {
fulfilRequest: (
id: number,
releaseData?: Record<string, unknown>,
adminNote?: string
adminNote?: string,
manualApproval?: boolean
) => Promise<void>;
rejectRequest: (id: number, adminNote?: string) => Promise<void>;
}
@@ -217,7 +218,12 @@ export const useRequests = ({
}, []);
const fulfilRequest = useCallback(
async (id: number, releaseData?: Record<string, unknown>, adminNote?: string) => {
async (
id: number,
releaseData?: Record<string, unknown>,
adminNote?: string,
manualApproval?: boolean
) => {
if (!isAdmin) {
throw new Error('Admin access required');
}
@@ -232,6 +238,7 @@ export const useRequests = ({
const updated = await fulfilAdminRequest(id, {
release_data: releaseData,
admin_note: adminNote,
manual_approval: manualApproval,
});
setRequests((prev) => upsertRequestRecord(prev, updated));
setError(null);
+19 -1
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Book, AppConfig, AdvancedFilterState, ContentType } from '../types';
import { searchBooks, searchMetadata, AuthenticationError } from '../services/api';
@@ -78,6 +78,24 @@ export function useSearch(options: UseSearchOptions): UseSearchReturn {
sort: string;
fieldValues: SearchFieldValues;
} | null>(null);
const previousContentTypeRef = useRef<ContentType>(contentType);
// Switching media type starts a new search session.
useEffect(() => {
if (previousContentTypeRef.current === contentType) {
return;
}
previousContentTypeRef.current = contentType;
setBooks([]);
setLastSearchQuery('');
setSearchFieldValues({});
setHasMore(false);
setTotalFound(0);
setCurrentPage(1);
lastSearchParamsRef.current = null;
onSearchReset?.();
}, [contentType, onSearchReset]);
const updateAdvancedFilters = useCallback((updates: Partial<AdvancedFilterState>) => {
setAdvancedFilters(prev => ({ ...prev, ...updates }));
+35 -10
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useEffect, useRef } from 'react';
import { getSettings, updateSettings, executeSettingsAction } from '../services/api';
import {
SettingsTab,
@@ -12,12 +12,22 @@ import {
setThemePreference,
THEME_FIELD,
} from '../utils/themePreference';
import {
cloneSettingsValues,
mergeFetchedSettingsWithDirtyValues,
type SettingsValues,
} from '../utils/settingsValues';
type ValueBearingField = Exclude<
SettingsField,
{ type: 'ActionButton' } | { type: 'HeadingField' } | { type: 'CustomComponentField' }
>;
interface FetchSettingsOptions {
silent?: boolean;
preserveDirtyValues?: boolean;
}
// Extract value from a field based on its type
function getFieldValue(field: SettingsField): unknown {
// These field types have no value property
@@ -88,11 +98,22 @@ export function useSettings(): UseSettingsReturn {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedTab, setSelectedTab] = useState<string | null>(null);
const [values, setValues] = useState<Record<string, Record<string, unknown>>>({});
const [originalValues, setOriginalValues] = useState<Record<string, Record<string, unknown>>>({});
const [values, setValues] = useState<SettingsValues>({});
const [originalValues, setOriginalValues] = useState<SettingsValues>({});
const [isSaving, setIsSaving] = useState(false);
const valuesRef = useRef<SettingsValues>({});
const originalValuesRef = useRef<SettingsValues>({});
const fetchSettings = useCallback(async (silent = false) => {
useEffect(() => {
valuesRef.current = values;
}, [values]);
useEffect(() => {
originalValuesRef.current = originalValues;
}, [originalValues]);
const fetchSettings = useCallback(async (options: FetchSettingsOptions = {}) => {
const { silent = false, preserveDirtyValues = false } = options;
if (!silent) {
setIsLoading(true);
}
@@ -114,7 +135,7 @@ export function useSettings(): UseSettingsReturn {
setGroups(response.groups || []);
// Initialize values from fetched data
const initialValues: Record<string, Record<string, unknown>> = {};
const initialValues: SettingsValues = {};
tabsWithTheme.forEach((tab) => {
initialValues[tab.name] = {};
getValueBearingFields(tab.fields).forEach((field) => {
@@ -127,8 +148,12 @@ export function useSettings(): UseSettingsReturn {
});
});
setValues(initialValues);
setOriginalValues(JSON.parse(JSON.stringify(initialValues)));
const nextValues = preserveDirtyValues
? mergeFetchedSettingsWithDirtyValues(initialValues, valuesRef.current, originalValuesRef.current)
: initialValues;
setValues(nextValues);
setOriginalValues(cloneSettingsValues(initialValues));
// Select first tab by default if none selected
if (tabsWithTheme.length > 0) {
@@ -231,7 +256,7 @@ export function useSettings(): UseSettingsReturn {
if (result.success) {
// Refetch all settings silently to pick up any backend-triggered changes
// (e.g., enabling a metadata provider auto-updates METADATA_PROVIDER)
await fetchSettings(true);
await fetchSettings({ silent: true });
}
return result;
@@ -259,7 +284,7 @@ export function useSettings(): UseSettingsReturn {
// Re-fetch settings after successful action to pick up updated options
// (e.g., BookLore "Test Connection" refreshes library/path lists)
if (result.success) {
fetchSettings(true);
await fetchSettings({ silent: true, preserveDirtyValues: true });
}
return result;
@@ -287,6 +312,6 @@ export function useSettings(): UseSettingsReturn {
saveTab,
executeAction,
isSaving,
refetch: fetchSettings,
refetch: () => fetchSettings(),
};
}
+12 -4
View File
@@ -9,6 +9,7 @@ export interface RequestListParams {
export interface FulfilAdminRequestBody {
release_data?: Record<string, unknown>;
admin_note?: string;
manual_approval?: boolean;
}
export interface RejectAdminRequestBody {
@@ -45,10 +46,17 @@ export const buildAdminRequestActionUrl = (
export const buildFulfilAdminRequestBody = (
body: FulfilAdminRequestBody = {}
): FulfilAdminRequestBody => {
return {
release_data: body.release_data,
admin_note: body.admin_note,
};
const payload: FulfilAdminRequestBody = {};
if (body.release_data !== undefined) {
payload.release_data = body.release_data;
}
if (body.admin_note !== undefined) {
payload.admin_note = body.admin_note;
}
if (body.manual_approval !== undefined) {
payload.manual_approval = body.manual_approval;
}
return payload;
};
export const buildRejectAdminRequestBody = (
@@ -36,6 +36,18 @@ describe('admin request API client functions', () => {
});
});
it('builds manual-approval fulfil payload without release data', () => {
const body = buildFulfilAdminRequestBody({
manual_approval: true,
admin_note: 'Handled manually',
});
assert.deepEqual(body, {
manual_approval: true,
admin_note: 'Handled manually',
});
});
it('builds reject endpoint URL and payload shape', () => {
const url = buildAdminRequestActionUrl('/api/admin/requests', 51, 'reject');
const body = buildRejectAdminRequestBody({
@@ -0,0 +1,40 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { getReleaseFormats } from '../utils/releaseFormats.js';
import type { Release } from '../types';
function buildRelease(overrides: Partial<Release>): Release {
return {
source: 'prowlarr',
source_id: 'release-1',
title: 'Test Release',
...overrides,
};
}
describe('releaseFormats.getReleaseFormats', () => {
it('returns primary and extra formats in order, normalized and deduplicated', () => {
const release = buildRelease({
format: 'AZW3',
extra: { formats: ['MOBI', 'EPUB', 'azw3', ' mobi '] },
});
assert.deepEqual(getReleaseFormats(release), ['azw3', 'mobi', 'epub']);
});
it('uses extra formats when primary format is missing', () => {
const release = buildRelease({
extra: { formats: ['EPUB', 'MOBI'] },
});
assert.deepEqual(getReleaseFormats(release), ['epub', 'mobi']);
});
it('supports legacy string value in extra.formats', () => {
const release = buildRelease({
extra: { formats: 'PDF' },
});
assert.deepEqual(getReleaseFormats(release), ['pdf']);
});
});
@@ -0,0 +1,73 @@
import * as assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { mergeFetchedSettingsWithDirtyValues } from '../utils/settingsValues.js';
describe('mergeFetchedSettingsWithDirtyValues', () => {
it('preserves unsaved dirty values while applying fresh fetched values', () => {
const fetchedValues = {
general: {
apiKey: 'saved-key',
endpoint: 'https://saved.example',
},
};
const currentValues = {
general: {
apiKey: 'unsaved-key',
endpoint: 'https://saved.example',
},
};
const originalValues = {
general: {
apiKey: 'saved-key',
endpoint: 'https://saved.example',
},
};
assert.deepEqual(
mergeFetchedSettingsWithDirtyValues(fetchedValues, currentValues, originalValues),
{
general: {
apiKey: 'unsaved-key',
endpoint: 'https://saved.example',
},
}
);
});
it('does not preserve values that are not dirty and ignores keys removed by backend', () => {
const fetchedValues = {
provider: {
host: 'new-host',
enabled: true,
},
};
const currentValues = {
provider: {
host: 'old-host',
enabled: false,
removedField: 'local-only',
},
};
const originalValues = {
provider: {
host: 'old-host',
enabled: false,
removedField: 'local-only',
},
};
assert.deepEqual(
mergeFetchedSettingsWithDirtyValues(fetchedValues, currentValues, originalValues),
{
provider: {
host: 'new-host',
enabled: true,
},
}
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { Release } from '../types';
function normalizeFormatValue(value: unknown): string | null {
if (typeof value !== 'string') {
return null;
}
const normalized = value.trim().toLowerCase();
return normalized.length > 0 ? normalized : null;
}
export function getReleaseFormats(release: Release): string[] {
const formats: string[] = [];
const seen = new Set<string>();
const addFormat = (value: unknown): void => {
const normalized = normalizeFormatValue(value);
if (!normalized || seen.has(normalized)) {
return;
}
seen.add(normalized);
formats.push(normalized);
};
addFormat(release.format);
const extraFormats = (release.extra as Record<string, unknown> | undefined)?.formats;
if (Array.isArray(extraFormats)) {
extraFormats.forEach(addFormat);
} else {
addFormat(extraFormats);
}
return formats;
}
+32
View File
@@ -0,0 +1,32 @@
export type SettingsValues = Record<string, Record<string, unknown>>;
export function cloneSettingsValues(values: SettingsValues): SettingsValues {
return JSON.parse(JSON.stringify(values)) as SettingsValues;
}
export function mergeFetchedSettingsWithDirtyValues(
fetchedValues: SettingsValues,
currentValues: SettingsValues,
originalValues: SettingsValues
): SettingsValues {
const mergedValues: SettingsValues = {};
for (const [tabName, fetchedTabValues] of Object.entries(fetchedValues)) {
mergedValues[tabName] = { ...fetchedTabValues };
const currentTabValues = currentValues[tabName] ?? {};
const originalTabValues = originalValues[tabName] ?? {};
for (const [key, currentValue] of Object.entries(currentTabValues)) {
if (!(key in fetchedTabValues)) {
continue;
}
const originalValue = originalTabValues[key];
if (JSON.stringify(currentValue) !== JSON.stringify(originalValue)) {
mergedValues[tabName][key] = currentValue;
}
}
}
return mergedValues;
}
+1 -6
View File
@@ -9,10 +9,5 @@
"types": ["node"],
"allowImportingTsExtensions": false
},
"include": [
"src/tests/**/*.node.test.ts",
"src/hooks/requestPolicyCore.ts",
"src/utils/requestPolicyUi.ts",
"src/types/index.ts"
]
"include": ["src"]
}
+22
View File
@@ -202,6 +202,28 @@ class TestAtomicCopy:
# No partial file should exist
assert not dest.exists()
def test_copy_recovers_when_metadata_step_hits_enoent(self, tmp_path):
"""Treat ENOENT from copy2 metadata as recoverable if bytes already copied."""
import errno
from shelfmark.download.fs import atomic_copy as _atomic_copy
source = tmp_path / "source.txt"
source.write_text("content")
dest = tmp_path / "dest.txt"
real_copyfile = shutil.copyfile
def _copy_then_enoent(src, dst):
real_copyfile(src, dst)
raise FileNotFoundError(errno.ENOENT, "No such file or directory", src)
with patch("shutil.copy2", side_effect=_copy_then_enoent):
result = _atomic_copy(source, dest)
assert result == dest
assert result.exists()
assert result.read_text() == "content"
def test_max_attempts_exceeded(self, tmp_path):
"""Raises after max collision attempts."""
from shelfmark.download.fs import atomic_copy as _atomic_copy
+28
View File
@@ -349,6 +349,34 @@ class TestAtomicMove:
assert mock_copy.called
assert mock_fallback.called
def test_cross_filesystem_move_recovers_when_metadata_step_hits_enoent(self, tmp_path, monkeypatch):
"""Completes EXDEV fallback move when copy2 metadata fails with ENOENT."""
from shelfmark.download.fs import atomic_move as _atomic_move
import errno
source = tmp_path / "source.txt"
source.write_text("content")
dest = tmp_path / "dest.txt"
def _raise_exdev(*_args, **_kwargs):
raise OSError(errno.EXDEV, "Cross-device link")
real_copyfile = shutil.copyfile
def _copy_then_enoent(src, dst):
real_copyfile(src, dst)
raise FileNotFoundError(errno.ENOENT, "No such file or directory", src)
monkeypatch.setattr(os, "rename", _raise_exdev)
with patch("shelfmark.download.fs.shutil.copy2", side_effect=_copy_then_enoent):
result = _atomic_move(source, dest)
assert result == dest
assert not source.exists()
assert dest.exists()
assert dest.read_text() == "content"
class TestHardlinkWithLibraryMode:
"""Tests for hardlinking in library mode context."""
+75
View File
@@ -927,6 +927,48 @@ class TestRequestRoutes:
assert fulfil_resp.status_code == 400
assert "release_data is required to fulfil book-level requests" in fulfil_resp.json["error"]
def test_admin_fulfil_book_level_request_manual_approval(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
policy = _policy(default_ebook="request_book")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
create_payload = {
"book_data": {
"title": "Manual Approval Book",
"author": "Manual Admin",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-manual-approval",
},
"context": {
"source": "direct_download",
"content_type": "ebook",
"request_level": "book",
},
}
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
create_resp = client.post("/api/requests", json=create_payload)
request_id = create_resp.json["id"]
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
with patch.object(main_module.backend, "queue_release", return_value=(True, None)) as mock_queue:
fulfil_resp = client.post(
f"/api/admin/requests/{request_id}/fulfil",
json={"manual_approval": True, "admin_note": "Added manually"},
)
assert create_resp.status_code == 201
assert fulfil_resp.status_code == 200
assert fulfil_resp.json["status"] == "fulfilled"
assert fulfil_resp.json["delivery_state"] == "complete"
assert fulfil_resp.json["release_data"] is None
assert fulfil_resp.json["admin_note"] == "Added manually"
mock_queue.assert_not_called()
def test_admin_fulfil_book_level_request_with_release_data(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
@@ -1576,6 +1618,39 @@ class TestAdminFulfilEdgeCases:
assert resp.status_code == 403
assert "Admin user identity unavailable" in resp.json["error"]
def test_fulfil_with_non_boolean_manual_approval_returns_400(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
policy = _policy(default_ebook="request_release")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module, "_load_users_request_policy_settings", return_value=policy):
with patch("shelfmark.core.request_routes._load_users_request_policy_settings", return_value=policy):
create_resp = client.post("/api/requests", json={
"book_data": {
"title": "Manual Flag Validation",
"author": "QA",
"provider": "p",
"provider_id": "mf1",
"content_type": "ebook",
},
"context": {"source": "prowlarr", "content_type": "ebook", "request_level": "release"},
"release_data": {"source": "prowlarr", "source_id": "mf-r", "title": "MF.epub"},
})
request_id = create_resp.json["id"]
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
fulfil_resp = client.post(
f"/api/admin/requests/{request_id}/fulfil",
json={"manual_approval": "yes"},
)
assert create_resp.status_code == 201
assert fulfil_resp.status_code == 400
assert "manual_approval must be a boolean" in fulfil_resp.json["error"]
class TestAdminRejectEdgeCases:
"""Edge cases for POST /api/admin/requests/<id>/reject."""
+59
View File
@@ -314,6 +314,41 @@ def test_fulfil_request_requires_release_data_for_book_level(user_db):
)
def test_fulfil_request_manual_approval_allows_book_level_without_release(user_db):
alice = user_db.create_user(username="alice")
admin = user_db.create_user(username="admin", role="admin")
created = create_request(
user_db,
user_id=alice["id"],
source_hint="direct_download",
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=_book_data(),
)
called = {"queue_called": False}
def fake_queue_release(*_args, **_kwargs):
called["queue_called"] = True
return True, None
fulfilled = fulfil_request(
user_db,
request_id=created["id"],
admin_user_id=admin["id"],
queue_release=fake_queue_release,
manual_approval=True,
)
assert fulfilled["status"] == "fulfilled"
assert fulfilled["delivery_state"] == "complete"
assert fulfilled["delivery_updated_at"] is not None
assert fulfilled["release_data"] is None
assert fulfilled["reviewed_by"] == admin["id"]
assert called["queue_called"] is False
def test_fulfil_request_rejects_oversized_release_override(user_db):
alice = user_db.create_user(username="alice")
admin = user_db.create_user(username="admin", role="admin")
@@ -1242,6 +1277,30 @@ def test_fulfil_non_string_admin_note_returns_error(user_db):
)
def test_fulfil_non_boolean_manual_approval_returns_error(user_db):
alice = user_db.create_user(username="alice")
admin = user_db.create_user(username="admin", role="admin")
created = create_request(
user_db,
user_id=alice["id"],
source_hint="prowlarr",
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=_book_data(),
release_data=_release_data(),
)
with pytest.raises(RequestServiceError, match="manual_approval must be a boolean"):
fulfil_request(
user_db,
request_id=created["id"],
admin_user_id=admin["id"],
queue_release=lambda *a, **kw: (True, None),
manual_approval="yes",
)
def test_fulfil_empty_admin_note_stored_as_none(user_db):
alice = user_db.create_user(username="alice")
admin = user_db.create_user(username="admin", role="admin")
+12 -11
View File
@@ -265,7 +265,7 @@ class TestRTorrentClientAddDownload:
args = mock_rpc.load.start.call_args[0]
assert args[1] == "magnet:?xt=urn:btih:abc123def456"
assert "d.custom1.set=cwabd" in args[2]
assert "d.directory_base.set=/downloads" in args[2]
assert "d.directory.set=/downloads" in args[2]
def test_add_download_torrent_file(self, monkeypatch):
"""Test adding a torrent via raw torrent data."""
@@ -376,9 +376,9 @@ class TestRTorrentClientGetStatus:
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = [
mock_rpc.d.multicall2.return_value = [
[
"abc123def456",
"ABC123DEF456",
2,
524288000,
1048576000,
@@ -423,10 +423,11 @@ class TestRTorrentClientGetStatus:
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.side_effect = [
# First call: get_status multicall2, second call: _get_torrent_path multicall2
mock_rpc.d.multicall2.side_effect = [
[
[
"abc123def456",
"ABC123DEF456",
4,
1048576000,
1048576000,
@@ -436,7 +437,7 @@ class TestRTorrentClientGetStatus:
1,
]
],
[["/downloads/test-torrent"]],
[["ABC123DEF456", "/downloads/test-torrent"]],
]
mock_xmlrpc = create_mock_xmlrpc_module()
@@ -474,7 +475,7 @@ class TestRTorrentClientGetStatus:
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = []
mock_rpc.d.multicall2.return_value = []
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
@@ -508,9 +509,9 @@ class TestRTorrentClientGetStatus:
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = [
mock_rpc.d.multicall2.return_value = [
[
"abc123def456",
"ABC123DEF456",
2,
524288000,
1048576000,
@@ -662,7 +663,7 @@ class TestRTorrentClientGetDownloadPath:
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = [["/downloads/test-file"]]
mock_rpc.d.multicall2.return_value = [["ABC123DEF456", "/downloads/test-file"]]
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
@@ -695,7 +696,7 @@ class TestRTorrentClientGetDownloadPath:
)
mock_rpc = MagicMock()
mock_rpc.d.multicall.filtered.return_value = []
mock_rpc.d.multicall2.return_value = []
mock_xmlrpc = create_mock_xmlrpc_module()
mock_xmlrpc.ServerProxy.return_value = mock_rpc
+51
View File
@@ -0,0 +1,51 @@
"""Tests for Torznab XML parsing helpers."""
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
def test_parse_torznab_xml_parses_basic_item():
xml_text = """<?xml version="1.0"?>
<rss>
<channel>
<item>
<title>Example Release</title>
<guid>abc-123</guid>
<link>https://example.com/download</link>
<size>12345</size>
<enclosure type="application/x-bittorrent" url="https://example.com/file.torrent" />
<prowlarrindexer id="42">Test Indexer</prowlarrindexer>
<newznab:attr xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/"
name="seeders" value="10" />
<newznab:attr xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/"
name="peers" value="15" />
</item>
</channel>
</rss>
"""
results = parse_torznab_xml(xml_text)
assert len(results) == 1
result = results[0]
assert result["title"] == "Example Release"
assert result["guid"] == "abc-123"
assert result["indexerId"] == 42
assert result["indexer"] == "Test Indexer"
assert result["protocol"] == "torrent"
assert result["seeders"] == 10
assert result["leechers"] == 5
def test_parse_torznab_xml_rejects_entity_expansion_payload():
xml_text = """<?xml version="1.0"?>
<!DOCTYPE foo [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<rss>
<channel>
<item>
<title>&xxe;</title>
</item>
</channel>
</rss>
"""
assert parse_torznab_xml(xml_text) == []