mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ac8d9f681 | ||
|
|
2f9b42f428 | ||
|
|
5a53d5910d | ||
|
|
b28ad55d46 | ||
|
|
9c3af5793b | ||
|
|
d1fd93f180 | ||
|
|
b67df083e6 | ||
|
|
b038867d8d | ||
|
|
472aae608b | ||
|
|
0e120abfaf | ||
|
|
f6357ead41 | ||
|
|
d67eeace3c | ||
|
|
e615797e69 | ||
|
|
2aee1d587e | ||
|
|
d1ab58411b | ||
|
|
f4daf05d03 | ||
|
|
5f9f47cc41 | ||
|
|
81b448bc9f | ||
|
|
eee8ba0e83 | ||
|
|
cecbae738e | ||
|
|
f5fafd2265 | ||
|
|
fbbff8f715 | ||
|
|
9b8402c9a7 | ||
|
|
3305ec9e46 | ||
|
|
f03be02de0 | ||
|
|
7d589abc35 | ||
|
|
84c8142b24 | ||
|
|
9bd7eae2b5 | ||
|
|
d6590be551 | ||
|
|
4c782ca92d | ||
|
|
b10a5a35ca | ||
|
|
196578fb18 | ||
|
|
ba62771a53 | ||
|
|
7a2de1ccdd |
+17
-3
@@ -1,10 +1,17 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies
|
||||
# Dependabot supports uv version updates, but GitHub currently lists uv
|
||||
# security updates as "Not applicable"; daily checks keep uv.lock moving
|
||||
# while repo-level Dependabot alerts/security updates cover supported ecosystems.
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "daily"
|
||||
time: "05:00"
|
||||
timezone: "Europe/London"
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
python-deps:
|
||||
@@ -16,20 +23,25 @@ updates:
|
||||
directory: "/src/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 10
|
||||
groups:
|
||||
npm-deps:
|
||||
patterns: ["*"]
|
||||
update-types: ["minor", "patch"]
|
||||
|
||||
# Dockerfile base images
|
||||
# Dockerfile base image digests. When a tag stays the same, Dependabot titles
|
||||
# can only show digest prefixes, so keep the group name explicit.
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
docker-images:
|
||||
docker-base-image-digests:
|
||||
patterns: ["*"]
|
||||
ignore:
|
||||
# Node.js: block major-version bumps so dependabot never proposes
|
||||
@@ -43,6 +55,8 @@ updates:
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 3
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
gh-actions:
|
||||
|
||||
@@ -25,14 +25,14 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v3
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v3
|
||||
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v3
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
+18
-3
@@ -4,7 +4,7 @@ ARG BUILDPLATFORM
|
||||
ARG BUILDARCH
|
||||
|
||||
# Frontend build stage.
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS frontend-builder
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f AS frontend-builder
|
||||
|
||||
# Helpful debug output to see what platforms BuildKit thinks it's using
|
||||
RUN echo "BUILDPLATFORM=$BUILDPLATFORM BUILDARCH=$BUILDARCH TARGETPLATFORM=$TARGETPLATFORM TARGETARCH=$TARGETARCH"
|
||||
@@ -25,9 +25,9 @@ COPY src/frontend/ ./
|
||||
RUN npm run build
|
||||
|
||||
# Use python-slim as the base image
|
||||
FROM python:3.14-slim AS base
|
||||
FROM python:3.14-slim@sha256:1697e8e8d39bf168e177ac6b5fdab6df86d81cfc24dae17dfb96cfc3ef76b4dd AS base
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3 /uv /uvx /bin/
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.11.3@sha256:90bbb3c16635e9627f49eec6539f956d70746c409209041800a0280b93152823 /uv /uvx /bin/
|
||||
|
||||
# Add build argument for version
|
||||
ARG BUILD_VERSION
|
||||
@@ -103,6 +103,15 @@ COPY pyproject.toml uv.lock ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-default-groups
|
||||
|
||||
# Runtime dependencies are installed into /app/.venv during the build. Remove the
|
||||
# base image's system pip so stale installer CVEs do not ship in the final image.
|
||||
RUN rm -rf \
|
||||
/usr/local/bin/pip \
|
||||
/usr/local/bin/pip3 \
|
||||
/usr/local/bin/pip3.* \
|
||||
/usr/local/lib/python*/site-packages/pip \
|
||||
/usr/local/lib/python*/site-packages/pip-*.dist-info
|
||||
|
||||
# Copy application code *after* dependencies are installed
|
||||
COPY . .
|
||||
|
||||
@@ -164,6 +173,9 @@ RUN apt-get update && \
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-default-groups --extra browser
|
||||
|
||||
# uv is only needed while building the image.
|
||||
RUN rm -f /usr/bin/uv /usr/bin/uvx
|
||||
|
||||
# Keep SeleniumBase's bundled driver cache writable for the fixed non-root user.
|
||||
RUN SELENIUMBASE_DRIVERS_DIR=$(/app/.venv/bin/python -c "import pathlib, seleniumbase; print(pathlib.Path(seleniumbase.__file__).resolve().parent / 'drivers')") && \
|
||||
chown -R 1000:1000 "${SELENIUMBASE_DRIVERS_DIR}" && \
|
||||
@@ -180,4 +192,7 @@ FROM base AS shelfmark-lite
|
||||
|
||||
ENV USING_EXTERNAL_BYPASSER=true
|
||||
|
||||
# uv is only needed while building the image.
|
||||
RUN rm -f /usr/bin/uv /usr/bin/uvx
|
||||
|
||||
CMD ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -31,7 +31,7 @@ This document lists all configuration options that can be set via environment va
|
||||
|
||||
## Bootstrap Configuration
|
||||
|
||||
These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.
|
||||
These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.
|
||||
|
||||
| Variable | Description | Type | Default |
|
||||
|----------|-------------|------|---------|
|
||||
@@ -43,6 +43,9 @@ These environment variables are used at startup before the settings system loads
|
||||
| `FLASK_PORT` | Port number for the Flask web server. | number | `8084` |
|
||||
| `SESSION_COOKIE_SECURE` | Enable secure cookies (requires HTTPS). | boolean | `false` |
|
||||
| `CWA_DB_PATH` | Path to the Calibre-Web database for authentication integration. | string (path) | `/auth/app.db` |
|
||||
| `HIDE_LOCAL_AUTH` | Hide the username/password login form when OIDC is active. | boolean | `false` |
|
||||
| `DISABLE_LOCAL_AUTH` | Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed. | boolean | `false` |
|
||||
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page. | boolean | `false` |
|
||||
| `DOCKERMODE` | Indicates the application is running inside a Docker container. | boolean | `false` |
|
||||
| `ONBOARDING` | Show the onboarding wizard on first run. Set to false to skip (useful for ephemeral storage). | boolean | `true` |
|
||||
|
||||
@@ -105,6 +108,27 @@ Path to the Calibre-Web database for authentication integration.
|
||||
- **Type:** string (path)
|
||||
- **Default:** `/auth/app.db`
|
||||
|
||||
#### `HIDE_LOCAL_AUTH`
|
||||
|
||||
Hide the username/password login form when OIDC is active.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `DISABLE_LOCAL_AUTH`
|
||||
|
||||
Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `OIDC_AUTO_REDIRECT`
|
||||
|
||||
Automatically redirect to the OIDC provider instead of showing the login page.
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `DOCKERMODE`
|
||||
|
||||
Indicates the application is running inside a Docker container.
|
||||
@@ -1073,6 +1097,7 @@ How long to cache individual book details. Default: 600 (10 minutes). Max: 60480
|
||||
| `PROWLARR_API_KEY` | Found in Prowlarr: Settings > General > API Key | string (secret) | _none_ |
|
||||
| `PROWLARR_INDEXERS` | Select which indexers to search. 📚 = has book categories. Leave empty to search all. | string (comma-separated) | _empty list_ |
|
||||
| `PROWLARR_AUTO_EXPAND` | Automatically retry search without category filtering if no results are found | boolean | `false` |
|
||||
| `PROWLARR_USE_SEED_PREFERENCES` | Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client | boolean | `false` |
|
||||
|
||||
<details>
|
||||
<summary>Detailed descriptions</summary>
|
||||
@@ -1124,6 +1149,15 @@ Automatically retry search without category filtering if no results are found
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
#### `PROWLARR_USE_SEED_PREFERENCES`
|
||||
|
||||
**Use Prowlarr seed preferences**
|
||||
|
||||
Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client
|
||||
|
||||
- **Type:** boolean
|
||||
- **Default:** `false`
|
||||
|
||||
</details>
|
||||
|
||||
## Newznab
|
||||
|
||||
+2
-1
@@ -39,9 +39,10 @@ These optional environment variables control login page behavior when OIDC is en
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HIDE_LOCAL_AUTH` | Hide the username/password login option, so only the OIDC button is shown | `false` |
|
||||
| `DISABLE_LOCAL_AUTH` | Disable username/password login and remove the local-admin prerequisite for OIDC. Implies `HIDE_LOCAL_AUTH`; with `AUTH_METHOD=builtin`, everyone is locked out until auth env vars are changed. | `false` |
|
||||
| `OIDC_AUTO_REDIRECT` | Automatically redirect to the OIDC provider instead of showing the login page | `false` |
|
||||
|
||||
If both are enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
|
||||
If `DISABLE_LOCAL_AUTH` and `OIDC_AUTO_REDIRECT` are both enabled, users are redirected straight to the OIDC provider. On failure they return to the login page with an error message but no password fallback.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
+24
-25
@@ -322,6 +322,27 @@ require_writable_dir() {
|
||||
fi
|
||||
}
|
||||
|
||||
fail_unwritable_config_dir() {
|
||||
local folder="$1"
|
||||
local owner
|
||||
|
||||
owner=$(stat -c '%u:%g' "$folder" 2>/dev/null || echo "unknown")
|
||||
|
||||
echo ""
|
||||
echo "========================================================"
|
||||
echo "ERROR: Config directory is not writable!"
|
||||
echo ""
|
||||
echo "Config directory: $folder"
|
||||
echo "Current owner: $owner"
|
||||
echo "Configured runtime identity: ${RUN_UID}:${RUN_GID}"
|
||||
echo ""
|
||||
echo "To fix this permanently, run on your HOST machine:"
|
||||
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
|
||||
echo "========================================================"
|
||||
echo ""
|
||||
exit 1
|
||||
}
|
||||
|
||||
resolve_runtime_home() {
|
||||
local runtime_home
|
||||
|
||||
@@ -417,37 +438,15 @@ else
|
||||
# Config is Shelfmark-owned state, so it keeps the thorough repair path.
|
||||
make_writable "${CONFIG_DIR:-/config}" tree
|
||||
|
||||
# Fallback to root if config dir is still not writable (common on NAS/Unraid after upgrade from v0.4.0)
|
||||
# Refuse to continue if the config directory is still not writable after repair.
|
||||
CONFIG_PATH=${CONFIG_DIR:-/config}
|
||||
set +e
|
||||
test_write "$CONFIG_PATH" >/dev/null 2>&1
|
||||
config_ok=$?
|
||||
set -e
|
||||
|
||||
if [ $config_ok -ne 0 ] && [ "$RUN_UID" != "0" ]; then
|
||||
config_owner=$(stat -c '%u' "$CONFIG_PATH" 2>/dev/null || echo "unknown")
|
||||
if [ "$config_owner" = "0" ]; then
|
||||
echo ""
|
||||
echo "========================================================"
|
||||
echo "WARNING: Permission issue detected!"
|
||||
echo ""
|
||||
echo "Config directory is owned by root but PUID=$RUN_UID."
|
||||
echo "This typically happens after upgrading from v0.4.0 where"
|
||||
echo "PUID/PGID settings were not respected."
|
||||
echo ""
|
||||
echo "Falling back to running as root to prevent data loss."
|
||||
echo ""
|
||||
echo "To fix this permanently, run on your HOST machine:"
|
||||
echo " chown -R $RUN_UID:$RUN_GID /path/to/config"
|
||||
echo ""
|
||||
echo "Then restart the container."
|
||||
echo "========================================================"
|
||||
echo ""
|
||||
RUN_UID=0
|
||||
RUN_GID=0
|
||||
USERNAME=root
|
||||
TARGET_USER_SPEC="0:0"
|
||||
fi
|
||||
if [ $config_ok -ne 0 ]; then
|
||||
fail_unwritable_config_dir "$CONFIG_PATH"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
+4
-4
@@ -21,15 +21,15 @@ dependencies = [
|
||||
"rarfile",
|
||||
"qbittorrent-api",
|
||||
"transmission-rpc",
|
||||
"authlib>=1.7.0,<1.8",
|
||||
"apprise>=1.9.0",
|
||||
"authlib>=1.7.2,<1.8",
|
||||
"apprise>=1.10.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
browser = [
|
||||
"pyvirtualdisplay",
|
||||
"pyautogui",
|
||||
"seleniumbase==4.48.2",
|
||||
"seleniumbase==4.48.4",
|
||||
"python-xlib",
|
||||
]
|
||||
|
||||
@@ -40,7 +40,7 @@ dev = [
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"ruff==0.15.11",
|
||||
"ruff==0.15.12",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
|
||||
@@ -172,6 +172,24 @@ def _generate_bootstrap_env_docs() -> list[str]:
|
||||
"type": "string (path)",
|
||||
"default": "/auth/app.db",
|
||||
},
|
||||
{
|
||||
"name": "HIDE_LOCAL_AUTH",
|
||||
"description": "Hide the username/password login form when OIDC is active.",
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "DISABLE_LOCAL_AUTH",
|
||||
"description": "Disable username/password login and remove the local-admin prerequisite for OIDC. Implies HIDE_LOCAL_AUTH; with AUTH_METHOD=builtin, everyone is locked out until auth env vars are changed.",
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "OIDC_AUTO_REDIRECT",
|
||||
"description": "Automatically redirect to the OIDC provider instead of showing the login page.",
|
||||
"type": "boolean",
|
||||
"default": "false",
|
||||
},
|
||||
{
|
||||
"name": "DOCKERMODE",
|
||||
"description": "Indicates the application is running inside a Docker container.",
|
||||
@@ -189,7 +207,7 @@ def _generate_bootstrap_env_docs() -> list[str]:
|
||||
lines = [
|
||||
"## Bootstrap Configuration",
|
||||
"",
|
||||
"These environment variables are used at startup before the settings system loads. They typically configure paths and server settings.",
|
||||
"These environment variables are used at startup before the settings system loads. They typically configure paths, server settings, and authentication startup behavior.",
|
||||
"",
|
||||
"| Variable | Description | Type | Default |",
|
||||
"|----------|-------------|------|---------|",
|
||||
|
||||
@@ -121,6 +121,7 @@ SESSION_COOKIE_SECURE_ENV = os.getenv("SESSION_COOKIE_SECURE", "false")
|
||||
SESSION_COOKIE_NAME = "shelfmark_session"
|
||||
CWA_DB_PATH = _resolve_cwa_db_path()
|
||||
HIDE_LOCAL_AUTH = string_to_bool(os.getenv("HIDE_LOCAL_AUTH", "false"))
|
||||
DISABLE_LOCAL_AUTH = string_to_bool(os.getenv("DISABLE_LOCAL_AUTH", "false"))
|
||||
OIDC_AUTO_REDIRECT = string_to_bool(os.getenv("OIDC_AUTO_REDIRECT", "false"))
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,7 @@ def _test_oidc_connection(current_values: dict[str, Any] | None = None) -> dict[
|
||||
@register_settings("security", "Security", icon="shield", order=5)
|
||||
def security_settings() -> list[SettingsField]:
|
||||
"""Security and authentication settings."""
|
||||
from shelfmark.config.env import CWA_DB_PATH
|
||||
from shelfmark.config.env import CWA_DB_PATH, DISABLE_LOCAL_AUTH
|
||||
|
||||
cwa_db_available = CWA_DB_PATH is not None and CWA_DB_PATH.exists()
|
||||
|
||||
@@ -108,11 +108,17 @@ def security_settings() -> list[SettingsField]:
|
||||
),
|
||||
show_when=_auth_condition("builtin"),
|
||||
),
|
||||
CustomComponentField(
|
||||
key="oidc_admin_requirement",
|
||||
component="oidc_admin_hint",
|
||||
label="A local admin account is required before OIDC can be enabled.",
|
||||
show_when=_auth_condition("oidc"),
|
||||
*(
|
||||
[]
|
||||
if DISABLE_LOCAL_AUTH
|
||||
else [
|
||||
CustomComponentField(
|
||||
key="oidc_admin_requirement",
|
||||
component="oidc_admin_hint",
|
||||
label="A local admin account is required before OIDC can be enabled.",
|
||||
show_when=_auth_condition("oidc"),
|
||||
),
|
||||
]
|
||||
),
|
||||
*(
|
||||
[]
|
||||
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.config.env import DISABLE_LOCAL_AUTH
|
||||
from shelfmark.core.user_db import UserDB
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
@@ -78,7 +79,7 @@ def on_save_security(
|
||||
auth_method = str(effective_values.get("AUTH_METHOD", "") or "").strip().lower()
|
||||
|
||||
if auth_method == "oidc":
|
||||
if not _has_local_password_admin():
|
||||
if not DISABLE_LOCAL_AUTH and not _has_local_password_admin():
|
||||
return {"error": True, "message": _OIDC_LOCKOUT_MESSAGE, "values": normalized_values}
|
||||
|
||||
missing_fields = _get_missing_oidc_required_fields(effective_values)
|
||||
|
||||
@@ -71,14 +71,16 @@ def determine_auth_mode(
|
||||
cwa_db_path: object | None,
|
||||
*,
|
||||
has_local_admin: bool = True,
|
||||
disable_local_auth: bool = False,
|
||||
) -> str:
|
||||
"""Determine active auth mode from security config and runtime prerequisites."""
|
||||
auth_mode = security_config.get("AUTH_METHOD", "none")
|
||||
local_admin_available = has_local_admin or disable_local_auth
|
||||
|
||||
if auth_mode == AUTH_SOURCE_CWA and cwa_db_path:
|
||||
return AUTH_SOURCE_CWA
|
||||
|
||||
if auth_mode == AUTH_SOURCE_BUILTIN and has_local_admin:
|
||||
if auth_mode == AUTH_SOURCE_BUILTIN and local_admin_available:
|
||||
return AUTH_SOURCE_BUILTIN
|
||||
|
||||
if auth_mode == AUTH_SOURCE_PROXY and security_config.get("PROXY_AUTH_USER_HEADER"):
|
||||
@@ -86,7 +88,7 @@ def determine_auth_mode(
|
||||
|
||||
if (
|
||||
auth_mode == AUTH_SOURCE_OIDC
|
||||
and has_local_admin
|
||||
and local_admin_available
|
||||
and security_config.get("OIDC_DISCOVERY_URL")
|
||||
and security_config.get("OIDC_CLIENT_ID")
|
||||
):
|
||||
@@ -102,6 +104,7 @@ def load_active_auth_mode(
|
||||
) -> str:
|
||||
"""Resolve active auth mode using current security config and runtime prerequisites."""
|
||||
try:
|
||||
from shelfmark.config.env import DISABLE_LOCAL_AUTH
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
security_config = {
|
||||
@@ -114,6 +117,7 @@ def load_active_auth_mode(
|
||||
security_config,
|
||||
cwa_db_path,
|
||||
has_local_admin=has_local_password_admin(user_db),
|
||||
disable_local_auth=DISABLE_LOCAL_AUTH,
|
||||
)
|
||||
except ImportError, OSError, RuntimeError, TypeError, ValueError, sqlite3.Error:
|
||||
return "none"
|
||||
|
||||
@@ -8,7 +8,7 @@ import time
|
||||
from http import HTTPStatus
|
||||
from io import BytesIO
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
@@ -39,6 +39,7 @@ FETCH_HEADERS = {
|
||||
|
||||
# Maximum image size to fetch (5 MB)
|
||||
MAX_IMAGE_SIZE = 5 * 1024 * 1024
|
||||
MAX_REDIRECTS = 5
|
||||
|
||||
# Negative cache TTL (for failed fetches) - 1 hour
|
||||
NEGATIVE_CACHE_TTL = 3600
|
||||
@@ -482,29 +483,85 @@ class ImageCacheService:
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_safe_url(url: str) -> bool:
|
||||
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
|
||||
def _prepare_safe_url(url: str) -> str | None:
|
||||
"""Prepare and validate a URL before fetching it."""
|
||||
if "\\" in url or any(ord(char) < 32 for char in url):
|
||||
return None
|
||||
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
prepared = requests.Request("GET", url).prepare()
|
||||
prepared_url = prepared.url
|
||||
if not isinstance(prepared_url, str):
|
||||
return None
|
||||
parsed = urlparse(prepared_url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
return False
|
||||
except requests.exceptions.RequestException, ValueError:
|
||||
return None
|
||||
|
||||
if not prepared_url:
|
||||
return None
|
||||
|
||||
if "\\" in prepared_url or any(ord(char) < 32 for char in prepared_url):
|
||||
return None
|
||||
|
||||
netloc_lower = parsed.netloc.lower()
|
||||
if "%2f" in netloc_lower or "%5c" in netloc_lower:
|
||||
return None
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False
|
||||
return None
|
||||
if not hostname:
|
||||
return False
|
||||
return None
|
||||
|
||||
try:
|
||||
resolved = socket.getaddrinfo(hostname, None)
|
||||
for _, _, _, _, sockaddr in resolved:
|
||||
ip = ipaddress.ip_address(sockaddr[0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
|
||||
return False
|
||||
return None
|
||||
except socket.gaierror, ValueError:
|
||||
return False
|
||||
return None
|
||||
|
||||
return True
|
||||
return prepared_url
|
||||
|
||||
@staticmethod
|
||||
def _is_safe_url(url: str) -> bool:
|
||||
"""Check that a URL is safe to fetch (no SSRF to internal resources)."""
|
||||
return ImageCacheService._prepare_safe_url(url) is not None
|
||||
|
||||
def _fetch_safe_response(self, url: str) -> requests.Response | None:
|
||||
"""Fetch a URL after validating the initial URL and each redirect."""
|
||||
current_url = self._prepare_safe_url(url)
|
||||
if not current_url:
|
||||
logger.warning("Blocked request to disallowed URL: %s", url)
|
||||
return None
|
||||
|
||||
for _ in range(MAX_REDIRECTS + 1):
|
||||
response = requests.get(
|
||||
current_url,
|
||||
timeout=(5, 10),
|
||||
headers=FETCH_HEADERS,
|
||||
stream=True,
|
||||
verify=get_ssl_verify(current_url),
|
||||
allow_redirects=False,
|
||||
)
|
||||
|
||||
if not response.is_redirect:
|
||||
return response
|
||||
|
||||
location = response.headers.get("location")
|
||||
response.close()
|
||||
if not location:
|
||||
return None
|
||||
|
||||
redirect_url = urljoin(current_url, location)
|
||||
next_url = self._prepare_safe_url(redirect_url)
|
||||
if not next_url:
|
||||
logger.warning("Blocked redirect to disallowed URL: %s", redirect_url)
|
||||
return None
|
||||
current_url = next_url
|
||||
|
||||
return None
|
||||
|
||||
def fetch_and_cache(self, cache_id: str, url: str) -> tuple[bytes, str] | None:
|
||||
"""Fetch an image from URL and cache it.
|
||||
@@ -519,17 +576,9 @@ class ImageCacheService:
|
||||
"""
|
||||
cached_data: tuple[bytes, str] | None = None
|
||||
try:
|
||||
if not self._is_safe_url(url):
|
||||
logger.warning("Blocked request to disallowed URL: %s", url)
|
||||
response = self._fetch_safe_response(url)
|
||||
if response is None:
|
||||
return None
|
||||
|
||||
response = requests.get(
|
||||
url,
|
||||
timeout=(5, 10),
|
||||
headers=FETCH_HEADERS,
|
||||
stream=True,
|
||||
verify=get_ssl_verify(url),
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Validate content type
|
||||
|
||||
@@ -73,6 +73,16 @@ def _has_username_or_email(claims: dict[str, Any]) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _is_email_verified(claims: dict[str, Any]) -> bool:
|
||||
"""Return True when claims explicitly mark the email address as verified."""
|
||||
email_verified = claims.get("email_verified")
|
||||
if isinstance(email_verified, bool):
|
||||
return email_verified
|
||||
if isinstance(email_verified, str):
|
||||
return email_verified.strip().lower() == "true"
|
||||
return False
|
||||
|
||||
|
||||
def _login_error_url(message: str) -> str:
|
||||
"""Build a login URL (with script_root) that includes an OIDC error message."""
|
||||
script_root = request.script_root.rstrip("/")
|
||||
@@ -295,7 +305,7 @@ def register_oidc_routes(app: Flask, user_db: UserDB) -> None:
|
||||
if admin_group and use_admin_group:
|
||||
is_admin = admin_group in groups
|
||||
|
||||
allow_email_link = bool(user_info.get("email"))
|
||||
allow_email_link = bool(user_info.get("email")) and _is_email_verified(claims)
|
||||
user = provision_oidc_user(
|
||||
user_db,
|
||||
user_info,
|
||||
|
||||
@@ -10,7 +10,7 @@ A mapping rewrites a remote path prefix into a local path prefix.
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PureWindowsPath
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -50,6 +50,42 @@ def _normalize_host(host: str) -> str:
|
||||
return str(host or "").strip().lower()
|
||||
|
||||
|
||||
def _is_relative_to(path: Path, prefix: Path) -> bool:
|
||||
try:
|
||||
path.relative_to(prefix)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _join_contained_path(local_prefix: str, remainder: str) -> Path | None:
|
||||
local_path = Path(local_prefix)
|
||||
|
||||
if remainder:
|
||||
remainder_path = Path(remainder)
|
||||
windows_remainder_path = PureWindowsPath(remainder)
|
||||
|
||||
if (
|
||||
remainder_path.is_absolute()
|
||||
or windows_remainder_path.is_absolute()
|
||||
or ".." in remainder_path.parts
|
||||
or ".." in windows_remainder_path.parts
|
||||
):
|
||||
return None
|
||||
|
||||
remapped = local_path / remainder_path
|
||||
else:
|
||||
remapped = local_path
|
||||
|
||||
resolved_local_path = local_path.resolve(strict=False)
|
||||
resolved_remapped = remapped.resolve(strict=False)
|
||||
if not _is_relative_to(resolved_remapped, resolved_local_path):
|
||||
return None
|
||||
|
||||
return remapped
|
||||
|
||||
|
||||
def parse_remote_path_mappings(value: object) -> list[RemotePathMapping]:
|
||||
"""Parse configured remote-path mapping rows into normalized mappings."""
|
||||
if not value or not isinstance(value, list):
|
||||
@@ -81,8 +117,12 @@ def remap_remote_to_local_with_match(
|
||||
mappings: Iterable[RemotePathMapping],
|
||||
host: str,
|
||||
remote_path: str | Path,
|
||||
) -> tuple[Path, bool]:
|
||||
"""Remap a remote path and report whether a configured mapping matched."""
|
||||
) -> tuple[Path | None, bool]:
|
||||
"""Remap a remote path and report whether a configured mapping matched.
|
||||
|
||||
Returns ``(None, True)`` when a mapping prefix matched but the remainder was
|
||||
unsafe to join under the local prefix.
|
||||
"""
|
||||
host_normalized = _normalize_host(host)
|
||||
remote_normalized = _normalize_prefix(str(remote_path))
|
||||
|
||||
@@ -119,7 +159,10 @@ def remap_remote_to_local_with_match(
|
||||
|
||||
remainder = remainder.removeprefix("/")
|
||||
|
||||
remapped = Path(local_prefix) / remainder if remainder else Path(local_prefix)
|
||||
remapped = _join_contained_path(local_prefix, remainder)
|
||||
if remapped is None:
|
||||
return None, True
|
||||
|
||||
return remapped, True
|
||||
|
||||
return Path(remote_normalized), False
|
||||
@@ -134,6 +177,8 @@ def remap_remote_to_local(
|
||||
host=host,
|
||||
remote_path=remote_path,
|
||||
)
|
||||
if remapped is None:
|
||||
return Path(str(remote_path))
|
||||
return remapped
|
||||
|
||||
|
||||
|
||||
@@ -220,6 +220,26 @@ def _normalize_release_result_request_payload(
|
||||
return "release", normalized_release_data
|
||||
|
||||
|
||||
def _validate_release_source_matches_policy_context(
|
||||
*,
|
||||
source: str,
|
||||
release_data: object,
|
||||
) -> None:
|
||||
if not isinstance(release_data, dict):
|
||||
return
|
||||
|
||||
release_source = normalize_source(release_data.get("source"))
|
||||
if release_source in {"", "*"} or release_source == source:
|
||||
return
|
||||
|
||||
msg = "Policy context source must match release_data.source"
|
||||
raise RequestServiceError(
|
||||
msg,
|
||||
status_code=400,
|
||||
code="policy_source_mismatch",
|
||||
)
|
||||
|
||||
|
||||
def _resolve_request_title(request_row: dict[str, Any]) -> str:
|
||||
return _resolve_title_from_book_data(request_row.get("book_data"))
|
||||
|
||||
@@ -317,6 +337,10 @@ def _prepare_request_create_arguments(
|
||||
content_type = normalize_content_type(
|
||||
context.get("content_type") or data.get("content_type") or book_data.get("content_type")
|
||||
)
|
||||
_validate_release_source_matches_policy_context(
|
||||
source=source,
|
||||
release_data=release_data,
|
||||
)
|
||||
request_level, release_data = _normalize_release_result_request_payload(
|
||||
source=source,
|
||||
request_level=request_level,
|
||||
@@ -324,6 +348,10 @@ def _prepare_request_create_arguments(
|
||||
release_data=release_data,
|
||||
content_type=content_type,
|
||||
)
|
||||
_validate_release_source_matches_policy_context(
|
||||
source=source,
|
||||
release_data=release_data,
|
||||
)
|
||||
|
||||
global_settings, user_settings, effective, requests_enabled = _resolve_effective_policy(
|
||||
user_db,
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
"""Archive extraction utilities for downloaded book archives."""
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
from shelfmark.download.fs import atomic_write
|
||||
from shelfmark.download.fs import atomic_move
|
||||
from shelfmark.download.postprocess.policy import (
|
||||
get_supported_audiobook_formats,
|
||||
get_supported_formats,
|
||||
)
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
_ARCHIVE_COPY_CHUNK_SIZE = 1024 * 1024
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import rarfile
|
||||
@@ -208,9 +211,25 @@ def _extract_files_from_archive(archive: ArchiveType, output_dir: Path) -> list[
|
||||
logger.warning("Path traversal attempt blocked: %r", info.filename)
|
||||
continue
|
||||
|
||||
with archive.open(info) as src:
|
||||
data = src.read()
|
||||
final_path = atomic_write(target_path, data)
|
||||
temp_path: Path | None = None
|
||||
try:
|
||||
with (
|
||||
archive.open(info) as src,
|
||||
tempfile.NamedTemporaryFile(
|
||||
dir=output_dir,
|
||||
prefix=".shelfmark-extract-",
|
||||
suffix=".tmp",
|
||||
delete=False,
|
||||
) as temp_file,
|
||||
):
|
||||
temp_path = Path(temp_file.name)
|
||||
shutil.copyfileobj(src, temp_file, length=_ARCHIVE_COPY_CHUNK_SIZE)
|
||||
|
||||
final_path = atomic_move(cast("Path", temp_path), target_path)
|
||||
except Exception:
|
||||
if temp_path is not None:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
extracted_files.append(final_path)
|
||||
logger.debug("Extracted: %s", filename)
|
||||
|
||||
|
||||
@@ -276,7 +276,18 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
remote_path=source_path_obj,
|
||||
)
|
||||
|
||||
delete_path = remapped if matched_mapping else source_path_obj
|
||||
if matched_mapping:
|
||||
if remapped is None:
|
||||
logger.warning(
|
||||
"Refusing to delete download data for %s %s because remote path mapping rejected unsafe path: %s",
|
||||
client.name,
|
||||
download_id,
|
||||
source_path_obj,
|
||||
)
|
||||
return
|
||||
delete_path = remapped
|
||||
else:
|
||||
delete_path = source_path_obj
|
||||
|
||||
if str(delete_path) in ("", "/"):
|
||||
logger.warning(
|
||||
@@ -435,6 +446,19 @@ class ExternalClientHandler(DownloadHandler, ABC):
|
||||
)
|
||||
|
||||
if matched_mapping:
|
||||
if remapped is None:
|
||||
message = (
|
||||
f"Remote path mapping rejected unsafe path '{source_path_obj}'. "
|
||||
f"Check Settings > Advanced > Remote Path Mappings."
|
||||
)
|
||||
failure_log = "Remote path mapping rejected unsafe path for %s (%s): %s"
|
||||
failure_args = (client.name, download_id, source_path_obj)
|
||||
if log_details:
|
||||
logger.error(failure_log, *failure_args)
|
||||
else:
|
||||
logger.debug(failure_log, *failure_args)
|
||||
return None, message
|
||||
|
||||
remapped_exists, remapped_error = _probe_completed_path(remapped)
|
||||
|
||||
if log_details:
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from http import HTTPStatus
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from types import SimpleNamespace
|
||||
from typing import NoReturn, TypedDict
|
||||
|
||||
@@ -46,6 +47,13 @@ _HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
_ONE_WEEK_IN_SECONDS = 604800
|
||||
|
||||
|
||||
class _UnsafeQBittorrentPath:
|
||||
pass
|
||||
|
||||
|
||||
_UNSAFE_QBITTORRENT_PATH = _UnsafeQBittorrentPath()
|
||||
|
||||
|
||||
class _QBittorrentAddKwargs(TypedDict, total=False):
|
||||
rename: str
|
||||
category: str
|
||||
@@ -136,6 +144,24 @@ def _is_explicit_add_failure(raw_result: object) -> bool:
|
||||
return normalized in {"fail", "fails", "error", "errors"}
|
||||
|
||||
|
||||
def _build_qbittorrent_child_path(base_path: object, child_path: object) -> str | None:
|
||||
"""Build a qBittorrent-reported child path without allowing escape from base."""
|
||||
if not isinstance(base_path, str) or not base_path:
|
||||
return None
|
||||
if not isinstance(child_path, str) or not child_path:
|
||||
return None
|
||||
|
||||
child = child_path.replace("\\", "/")
|
||||
posix_child = PurePosixPath(child)
|
||||
windows_child = PureWindowsPath(child_path)
|
||||
if posix_child.is_absolute() or windows_child.is_absolute() or windows_child.drive:
|
||||
return None
|
||||
if any(part == ".." for part in posix_child.parts):
|
||||
return None
|
||||
|
||||
return os.path.normpath(str(Path(base_path) / child))
|
||||
|
||||
|
||||
@register_client("torrent")
|
||||
class QBittorrentClient(DownloadClient):
|
||||
"""qBittorrent download client."""
|
||||
@@ -629,16 +655,18 @@ class QBittorrentClient(DownloadClient):
|
||||
download_id = getattr(torrent, "hash", "")
|
||||
if isinstance(download_id, str) and download_id:
|
||||
derived = self._derive_download_path_from_files(download_id)
|
||||
if derived:
|
||||
if derived and not isinstance(derived, _UnsafeQBittorrentPath):
|
||||
return derived
|
||||
|
||||
# Legacy fallback: save_path + name (for older clients/emulators)
|
||||
return self._build_path(
|
||||
return _build_qbittorrent_child_path(
|
||||
getattr(torrent, "save_path", ""),
|
||||
getattr(torrent, "name", ""),
|
||||
)
|
||||
|
||||
def _derive_download_path_from_files(self, download_id: str) -> str | None:
|
||||
def _derive_download_path_from_files(
|
||||
self, download_id: str
|
||||
) -> str | _UnsafeQBittorrentPath | None:
|
||||
"""Derive completed download path using `/torrents/properties` + `/torrents/files`.
|
||||
|
||||
This mirrors how common automation apps derive the path when
|
||||
@@ -685,9 +713,12 @@ class QBittorrentClient(DownloadClient):
|
||||
first_name_norm = first_name.replace("\\", "/")
|
||||
top_level = first_name_norm.split("/", 1)[0]
|
||||
if not top_level:
|
||||
return None
|
||||
return _UNSAFE_QBITTORRENT_PATH
|
||||
|
||||
return os.path.normpath(str(Path(save_path) / top_level))
|
||||
derived = _build_qbittorrent_child_path(save_path, top_level)
|
||||
if derived is None:
|
||||
return _UNSAFE_QBITTORRENT_PATH
|
||||
return os.path.normpath(derived)
|
||||
except _QBITTORRENT_CLIENT_ERRORS as e:
|
||||
logger.debug(
|
||||
"qBittorrent could not derive path from files: %s: %s",
|
||||
|
||||
@@ -33,6 +33,24 @@ _SABNZBD_CLIENT_ERRORS = (
|
||||
_SabnzbdRequestParam = str | int | float | bool
|
||||
|
||||
|
||||
def _url_origin(value: str) -> tuple[str, str, int] | None:
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
scheme = parsed.scheme.lower()
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
if scheme not in {"http", "https"} or not hostname:
|
||||
return None
|
||||
|
||||
if port is None:
|
||||
port = 443 if scheme == "https" else 80
|
||||
|
||||
return scheme, hostname, port
|
||||
|
||||
|
||||
def _parse_eta(eta_str: str) -> int | None:
|
||||
"""Parse SABnzbd ETA string (format: 'H:MM:SS') to seconds."""
|
||||
if not eta_str or eta_str == "0:00:00":
|
||||
@@ -220,6 +238,18 @@ class SABnzbdClient(DownloadClient):
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
def _can_prefetch_nzb_url(self, url: str) -> bool:
|
||||
target_origin = _url_origin(url)
|
||||
if target_origin is None:
|
||||
return False
|
||||
|
||||
for key in ("PROWLARR_URL", "NEWZNAB_URL"):
|
||||
trusted_url = normalize_http_config_url(config.get(key, ""))
|
||||
if trusted_url and _url_origin(trusted_url) == target_origin:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _get_prowlarr_headers(self, url: str) -> dict:
|
||||
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
|
||||
api_key = str(config.get("PROWLARR_API_KEY", "") or "").strip()
|
||||
@@ -326,15 +356,20 @@ class SABnzbdClient(DownloadClient):
|
||||
|
||||
try:
|
||||
logger.debug("Adding NZB to SABnzbd: %s", name)
|
||||
nzb_filename = self._build_nzb_filename(name, url)
|
||||
nzb_content = self._fetch_nzb_content(url)
|
||||
result = self._api_post_file(nzb_content, nzb_filename, name, resolved_category)
|
||||
nzo_id = self._extract_nzo_id(result)
|
||||
logger.info("Added NZB to SABnzbd: %s", nzo_id)
|
||||
if self._can_prefetch_nzb_url(url):
|
||||
nzb_filename = self._build_nzb_filename(name, url)
|
||||
nzb_content = self._fetch_nzb_content(url)
|
||||
result = self._api_post_file(nzb_content, nzb_filename, name, resolved_category)
|
||||
nzo_id = self._extract_nzo_id(result)
|
||||
logger.info("Added NZB to SABnzbd: %s", nzo_id)
|
||||
else:
|
||||
logger.info("Skipping SABnzbd addfile prefetch for untrusted NZB URL")
|
||||
nzo_id = ""
|
||||
except _SABNZBD_CLIENT_ERRORS as e:
|
||||
logger.warning("SABnzbd addfile failed, falling back to addurl: %s", e)
|
||||
else:
|
||||
return nzo_id
|
||||
if nzo_id:
|
||||
return nzo_id
|
||||
|
||||
try:
|
||||
result = self._api_call(
|
||||
|
||||
@@ -7,12 +7,13 @@ import hashlib
|
||||
import re
|
||||
from binascii import Error as BinasciiError
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import parse_qs, urljoin, urlparse
|
||||
from urllib.parse import ParseResult, parse_qs, urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -32,6 +33,7 @@ _TORRENT_FETCH_ERRORS = (
|
||||
ValueError,
|
||||
)
|
||||
_TORRENT_PARSE_ERRORS = (IndexError, KeyError, TypeError, ValueError)
|
||||
_TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS = ("PROWLARR_URL", "NEWZNAB_URL")
|
||||
|
||||
type BencodeValue = dict[str | bytes, BencodeValue] | list[BencodeValue] | int | bytes | str
|
||||
|
||||
@@ -93,6 +95,9 @@ def extract_torrent_info(
|
||||
# Not a magnet - try to fetch and parse the .torrent file
|
||||
if not fetch_torrent:
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
if not _is_trusted_torrent_fetch_url(url):
|
||||
logger.debug("Skipping torrent prefetch for untrusted URL: %s...", url[:80])
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
|
||||
headers: dict[str, str] = {"Accept": "application/x-bittorrent"}
|
||||
# TODO(shelfmark): Move this source-specific Prowlarr auth handling into a source hook.
|
||||
@@ -133,6 +138,12 @@ def extract_torrent_info(
|
||||
is_magnet=True,
|
||||
magnet_url=redirect_url,
|
||||
)
|
||||
if not _is_trusted_torrent_fetch_url(redirect_url):
|
||||
logger.debug(
|
||||
"Skipping torrent prefetch redirect to untrusted URL: %s...",
|
||||
redirect_url[:80],
|
||||
)
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
# Not a magnet redirect, follow it manually
|
||||
logger.debug("Following redirect to: %s...", redirect_url[:80])
|
||||
resp = requests.get(
|
||||
@@ -172,6 +183,36 @@ def extract_torrent_info(
|
||||
return TorrentInfo(info_hash=expected_hash, torrent_data=None, is_magnet=False)
|
||||
|
||||
|
||||
def _is_trusted_torrent_fetch_url(url: str) -> bool:
|
||||
parsed = urlparse(url)
|
||||
origin = _url_origin(parsed)
|
||||
if origin is None:
|
||||
return False
|
||||
|
||||
for key in _TRUSTED_TORRENT_FETCH_URL_CONFIG_KEYS:
|
||||
configured_url = str(config.get(key, "") or "").strip()
|
||||
if not configured_url:
|
||||
continue
|
||||
configured_origin = _url_origin(urlparse(normalize_http_url(configured_url)))
|
||||
if configured_origin == origin:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _url_origin(parsed_url: ParseResult) -> tuple[str, str, int] | None:
|
||||
scheme = parsed_url.scheme.lower()
|
||||
if scheme not in {"http", "https"}:
|
||||
return None
|
||||
|
||||
hostname = parsed_url.hostname
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
default_port = 443 if scheme == "https" else 80
|
||||
return (scheme, hostname.lower(), parsed_url.port or default_port)
|
||||
|
||||
|
||||
def parse_transmission_url(url: str) -> tuple[str, str, int, str]:
|
||||
"""Parse Transmission URL into (protocol, host, port, path)."""
|
||||
parsed = urlparse(url)
|
||||
|
||||
+16
-12
@@ -229,6 +229,10 @@ def _is_permission_error(e: Exception) -> bool:
|
||||
return isinstance(e, PermissionError) or (isinstance(e, OSError) and e.errno == errno.EPERM)
|
||||
|
||||
|
||||
def _should_fallback_to_content_copy(error: Exception) -> bool:
|
||||
return _is_permission_error(error) or (isinstance(error, OSError) and error.errno == errno.EIO)
|
||||
|
||||
|
||||
def _system_op(op: str, source: Path, dest: Path) -> None:
|
||||
"""Execute system command (mv or cp) as final fallback."""
|
||||
logger.warning("Attempting system %s as final fallback: %s -> %s", op, source, dest)
|
||||
@@ -463,9 +467,9 @@ def atomic_move(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
try:
|
||||
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
|
||||
except (PermissionError, OSError) as copy_error:
|
||||
if _is_permission_error(copy_error):
|
||||
if _should_fallback_to_content_copy(copy_error):
|
||||
logger.debug(
|
||||
"Permission error during move-copy, falling back to copyfile (%s -> %s): %s",
|
||||
"copy2 failed during move-copy, falling back to copyfile (%s -> %s): %s",
|
||||
source_path,
|
||||
temp_path,
|
||||
copy_error,
|
||||
@@ -583,7 +587,7 @@ def atomic_hardlink(source_path: Path, dest_path: Path, max_attempts: int = 100)
|
||||
error=e,
|
||||
)
|
||||
if permission_error or _hardlink_not_supported(e):
|
||||
logger.debug(
|
||||
logger.warning(
|
||||
"Hardlink failed (%s), falling back to copy: %s -> %s",
|
||||
e,
|
||||
source_path,
|
||||
@@ -631,16 +635,16 @@ def atomic_copy(source_path: Path, dest_path: Path, max_attempts: int = 100) ->
|
||||
try:
|
||||
run_blocking_io(shutil.copy2, str(source_path), str(temp_path))
|
||||
except (PermissionError, OSError) as e:
|
||||
# Handle NFS permission errors immediately here
|
||||
if _is_permission_error(e):
|
||||
log_transfer_permission_context(
|
||||
"atomic_copy",
|
||||
source=source_path,
|
||||
dest=temp_path,
|
||||
error=e,
|
||||
)
|
||||
if _should_fallback_to_content_copy(e):
|
||||
if _is_permission_error(e):
|
||||
log_transfer_permission_context(
|
||||
"atomic_copy",
|
||||
source=source_path,
|
||||
dest=temp_path,
|
||||
error=e,
|
||||
)
|
||||
logger.debug(
|
||||
"Permission error during copy, falling back to copyfile (%s -> %s): %s",
|
||||
"copy2 failed during copy, falling back to copyfile (%s -> %s): %s",
|
||||
source_path,
|
||||
temp_path,
|
||||
e,
|
||||
|
||||
@@ -29,6 +29,7 @@ from shelfmark.download.postprocess.pipeline import is_torrent_source, safe_clea
|
||||
from shelfmark.download.postprocess.router import post_process_download
|
||||
from shelfmark.release_sources import (
|
||||
get_handler,
|
||||
get_source,
|
||||
get_source_display_name,
|
||||
)
|
||||
|
||||
@@ -108,6 +109,13 @@ def _parse_release_search_mode(value: object) -> SearchMode:
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def _source_unavailable_message(source_name: str) -> str | None:
|
||||
source = get_source(source_name)
|
||||
if source.is_available():
|
||||
return None
|
||||
return f"{source.display_name} is unavailable. Enable and configure the source in Settings."
|
||||
|
||||
|
||||
def _optional_number(value: object) -> float | None:
|
||||
if isinstance(value, bool):
|
||||
return float(value)
|
||||
@@ -138,13 +146,6 @@ def _optional_positive_int(value: object) -> int | None:
|
||||
return parsed if parsed > 0 else None
|
||||
|
||||
|
||||
def _seed_time_seconds_to_minutes(value: object) -> int | None:
|
||||
seed_time_seconds = _optional_positive_int(value)
|
||||
if seed_time_seconds is None:
|
||||
return None
|
||||
return (seed_time_seconds + 59) // 60
|
||||
|
||||
|
||||
def _config_float(value: object, default: float) -> float:
|
||||
if isinstance(value, bool) or value is None:
|
||||
return default
|
||||
@@ -166,19 +167,34 @@ def _build_retry_resolution_fields(
|
||||
if not isinstance(extra, dict):
|
||||
extra = {}
|
||||
|
||||
retry_download_url = normalize_optional_text(release_data.get("download_url"))
|
||||
protocol = normalize_optional_text(release_data.get("protocol"))
|
||||
source = normalize_optional_text(release_data.get("source"))
|
||||
if source is not None:
|
||||
handler = get_handler(source)
|
||||
source_retry_fields = handler.build_retry_resolution_fields(release_data)
|
||||
retry_download_url = (
|
||||
normalize_optional_text(source_retry_fields.get("retry_download_url"))
|
||||
or retry_download_url
|
||||
)
|
||||
protocol = (
|
||||
normalize_optional_text(source_retry_fields.get("retry_download_protocol")) or protocol
|
||||
)
|
||||
|
||||
ratio_limit = _optional_number(release_data.get("ratio_limit"))
|
||||
if ratio_limit is None:
|
||||
ratio_limit = _optional_number(extra.get("minimum_ratio"))
|
||||
if ratio_limit is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
|
||||
ratio_limit = _optional_number(extra.get("configured_ratio_limit"))
|
||||
|
||||
seeding_time_limit_minutes = _optional_positive_int(
|
||||
release_data.get("seeding_time_limit_minutes")
|
||||
)
|
||||
if seeding_time_limit_minutes is None:
|
||||
seeding_time_limit_minutes = _seed_time_seconds_to_minutes(extra.get("minimum_seed_time"))
|
||||
if seeding_time_limit_minutes is None and config.get("PROWLARR_USE_SEED_PREFERENCES", False):
|
||||
seeding_time_limit_minutes = _optional_positive_int(
|
||||
extra.get("configured_seed_time_minutes")
|
||||
)
|
||||
|
||||
return {
|
||||
"retry_download_url": normalize_optional_text(release_data.get("download_url")),
|
||||
"retry_download_url": retry_download_url,
|
||||
"retry_download_protocol": protocol.lower() if protocol is not None else None,
|
||||
"retry_release_name": normalize_optional_text(release_data.get("title")),
|
||||
"retry_expected_hash": normalize_optional_text(
|
||||
@@ -199,6 +215,11 @@ def queue_release(
|
||||
"""Add a release to the download queue. Returns (success, error_message)."""
|
||||
try:
|
||||
source = release_data["source"]
|
||||
unavailable_message = _source_unavailable_message(source)
|
||||
if unavailable_message:
|
||||
logger.warning("Rejected queue request for unavailable source %s", source)
|
||||
return False, unavailable_message
|
||||
|
||||
extra = release_data.get("extra", {})
|
||||
raw_request_id = release_data.get("_request_id")
|
||||
request_id: int | None = None
|
||||
@@ -590,6 +611,16 @@ def _download_task(task_id: str, cancel_flag: Event) -> str | None:
|
||||
logger.error("Task not found in queue: %s", task_id)
|
||||
return None
|
||||
|
||||
unavailable_message = _source_unavailable_message(task.source)
|
||||
if unavailable_message:
|
||||
logger.warning("Task %s: source unavailable: %s", task_id, unavailable_message)
|
||||
_capture_task_error(
|
||||
task,
|
||||
message=unavailable_message,
|
||||
exc_type="SourceUnavailable",
|
||||
)
|
||||
return None
|
||||
|
||||
title_label = task.title or "Unknown title"
|
||||
logger.info(
|
||||
"Task %s: starting download (%s) - %s",
|
||||
|
||||
@@ -13,7 +13,6 @@ from shelfmark.core.naming import (
|
||||
build_library_path,
|
||||
derive_primary_title,
|
||||
parse_naming_template,
|
||||
same_filesystem,
|
||||
sanitize_filename,
|
||||
)
|
||||
from shelfmark.core.utils import is_audiobook as check_audiobook
|
||||
@@ -39,10 +38,7 @@ _TRANSFER_PROCESS_ERRORS = (AttributeError, KeyError, OSError, RuntimeError, Typ
|
||||
|
||||
|
||||
def should_hardlink(task: DownloadTask) -> bool:
|
||||
"""Check if hardlinking is enabled for this task (Prowlarr torrents only)."""
|
||||
if task.source != "prowlarr":
|
||||
return False
|
||||
|
||||
"""Check if hardlinking is enabled for this torrent-backed task."""
|
||||
if not task.original_download_path:
|
||||
return False
|
||||
|
||||
@@ -96,21 +92,21 @@ def resolve_hardlink_source(
|
||||
if hardlink_enabled and task.original_download_path:
|
||||
hardlink_source = Path(task.original_download_path)
|
||||
hardlink_source_exists = run_blocking_io(hardlink_source.exists)
|
||||
if (
|
||||
destination
|
||||
and hardlink_source_exists
|
||||
and run_blocking_io(same_filesystem, hardlink_source, destination)
|
||||
):
|
||||
if hardlink_source_exists:
|
||||
use_hardlink = True
|
||||
source_path = hardlink_source
|
||||
elif hardlink_source_exists:
|
||||
logger.warning(
|
||||
"Cannot hardlink: %s and %s are on different filesystems. Falling back to copy. To fix: ensure torrent client downloads to same filesystem as destination.",
|
||||
logger.info(
|
||||
"Hardlink enabled for task %s; attempting link from %s to %s",
|
||||
task.task_id,
|
||||
hardlink_source,
|
||||
destination,
|
||||
)
|
||||
if status_callback:
|
||||
status_callback("resolving", "Cannot hardlink (different filesystems), using copy")
|
||||
else:
|
||||
logger.warning(
|
||||
"Hardlink enabled for task %s, but source path does not exist: %s",
|
||||
task.task_id,
|
||||
hardlink_source,
|
||||
)
|
||||
|
||||
return TransferPlan(
|
||||
source_path=source_path,
|
||||
|
||||
@@ -26,6 +26,7 @@ from shelfmark.config.env import (
|
||||
BUILD_VERSION,
|
||||
CONFIG_DIR,
|
||||
CWA_DB_PATH,
|
||||
DISABLE_LOCAL_AUTH,
|
||||
FLASK_HOST,
|
||||
FLASK_PORT,
|
||||
HIDE_LOCAL_AUTH,
|
||||
@@ -1477,6 +1478,43 @@ def _download_row_owned_by_actor(
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_queue_actor() -> tuple[bool, int | None, str | None, Response | None]:
|
||||
is_admin, db_user_id, can_access_status = _resolve_status_scope()
|
||||
actor_username = session.get("user_id")
|
||||
normalized_actor_username = actor_username if isinstance(actor_username, str) else None
|
||||
|
||||
if not is_admin and (not can_access_status or db_user_id is None):
|
||||
return (
|
||||
is_admin,
|
||||
db_user_id,
|
||||
normalized_actor_username,
|
||||
jsonify({"error": "User identity unavailable", "code": "user_identity_unavailable"}),
|
||||
)
|
||||
|
||||
return is_admin, db_user_id, normalized_actor_username, None
|
||||
|
||||
|
||||
def _queue_task_visible_to_actor(
|
||||
task_id: str,
|
||||
*,
|
||||
is_admin: bool,
|
||||
actor_user_id: int | None,
|
||||
actor_username: str | None,
|
||||
) -> bool:
|
||||
if is_admin:
|
||||
return True
|
||||
|
||||
task = backend.book_queue.get_task(task_id)
|
||||
if task is None:
|
||||
return False
|
||||
|
||||
return _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=actor_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
|
||||
|
||||
backend.book_queue.set_queue_hook(_record_download_queued)
|
||||
backend.book_queue.set_terminal_status_hook(_record_download_terminal_snapshot)
|
||||
|
||||
@@ -1586,6 +1624,7 @@ def api_local_download() -> Response | tuple[Response, int]:
|
||||
|
||||
|
||||
@app.route("/api/covers/<cover_id>", methods=["GET"])
|
||||
@login_required
|
||||
def api_cover(cover_id: str) -> Response | tuple[Response, int]:
|
||||
"""Serve a cached book cover image.
|
||||
|
||||
@@ -1797,6 +1836,22 @@ def api_set_priority(book_id: str) -> Response | tuple[Response, int]:
|
||||
return jsonify({"error": "Priority not provided"}), 400
|
||||
|
||||
priority = int(data["priority"])
|
||||
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
|
||||
task = backend.book_queue.get_task(book_id)
|
||||
if task is None:
|
||||
return jsonify({"error": "Failed to update priority or book not found"}), 404
|
||||
|
||||
if not is_admin and not _task_owned_by_actor(
|
||||
task,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
|
||||
success = backend.set_book_priority(book_id, priority)
|
||||
|
||||
if success:
|
||||
@@ -1835,6 +1890,23 @@ def api_reorder_queue() -> Response | tuple[Response, int]:
|
||||
if not isinstance(priority, int):
|
||||
return jsonify({"error": f"Invalid priority for book {book_id}"}), 400
|
||||
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
|
||||
if not is_admin:
|
||||
owned_book_priorities = {}
|
||||
for book_id in book_priorities:
|
||||
task = backend.book_queue.get_task(str(book_id))
|
||||
if task is None:
|
||||
continue
|
||||
if not _task_owned_by_actor(
|
||||
task, actor_user_id=db_user_id, actor_username=actor_username
|
||||
):
|
||||
return jsonify({"error": "Forbidden", "code": "download_not_owned"}), 403
|
||||
owned_book_priorities[book_id] = book_priorities[book_id]
|
||||
book_priorities = owned_book_priorities
|
||||
|
||||
success = backend.reorder_queue(book_priorities)
|
||||
|
||||
if success:
|
||||
@@ -1856,6 +1928,20 @@ def api_queue_order() -> Response | tuple[Response, int]:
|
||||
"""
|
||||
try:
|
||||
queue_order = backend.get_queue_order()
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
if not is_admin:
|
||||
queue_order = [
|
||||
item
|
||||
for item in queue_order
|
||||
if _queue_task_visible_to_actor(
|
||||
str(item.get("id", "")),
|
||||
is_admin=False,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
]
|
||||
return jsonify({"queue": queue_order})
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Queue order error: {e}")
|
||||
@@ -1873,6 +1959,20 @@ def api_active_downloads() -> Response | tuple[Response, int]:
|
||||
"""
|
||||
try:
|
||||
active_downloads = backend.get_active_downloads()
|
||||
is_admin, db_user_id, actor_username, identity_error = _resolve_queue_actor()
|
||||
if identity_error is not None:
|
||||
return identity_error, 403
|
||||
if not is_admin:
|
||||
active_downloads = [
|
||||
task_id
|
||||
for task_id in active_downloads
|
||||
if _queue_task_visible_to_actor(
|
||||
task_id,
|
||||
is_admin=False,
|
||||
actor_user_id=db_user_id,
|
||||
actor_username=actor_username,
|
||||
)
|
||||
]
|
||||
return jsonify({"active_downloads": active_downloads})
|
||||
except _OPERATIONAL_ERRORS as e:
|
||||
logger.error_trace(f"Active downloads error: {e}")
|
||||
@@ -1955,6 +2055,9 @@ def api_login() -> Response | tuple[Response, int]:
|
||||
if auth_mode == "proxy":
|
||||
return jsonify({"error": "Proxy authentication is enabled"}), 401
|
||||
|
||||
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
|
||||
return jsonify({"error": "Local authentication is disabled"}), 403
|
||||
|
||||
if auth_mode == "oidc" and HIDE_LOCAL_AUTH:
|
||||
return jsonify({"error": "Local authentication is disabled"}), 403
|
||||
|
||||
@@ -2184,6 +2287,9 @@ def api_auth_check() -> Response | tuple[Response, int]:
|
||||
if logout_url:
|
||||
response_data["logout_url"] = logout_url
|
||||
|
||||
if auth_mode in ("builtin", "oidc") and DISABLE_LOCAL_AUTH:
|
||||
response_data["hide_local_auth"] = True
|
||||
|
||||
# Add custom OIDC button label and SSO enforcement flags if configured
|
||||
if auth_mode == "oidc":
|
||||
oidc_button_label = app_config.get("OIDC_BUTTON_LABEL", "")
|
||||
|
||||
@@ -47,6 +47,11 @@ _HTTP_STATUS_NOT_FOUND = HTTPStatus.NOT_FOUND
|
||||
|
||||
GOOGLE_BOOKS_BASE_URL = "https://www.googleapis.com/books/v1"
|
||||
|
||||
|
||||
class _GoogleBooksRequestError(Exception):
|
||||
"""Raised when Google Books does not return a usable API response."""
|
||||
|
||||
|
||||
# Sort mapping - Google only supports "relevance" and "newest"
|
||||
SORT_MAPPING: dict[SortOrder, str | None] = {
|
||||
SortOrder.RELEVANCE: None, # Default, no param needed
|
||||
@@ -117,7 +122,10 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
f"{options.query}:{options.search_type.value}:{options.sort.value}:"
|
||||
f"{options.language}:{options.limit}:{options.page}:{fields_key}"
|
||||
)
|
||||
return self._search_cached(cache_key, options)
|
||||
try:
|
||||
return self._search_cached(cache_key, options)
|
||||
except _GoogleBooksRequestError:
|
||||
return []
|
||||
|
||||
@cacheable(
|
||||
ttl_key="METADATA_CACHE_SEARCH_TTL",
|
||||
@@ -166,18 +174,19 @@ class GoogleBooksProvider(MetadataProvider):
|
||||
if options.language:
|
||||
params["langRestrict"] = options.language
|
||||
|
||||
result = self._make_request("/volumes", params)
|
||||
if result is None:
|
||||
raise _GoogleBooksRequestError
|
||||
|
||||
books: list[BookMetadata] = []
|
||||
try:
|
||||
result = self._make_request("/volumes", params)
|
||||
if result:
|
||||
items = result.get("items", [])
|
||||
items = result.get("items", [])
|
||||
for item in items:
|
||||
book = self._parse_volume(item)
|
||||
if book:
|
||||
books.append(book)
|
||||
|
||||
for item in items:
|
||||
book = self._parse_volume(item)
|
||||
if book:
|
||||
books.append(book)
|
||||
|
||||
logger.info("Google Books search '%s' returned %s results", query, len(books))
|
||||
logger.info("Google Books search '%s' returned %s results", query, len(books))
|
||||
|
||||
except Exception:
|
||||
logger.exception("Google Books search error")
|
||||
|
||||
@@ -390,6 +390,10 @@ class DownloadHandler(ABC):
|
||||
"""
|
||||
return
|
||||
|
||||
def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return private queue-time fields needed for restart-safe retry."""
|
||||
return {}
|
||||
|
||||
@abstractmethod
|
||||
def cancel(self, task_id: str) -> bool:
|
||||
"""Cancel an in-progress download."""
|
||||
|
||||
@@ -24,6 +24,8 @@ if TYPE_CHECKING:
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
DEFAULT_ABB_HOSTNAME = "audiobookbay.lu"
|
||||
ALLOWED_DETAIL_URL_SCHEMES = {"https"}
|
||||
|
||||
|
||||
def _resolve_configured_hostname() -> str:
|
||||
@@ -32,6 +34,23 @@ def _resolve_configured_hostname() -> str:
|
||||
return normalize_hostname(configured_hostname if isinstance(configured_hostname, str) else "")
|
||||
|
||||
|
||||
def _resolve_allowed_detail_hostname() -> str:
|
||||
"""Return the ABB hostname allowed for queued detail URLs."""
|
||||
return _resolve_configured_hostname() or DEFAULT_ABB_HOSTNAME
|
||||
|
||||
|
||||
def _detail_url_matches_host(detail_url: str, hostname: str) -> bool:
|
||||
"""Return True when a detail URL uses the allowed ABB scheme and host."""
|
||||
parsed = urlparse(detail_url)
|
||||
detail_hostname = normalize_hostname(parsed.hostname)
|
||||
allowed_hostname = normalize_hostname(hostname).lower().rstrip(".")
|
||||
return (
|
||||
parsed.scheme.lower() in ALLOWED_DETAIL_URL_SCHEMES
|
||||
and bool(detail_hostname)
|
||||
and detail_hostname.lower().rstrip(".") == allowed_hostname
|
||||
)
|
||||
|
||||
|
||||
@register_handler("audiobookbay")
|
||||
class AudiobookBayHandler(ExternalClientHandler):
|
||||
"""Handler for AudiobookBay downloads via configured torrent client."""
|
||||
@@ -69,9 +88,14 @@ class AudiobookBayHandler(ExternalClientHandler):
|
||||
logger.warning("Missing details URL for AudiobookBay task: %s", task.task_id)
|
||||
return None
|
||||
|
||||
hostname = _resolve_configured_hostname()
|
||||
if not hostname:
|
||||
hostname = normalize_hostname(urlparse(detail_url).hostname)
|
||||
hostname = _resolve_allowed_detail_hostname()
|
||||
if not _detail_url_matches_host(detail_url, hostname):
|
||||
status_callback("error", "Invalid AudiobookBay details URL")
|
||||
logger.warning(
|
||||
"Rejected AudiobookBay details URL with invalid scheme or host: %s",
|
||||
detail_url,
|
||||
)
|
||||
return None
|
||||
|
||||
status_callback("resolving", "Extracting magnet link")
|
||||
magnet_link = scraper.extract_magnet_link(detail_url, hostname)
|
||||
|
||||
@@ -14,7 +14,7 @@ from typing import TYPE_CHECKING, Self
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
|
||||
from .dcc import DCCOffer, parse_dcc_send
|
||||
from .dcc import DCCError, DCCOffer, parse_dcc_send, validate_dcc_endpoint
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@@ -400,6 +400,33 @@ class IRCClient:
|
||||
self.send_notice(sender, f"\x01VERSION {self.version}\x01")
|
||||
logger.debug("Sent VERSION to %s", sender)
|
||||
|
||||
@staticmethod
|
||||
def _sender_nick(msg: IRCMessage) -> str | None:
|
||||
"""Extract the nick from a message prefix."""
|
||||
if not msg.prefix:
|
||||
return None
|
||||
return msg.prefix.split("!", maxsplit=1)[0]
|
||||
|
||||
def _is_allowed_dcc_sender(
|
||||
self,
|
||||
msg: IRCMessage,
|
||||
expected_senders: set[str] | None,
|
||||
) -> bool:
|
||||
allowed_senders = expected_senders or self.online_servers
|
||||
if not allowed_senders:
|
||||
return True
|
||||
|
||||
sender = self._sender_nick(msg)
|
||||
if sender is None:
|
||||
logger.warning("Ignoring DCC offer without sender prefix")
|
||||
return False
|
||||
|
||||
normalized_allowed = {nick.casefold() for nick in allowed_senders}
|
||||
if sender.casefold() not in normalized_allowed:
|
||||
logger.warning("Ignoring DCC offer from unexpected sender: %s", sender)
|
||||
return False
|
||||
return True
|
||||
|
||||
def read_messages(self, *, auto_handle: bool = True) -> Iterator[IRCMessage]:
|
||||
"""Read and yield IRC messages, optionally auto-handling PING/VERSION."""
|
||||
for line in self._recv_lines():
|
||||
@@ -422,6 +449,7 @@ class IRCClient:
|
||||
timeout: float = 60.0,
|
||||
*,
|
||||
result_type: bool = False,
|
||||
expected_senders: set[str] | None = None,
|
||||
) -> DCCOffer | None:
|
||||
"""Wait for a DCC SEND offer. Returns None on timeout or no results."""
|
||||
target_event = IRCEvent.SEARCH_RESULT if result_type else IRCEvent.BOOK_RESULT
|
||||
@@ -433,12 +461,15 @@ class IRCClient:
|
||||
return None
|
||||
|
||||
if msg.event == target_event:
|
||||
if not self._is_allowed_dcc_sender(msg, expected_senders):
|
||||
continue
|
||||
try:
|
||||
offer = parse_dcc_send(msg.raw)
|
||||
validate_dcc_endpoint(offer)
|
||||
logger.info("Received DCC offer: %s", offer.filename)
|
||||
except Exception:
|
||||
logger.exception("Failed to parse DCC")
|
||||
return None
|
||||
except DCCError:
|
||||
logger.exception("Rejected DCC offer")
|
||||
continue
|
||||
else:
|
||||
return offer
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import re
|
||||
import socket
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from ipaddress import ip_address
|
||||
from pathlib import PureWindowsPath
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -59,6 +61,10 @@ class DCCConnectionError(DCCError):
|
||||
"""Failed to connect to DCC sender."""
|
||||
|
||||
|
||||
class DCCSecurityError(DCCError):
|
||||
"""Rejected unsafe DCC offer metadata."""
|
||||
|
||||
|
||||
def int_to_ip(ip_int: int) -> str:
|
||||
"""Convert 32-bit integer (DCC format) to dotted IP notation."""
|
||||
packed = struct.pack(">I", ip_int)
|
||||
@@ -76,15 +82,53 @@ def parse_dcc_send(text: str) -> DCCOffer:
|
||||
ip_int = int(match.group(2))
|
||||
port = int(match.group(3))
|
||||
size = int(match.group(4))
|
||||
try:
|
||||
ip = int_to_ip(ip_int)
|
||||
except struct.error as e:
|
||||
msg = f"Invalid DCC IP integer: {ip_int}"
|
||||
raise DCCParseError(msg) from e
|
||||
|
||||
return DCCOffer(
|
||||
filename=filename,
|
||||
ip=int_to_ip(ip_int),
|
||||
filename=safe_dcc_filename(filename),
|
||||
ip=ip,
|
||||
port=port,
|
||||
size=size,
|
||||
)
|
||||
|
||||
|
||||
def safe_dcc_filename(filename: str) -> str:
|
||||
"""Return a DCC filename that cannot escape its destination directory."""
|
||||
safe_name = filename.strip()
|
||||
windows_path = PureWindowsPath(safe_name)
|
||||
if (
|
||||
not safe_name
|
||||
or safe_name in {".", ".."}
|
||||
or "/" in safe_name
|
||||
or "\\" in safe_name
|
||||
or windows_path.drive
|
||||
):
|
||||
msg = f"Rejected unsafe DCC filename: {filename!r}"
|
||||
raise DCCSecurityError(msg)
|
||||
return safe_name
|
||||
|
||||
|
||||
def validate_dcc_endpoint(offer: DCCOffer) -> None:
|
||||
"""Reject DCC endpoints that can target local/internal network services."""
|
||||
if not 1 <= offer.port <= 65535:
|
||||
msg = f"Rejected invalid DCC port: {offer.port}"
|
||||
raise DCCSecurityError(msg)
|
||||
|
||||
try:
|
||||
address = ip_address(offer.ip)
|
||||
except ValueError as e:
|
||||
msg = f"Rejected invalid DCC IP address: {offer.ip}"
|
||||
raise DCCSecurityError(msg) from e
|
||||
|
||||
if not address.is_global:
|
||||
msg = f"Rejected non-public DCC endpoint: {offer.ip}"
|
||||
raise DCCSecurityError(msg)
|
||||
|
||||
|
||||
def download_dcc(
|
||||
offer: DCCOffer,
|
||||
dest_path: Path,
|
||||
@@ -93,6 +137,7 @@ def download_dcc(
|
||||
timeout: float = 30.0,
|
||||
) -> None:
|
||||
"""Download file via DCC protocol to dest_path. Raises DCCError on failure."""
|
||||
validate_dcc_endpoint(offer)
|
||||
logger.info("DCC connecting to %s:%s for %s", offer.ip, offer.port, offer.filename)
|
||||
|
||||
try:
|
||||
|
||||
@@ -12,7 +12,7 @@ from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.release_sources import DownloadHandler, register_handler
|
||||
|
||||
from .connection_manager import connection_manager
|
||||
from .dcc import DCCError, download_dcc
|
||||
from .dcc import DCCError, download_dcc, safe_dcc_filename
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@@ -23,6 +23,15 @@ if TYPE_CHECKING:
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def _server_from_download_request(download_request: str) -> str | None:
|
||||
"""Extract the expected IRC bot nick from a release request line."""
|
||||
stripped = download_request.strip()
|
||||
if not stripped.startswith("!"):
|
||||
return None
|
||||
server = stripped[1:].split(maxsplit=1)[0]
|
||||
return server or None
|
||||
|
||||
|
||||
def _config_text(key: str) -> str:
|
||||
"""Read a string config value with whitespace trimmed."""
|
||||
value = config.get(key, "")
|
||||
@@ -72,6 +81,7 @@ class IRCDownloadHandler(DownloadHandler):
|
||||
"""Download a release via IRC DCC. task.task_id contains the IRC request string."""
|
||||
download_request = task.task_id
|
||||
logger.info("IRC download: %s...", download_request[:60])
|
||||
expected_server = _server_from_download_request(download_request)
|
||||
|
||||
# Get IRC settings
|
||||
server = _config_text("IRC_SERVER")
|
||||
@@ -123,7 +133,8 @@ class IRCDownloadHandler(DownloadHandler):
|
||||
# Phase 3: Wait for DCC offer
|
||||
status_callback("resolving", "Waiting for bot response")
|
||||
|
||||
offer = client.wait_for_dcc(timeout=120.0, result_type=False)
|
||||
wait_kwargs = {"expected_senders": {expected_server}} if expected_server else {}
|
||||
offer = client.wait_for_dcc(timeout=120.0, result_type=False, **wait_kwargs)
|
||||
|
||||
if not offer:
|
||||
status_callback("error", "No response from bot")
|
||||
@@ -137,7 +148,9 @@ class IRCDownloadHandler(DownloadHandler):
|
||||
status_callback("downloading", "")
|
||||
|
||||
# Get file extension from offer filename
|
||||
ext = Path(offer.filename).suffix.lstrip(".") or task.format or "epub"
|
||||
ext = (
|
||||
Path(safe_dcc_filename(offer.filename)).suffix.lstrip(".") or task.format or "epub"
|
||||
)
|
||||
|
||||
# Stage to temp directory (lazy import to avoid circular import)
|
||||
from shelfmark.download.staging import get_staging_path
|
||||
|
||||
@@ -30,7 +30,7 @@ from shelfmark.release_sources import (
|
||||
)
|
||||
|
||||
from .connection_manager import connection_manager
|
||||
from .dcc import DCCError, download_dcc
|
||||
from .dcc import DCCError, download_dcc, safe_dcc_filename
|
||||
from .parser import SearchResult, extract_results_from_zip, parse_results_file
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
@@ -227,7 +227,8 @@ class IRCReleaseSource(ReleaseSource):
|
||||
|
||||
# Wait for results DCC - this is the long wait
|
||||
_emit_status(f"Connected to #{channel} - Waiting for results...", phase="searching")
|
||||
offer = client.wait_for_dcc(timeout=60.0, result_type=True)
|
||||
wait_kwargs = {"expected_senders": {search_bot}} if search_bot else {}
|
||||
offer = client.wait_for_dcc(timeout=60.0, result_type=True, **wait_kwargs)
|
||||
if not offer:
|
||||
logger.info("No search results received")
|
||||
_emit_status("No results found", phase="complete")
|
||||
@@ -247,7 +248,7 @@ class IRCReleaseSource(ReleaseSource):
|
||||
# Download results file
|
||||
_emit_status(f"Connected to #{channel} - Downloading results...", phase="downloading")
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
result_path = Path(tmpdir) / offer.filename
|
||||
result_path = Path(tmpdir) / safe_dcc_filename(offer.filename)
|
||||
download_dcc(offer, result_path, timeout=30.0)
|
||||
|
||||
# Parse results
|
||||
|
||||
@@ -8,6 +8,7 @@ if TYPE_CHECKING:
|
||||
from shelfmark.core.models import DownloadTask
|
||||
|
||||
from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.request_helpers import normalize_optional_text
|
||||
from shelfmark.download.clients import DownloadClient, get_client, list_configured_clients
|
||||
from shelfmark.download.clients.base_handler import (
|
||||
COMPLETED_PATH_MAX_ATTEMPTS as _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS,
|
||||
@@ -83,6 +84,44 @@ class NewznabHandler(ExternalClientHandler):
|
||||
def _completed_path_max_attempts(self) -> int:
|
||||
return COMPLETED_PATH_MAX_ATTEMPTS
|
||||
|
||||
def build_retry_resolution_fields(self, release_data: dict) -> dict:
|
||||
source_id = normalize_optional_text(release_data.get("source_id"))
|
||||
if source_id is None:
|
||||
return {}
|
||||
|
||||
result = get_release(source_id)
|
||||
if result is None:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"retry_download_url": normalize_optional_text(_get_download_url(result)),
|
||||
"retry_download_protocol": normalize_optional_text(_get_protocol(result)),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _restore_download_request_from_task(cls, task: DownloadTask) -> DownloadRequest | None:
|
||||
retry_download_url = normalize_optional_text(getattr(task, "retry_download_url", None))
|
||||
retry_download_protocol = normalize_optional_text(
|
||||
getattr(task, "retry_download_protocol", None)
|
||||
)
|
||||
if retry_download_url is None or retry_download_protocol is None:
|
||||
return None
|
||||
|
||||
protocol = retry_download_protocol.lower()
|
||||
if protocol not in {"torrent", "usenet"}:
|
||||
return None
|
||||
|
||||
return DownloadRequest(
|
||||
url=retry_download_url,
|
||||
protocol=protocol,
|
||||
release_name=(
|
||||
normalize_optional_text(getattr(task, "retry_release_name", None))
|
||||
or task.title
|
||||
or "Unknown"
|
||||
),
|
||||
expected_hash=normalize_optional_text(getattr(task, "retry_expected_hash", None)),
|
||||
)
|
||||
|
||||
def _resolve_download(
|
||||
self,
|
||||
task: DownloadTask,
|
||||
@@ -90,6 +129,10 @@ class NewznabHandler(ExternalClientHandler):
|
||||
) -> DownloadRequest | None:
|
||||
result = get_release(task.task_id)
|
||||
if not result:
|
||||
restored_request = self._restore_download_request_from_task(task)
|
||||
if restored_request is not None:
|
||||
logger.info("Restored Newznab download request for retry: %s", task.task_id)
|
||||
return restored_request
|
||||
logger.warning("Newznab release cache miss: %s", task.task_id)
|
||||
status_callback("error", "Release not found in cache (may have expired)")
|
||||
return None
|
||||
|
||||
@@ -109,7 +109,6 @@ def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Rel
|
||||
if is_freeleech:
|
||||
add_flag("FreeLeech")
|
||||
|
||||
download_url = str(result.get("downloadUrl") or "").strip()
|
||||
info_url = result.get("infoUrl") or result.get("guid")
|
||||
|
||||
return Release(
|
||||
@@ -120,7 +119,7 @@ def _newznab_result_to_release(result: dict, content_type: str = "ebook") -> Rel
|
||||
language=None,
|
||||
size=_parse_size(size_bytes),
|
||||
size_bytes=size_bytes,
|
||||
download_url=download_url or None,
|
||||
download_url=None,
|
||||
info_url=info_url,
|
||||
protocol=protocol,
|
||||
indexer=indexer,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from collections.abc import Mapping
|
||||
from contextlib import suppress
|
||||
from http import HTTPStatus
|
||||
from typing import Any
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import requests
|
||||
|
||||
@@ -11,7 +11,7 @@ from shelfmark.core.logger import setup_logger
|
||||
from shelfmark.core.utils import normalize_http_url
|
||||
from shelfmark.download.network import get_ssl_verify
|
||||
from shelfmark.release_sources.prowlarr.torznab import parse_torznab_xml
|
||||
from shelfmark.release_sources.prowlarr.utils import coerce_int_like
|
||||
from shelfmark.release_sources.prowlarr.utils import coerce_float_like, coerce_int_like
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
@@ -27,6 +27,15 @@ _PROWLARR_CLIENT_ERRORS = (
|
||||
)
|
||||
|
||||
|
||||
class IndexerSeedSettings(TypedDict, total=False):
|
||||
ratio_limit: float
|
||||
seeding_time_limit_minutes: int
|
||||
|
||||
|
||||
_INDEXER_FIELD_SEED_RATIO = "torrentBaseSettings.seedRatio"
|
||||
_INDEXER_FIELD_SEED_TIME_MINUTES = "torrentBaseSettings.seedTime"
|
||||
|
||||
|
||||
def _normalize_json_object(payload: object, *, context: str) -> dict[str, Any]:
|
||||
"""Return a JSON object payload with string keys or raise on unexpected shapes."""
|
||||
if not isinstance(payload, Mapping):
|
||||
@@ -52,6 +61,19 @@ def _normalize_json_object_list(payload: object, *, context: str) -> list[dict[s
|
||||
return [_normalize_json_object(item, context=context) for item in payload]
|
||||
|
||||
|
||||
def _get_field_value(fields: object, name: str) -> object | None:
|
||||
if not isinstance(fields, list):
|
||||
return None
|
||||
|
||||
for field in fields:
|
||||
if not isinstance(field, Mapping):
|
||||
continue
|
||||
if field.get("name") == name:
|
||||
return field.get("value")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class ProwlarrClient:
|
||||
"""Client for interacting with the Prowlarr API."""
|
||||
|
||||
@@ -183,6 +205,42 @@ class ProwlarrClient:
|
||||
|
||||
return enriched_ids
|
||||
|
||||
def get_indexer_seed_settings(
|
||||
self, *, restrict_to: list[int] | None = None
|
||||
) -> dict[int, IndexerSeedSettings]:
|
||||
"""Return configured per-indexer torrent share limits.
|
||||
|
||||
Prowlarr exposes seedTime in minutes, which is also the unit expected by
|
||||
torrent clients.
|
||||
"""
|
||||
settings_by_indexer: dict[int, IndexerSeedSettings] = {}
|
||||
|
||||
for idx in self.get_enabled_indexers_detailed():
|
||||
idx_id_int = coerce_int_like(idx.get("id"))
|
||||
if idx_id_int is None:
|
||||
continue
|
||||
if restrict_to is not None and idx_id_int not in restrict_to:
|
||||
continue
|
||||
if str(idx.get("protocol") or "").lower() != "torrent":
|
||||
continue
|
||||
|
||||
fields = idx.get("fields")
|
||||
ratio_limit = coerce_float_like(_get_field_value(fields, _INDEXER_FIELD_SEED_RATIO))
|
||||
seeding_time_limit = coerce_int_like(
|
||||
_get_field_value(fields, _INDEXER_FIELD_SEED_TIME_MINUTES)
|
||||
)
|
||||
|
||||
settings: IndexerSeedSettings = {}
|
||||
if ratio_limit is not None and ratio_limit > 0:
|
||||
settings["ratio_limit"] = ratio_limit
|
||||
if seeding_time_limit is not None and seeding_time_limit > 0:
|
||||
settings["seeding_time_limit_minutes"] = seeding_time_limit
|
||||
|
||||
if settings:
|
||||
settings_by_indexer[idx_id_int] = settings
|
||||
|
||||
return settings_by_indexer
|
||||
|
||||
def get_enabled_indexers(self) -> list[dict[str, Any]]:
|
||||
"""Get enabled indexers with book capability info."""
|
||||
indexers = self.get_indexers()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Prowlarr download handler - resolves releases and delegates lifecycle to shared clients."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from shelfmark.core.config import config
|
||||
from shelfmark.core.logger import setup_logger
|
||||
@@ -51,22 +51,11 @@ COMPLETED_PATH_RETRY_INTERVAL = _DEFAULT_COMPLETED_PATH_RETRY_INTERVAL
|
||||
COMPLETED_PATH_MAX_ATTEMPTS = _DEFAULT_COMPLETED_PATH_MAX_ATTEMPTS
|
||||
|
||||
|
||||
def _coerce_seed_time_minutes(raw_seed_time: object) -> int | None:
|
||||
"""Convert Prowlarr's minimum seed time from seconds to whole minutes."""
|
||||
if raw_seed_time is None:
|
||||
def _coerce_positive_minutes(raw_minutes: object) -> int | None:
|
||||
minutes = coerce_int_like(raw_minutes)
|
||||
if minutes is None:
|
||||
return None
|
||||
|
||||
seed_time_seconds = coerce_int_like(raw_seed_time)
|
||||
if seed_time_seconds is None:
|
||||
logger.warning("Invalid Prowlarr minimumSeedTime value: %r", raw_seed_time)
|
||||
return None
|
||||
|
||||
if seed_time_seconds < 0:
|
||||
logger.warning("Ignoring negative Prowlarr minimumSeedTime value: %s", seed_time_seconds)
|
||||
return None
|
||||
|
||||
# Round up so we never under-seed when a tracker uses a non-minute boundary.
|
||||
return (seed_time_seconds + 59) // 60
|
||||
return minutes if minutes > 0 else None
|
||||
|
||||
|
||||
@register_handler("prowlarr")
|
||||
@@ -90,6 +79,22 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
def _completed_path_max_attempts(self) -> int:
|
||||
return COMPLETED_PATH_MAX_ATTEMPTS
|
||||
|
||||
def build_retry_resolution_fields(self, release_data: dict[str, Any]) -> dict[str, Any]:
|
||||
source_id = normalize_optional_text(release_data.get("source_id"))
|
||||
if source_id is None:
|
||||
return {}
|
||||
|
||||
prowlarr_result = get_release(source_id)
|
||||
if prowlarr_result is None:
|
||||
return {}
|
||||
|
||||
return {
|
||||
"retry_download_url": normalize_optional_text(
|
||||
get_preferred_download_url(prowlarr_result)
|
||||
),
|
||||
"retry_download_protocol": normalize_optional_text(get_protocol(prowlarr_result)),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _restore_download_request_from_task(cls, task: DownloadTask) -> DownloadRequest | None:
|
||||
"""Rebuild a DownloadRequest when the in-memory Prowlarr cache is gone."""
|
||||
@@ -157,12 +162,14 @@ class ProwlarrHandler(ExternalClientHandler):
|
||||
release_name = prowlarr_result.get("title") or task.title or "Unknown"
|
||||
expected_hash = str(prowlarr_result.get("infoHash") or "").strip() or None
|
||||
|
||||
# Seed criteria from the indexer (Torznab attributes)
|
||||
raw_seed_time = prowlarr_result.get("minimumSeedTime")
|
||||
raw_ratio = prowlarr_result.get("minimumRatio")
|
||||
seeding_time_limit = None
|
||||
ratio_limit = None
|
||||
if config.get("PROWLARR_USE_SEED_PREFERENCES", False):
|
||||
raw_configured_seed_time = prowlarr_result.get("configuredSeedTimeMinutes")
|
||||
raw_configured_ratio = prowlarr_result.get("configuredRatioLimit")
|
||||
|
||||
seeding_time_limit = _coerce_seed_time_minutes(raw_seed_time)
|
||||
ratio_limit = float(raw_ratio) if raw_ratio is not None else None
|
||||
seeding_time_limit = _coerce_positive_minutes(raw_configured_seed_time)
|
||||
ratio_limit = float(raw_configured_ratio) if raw_configured_ratio is not None else None
|
||||
|
||||
return DownloadRequest(
|
||||
url=download_url,
|
||||
|
||||
@@ -190,4 +190,11 @@ def prowlarr_config_settings() -> list[SettingsField]:
|
||||
description="Automatically retry search without category filtering if no results are found",
|
||||
show_when={"field": "PROWLARR_ENABLED", "value": True},
|
||||
),
|
||||
CheckboxField(
|
||||
key="PROWLARR_USE_SEED_PREFERENCES",
|
||||
label="Use Prowlarr seed preferences",
|
||||
default=False,
|
||||
description="Apply per-indexer seed time and ratio preferences from Prowlarr when sending torrents to the download client",
|
||||
show_when={"field": "PROWLARR_ENABLED", "value": True},
|
||||
),
|
||||
]
|
||||
|
||||
@@ -27,12 +27,11 @@ from shelfmark.release_sources import (
|
||||
SortOption,
|
||||
register_source,
|
||||
)
|
||||
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.api import IndexerSeedSettings, ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.cache import cache_release
|
||||
from shelfmark.release_sources.prowlarr.utils import (
|
||||
coerce_float_like,
|
||||
coerce_int_like,
|
||||
get_preferred_download_url,
|
||||
get_protocol,
|
||||
)
|
||||
|
||||
@@ -407,7 +406,7 @@ def _prowlarr_result_to_release(
|
||||
language=language_detected,
|
||||
size=_parse_size(size_bytes),
|
||||
size_bytes=size_bytes,
|
||||
download_url=get_preferred_download_url(result),
|
||||
download_url=None,
|
||||
info_url=result.get("infoUrl") or result.get("guid"),
|
||||
protocol=(
|
||||
ReleaseProtocol.TORRENT
|
||||
@@ -433,8 +432,8 @@ def _prowlarr_result_to_release(
|
||||
"freeleech": is_freeleech,
|
||||
"download_volume_factor": result.get("downloadVolumeFactor"),
|
||||
"upload_volume_factor": result.get("uploadVolumeFactor"),
|
||||
"minimum_ratio": result.get("minimumRatio"),
|
||||
"minimum_seed_time": result.get("minimumSeedTime"),
|
||||
"configured_ratio_limit": result.get("configuredRatioLimit"),
|
||||
"configured_seed_time_minutes": result.get("configuredSeedTimeMinutes"),
|
||||
"info_hash": result.get("infoHash"),
|
||||
"formats": formats or None,
|
||||
"formats_display": formats_display,
|
||||
@@ -444,6 +443,27 @@ def _prowlarr_result_to_release(
|
||||
)
|
||||
|
||||
|
||||
def _apply_indexer_seed_settings(
|
||||
result: dict,
|
||||
indexer_seed_settings: dict[int, IndexerSeedSettings],
|
||||
) -> dict:
|
||||
indexer_id = _coerce_indexer_id(result.get("indexerId"))
|
||||
if indexer_id is None:
|
||||
return result
|
||||
|
||||
seed_settings = indexer_seed_settings.get(indexer_id)
|
||||
if not seed_settings:
|
||||
return result
|
||||
|
||||
enriched_result = dict(result)
|
||||
if "ratio_limit" in seed_settings:
|
||||
enriched_result["configuredRatioLimit"] = seed_settings["ratio_limit"]
|
||||
if "seeding_time_limit_minutes" in seed_settings:
|
||||
enriched_result["configuredSeedTimeMinutes"] = seed_settings["seeding_time_limit_minutes"]
|
||||
|
||||
return enriched_result
|
||||
|
||||
|
||||
@register_source("prowlarr")
|
||||
class ProwlarrSource(ReleaseSource):
|
||||
"""Prowlarr release source for ebooks and audiobooks."""
|
||||
@@ -762,6 +782,11 @@ class ProwlarrSource(ReleaseSource):
|
||||
# Some indexers benefit from title+author queries and extra format detection.
|
||||
enriched_indexer_ids = client.get_enriched_indexer_ids(restrict_to=indexer_ids)
|
||||
enriched_indexer_ids_set = set(enriched_indexer_ids)
|
||||
indexer_seed_settings = (
|
||||
client.get_indexer_seed_settings(restrict_to=indexer_ids)
|
||||
if config.get("PROWLARR_USE_SEED_PREFERENCES", False)
|
||||
else {}
|
||||
)
|
||||
|
||||
def _check_timeout() -> None:
|
||||
if time.monotonic() > deadline:
|
||||
@@ -839,15 +864,18 @@ class ProwlarrSource(ReleaseSource):
|
||||
results: list[Release] = []
|
||||
enriched_source_ids: set[str] = set()
|
||||
|
||||
for r in all_results:
|
||||
idx_id = r.get("indexerId")
|
||||
for raw_result in all_results:
|
||||
result_with_seed_settings = _apply_indexer_seed_settings(
|
||||
raw_result, indexer_seed_settings
|
||||
)
|
||||
idx_id = result_with_seed_settings.get("indexerId")
|
||||
idx_id_int = _coerce_indexer_id(idx_id)
|
||||
|
||||
is_enriched = bool(
|
||||
idx_id_int is not None and idx_id_int in enriched_indexer_ids_set
|
||||
)
|
||||
release = _prowlarr_result_to_release(
|
||||
r,
|
||||
result_with_seed_settings,
|
||||
content_type,
|
||||
enable_format_detection=is_enriched,
|
||||
)
|
||||
|
||||
@@ -140,9 +140,6 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]:
|
||||
|
||||
download_volume_factor = _coerce_float(attrs.get("downloadvolumefactor"))
|
||||
upload_volume_factor = _coerce_float(attrs.get("uploadvolumefactor"))
|
||||
minimum_ratio = _coerce_float(attrs.get("minimumratio"))
|
||||
minimum_seed_time = _coerce_int(attrs.get("minimumseedtime"))
|
||||
|
||||
cleaned_title = _strip_author_from_title(title, author)
|
||||
|
||||
results.append(
|
||||
@@ -168,8 +165,6 @@ def parse_torznab_xml(xml_text: str) -> list[dict[str, Any]]:
|
||||
"bookTitle": book_title,
|
||||
"downloadVolumeFactor": download_volume_factor,
|
||||
"uploadVolumeFactor": upload_volume_factor,
|
||||
"minimumRatio": minimum_ratio,
|
||||
"minimumSeedTime": minimum_seed_time,
|
||||
# Pass through all torznab attributes for tooltip display
|
||||
"torznabAttrs": attrs,
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"react/jsx-no-useless-fragment": "error",
|
||||
"react/self-closing-comp": "error",
|
||||
"typescript/switch-exhaustiveness-check": "error",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
|
||||
Generated
+395
-416
File diff suppressed because it is too large
Load Diff
@@ -18,23 +18,23 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"socket.io-client": "^4.7.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"knip": "^6.6.2",
|
||||
"oxfmt": "^0.46.0",
|
||||
"oxlint": "^1.61.0",
|
||||
"oxlint-tsgolint": "^0.21.1",
|
||||
"knip": "^6.12.1",
|
||||
"oxfmt": "^0.48.0",
|
||||
"oxlint": "^1.63.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.10",
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ export const OidcEnvInfo = (_props: CustomSettingsFieldRendererProps) => {
|
||||
{' '}
|
||||
<span className="opacity-40"># Hide the local login form</span>
|
||||
{'\n'}
|
||||
{' '}- <span className="text-blue-400">DISABLE_LOCAL_AUTH</span>=
|
||||
<span className="text-green-400">true</span>{' '}
|
||||
<span className="opacity-40"># Disable username/password login</span>
|
||||
{'\n'}
|
||||
{' '}- <span className="text-blue-400">OIDC_AUTO_REDIRECT</span>=
|
||||
<span className="text-green-400">true</span>
|
||||
{' '}
|
||||
|
||||
@@ -75,8 +75,8 @@ const hydrateSettingsResponse = (response: SettingsResponse): HydratedSettingsSt
|
||||
});
|
||||
|
||||
const values = extractSettingsValues(tabs);
|
||||
if (values.general && Object.prototype.hasOwnProperty.call(values.general, '_THEME')) {
|
||||
values.general._THEME = getStoredThemePreference();
|
||||
if (values.general && Object.prototype.hasOwnProperty.call(values.general, THEME_FIELD.key)) {
|
||||
values.general[THEME_FIELD.key] = getStoredThemePreference();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -208,7 +208,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
|
||||
const updateValue = useCallback(
|
||||
(tabName: string, key: string, value: unknown) => {
|
||||
if (key === '_THEME' && typeof value === 'string') {
|
||||
if (key === THEME_FIELD.key && typeof value === 'string') {
|
||||
setThemePreference(value);
|
||||
setOriginalValues((prev) => ({
|
||||
...prev,
|
||||
@@ -277,7 +277,7 @@ export function useSettings(): UseSettingsReturn {
|
||||
if (tab) {
|
||||
for (const field of getValueBearingFields(tab.fields)) {
|
||||
if (field.fromEnv) continue; // Skip env-locked fields
|
||||
if (field.key === '_THEME') continue; // Skip client-side only theme field
|
||||
if (field.key === THEME_FIELD.key) continue; // Skip client-side only theme field
|
||||
|
||||
const value = tabValues[field.key];
|
||||
const originalValue = originalTabValues[field.key];
|
||||
|
||||
@@ -4,7 +4,9 @@ import type { Language } from '../types/index';
|
||||
import {
|
||||
LANGUAGE_OPTION_ALL,
|
||||
LANGUAGE_OPTION_DEFAULT,
|
||||
buildLanguageNormalizer,
|
||||
getReleaseSearchLanguageParams,
|
||||
releaseLanguageMatchesFilter,
|
||||
} from '../utils/languageFilters';
|
||||
|
||||
const supportedLanguages: Language[] = [
|
||||
@@ -35,4 +37,33 @@ describe('languageFilters release search params', () => {
|
||||
|
||||
expect(result).toEqual(['de', 'hu']);
|
||||
});
|
||||
|
||||
it('normalizes legacy default language names when combined with explicit filters', () => {
|
||||
const result = getReleaseSearchLanguageParams(
|
||||
[LANGUAGE_OPTION_DEFAULT, 'de'],
|
||||
supportedLanguages,
|
||||
['english'],
|
||||
);
|
||||
|
||||
expect(result).toEqual(['en', 'de']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('releaseLanguageMatchesFilter', () => {
|
||||
it('matches release language names against legacy default language names', () => {
|
||||
const normalizer = buildLanguageNormalizer(supportedLanguages);
|
||||
|
||||
expect(releaseLanguageMatchesFilter('English', ['english'], normalizer)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps English-only issue 948 fallback results with a legacy English default', () => {
|
||||
const normalizer = buildLanguageNormalizer(supportedLanguages);
|
||||
const issue948Languages = [...Array<string>(48).fill('en'), 'de, en', 'en, es'];
|
||||
|
||||
const visibleLanguages = issue948Languages.filter((language) =>
|
||||
releaseLanguageMatchesFilter(language, ['english'], normalizer),
|
||||
);
|
||||
|
||||
expect(visibleLanguages).toHaveLength(48);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,13 +31,13 @@ const resolveBasePath = (): string => {
|
||||
};
|
||||
|
||||
// Lazy initialization to ensure DOM is ready when base path is resolved
|
||||
let _basePath: string | null = null;
|
||||
let cachedBasePath: string | null = null;
|
||||
|
||||
export const getBasePath = (): string => {
|
||||
if (_basePath === null) {
|
||||
_basePath = normalizeBasePath(resolveBasePath());
|
||||
if (cachedBasePath === null) {
|
||||
cachedBasePath = normalizeBasePath(resolveBasePath());
|
||||
}
|
||||
return _basePath;
|
||||
return cachedBasePath;
|
||||
};
|
||||
|
||||
export const withBasePath = (path: string): string => {
|
||||
|
||||
@@ -48,8 +48,11 @@ export const getLanguageFilterValues = (
|
||||
return null;
|
||||
}
|
||||
|
||||
const supportedCodes = new Set(supportedLanguages.map((lang) => lang.code));
|
||||
const defaultCodes = defaultLanguageCodes.filter((code) => supportedCodes.has(code));
|
||||
const languageNormalizer = buildLanguageNormalizer(supportedLanguages);
|
||||
const supportedCodes = new Set(supportedLanguages.map((lang) => lang.code.toLowerCase()));
|
||||
const defaultCodes = defaultLanguageCodes
|
||||
.map((code) => languageNormalizer.get(code.toLowerCase()) ?? code.toLowerCase())
|
||||
.filter((code) => supportedCodes.has(code));
|
||||
const resolved = new Set<string>();
|
||||
|
||||
uniqueSelection.forEach((code) => {
|
||||
@@ -58,8 +61,9 @@ export const getLanguageFilterValues = (
|
||||
return;
|
||||
}
|
||||
|
||||
if (supportedCodes.has(code)) {
|
||||
resolved.add(code);
|
||||
const normalizedCode = languageNormalizer.get(code.toLowerCase()) ?? code.toLowerCase();
|
||||
if (supportedCodes.has(normalizedCode)) {
|
||||
resolved.add(normalizedCode);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -130,6 +134,11 @@ export const releaseLanguageMatchesFilter = (
|
||||
return part;
|
||||
});
|
||||
|
||||
const selectedSet = new Set(selectedCodes.map((c) => c.toLowerCase()));
|
||||
const selectedSet = new Set(
|
||||
selectedCodes.map((code) => {
|
||||
const normalizedCode = code.toLowerCase();
|
||||
return languageNormalizer?.get(normalizedCode) ?? normalizedCode;
|
||||
}),
|
||||
);
|
||||
return releaseCodes.every((code) => selectedSet.has(code));
|
||||
};
|
||||
|
||||
@@ -120,6 +120,83 @@ class TestAudiobookBayHandlerDownload:
|
||||
)
|
||||
assert "resolving" in recorder.statuses
|
||||
|
||||
@patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link")
|
||||
@patch("shelfmark.release_sources.audiobookbay.handler.get_client")
|
||||
@patch("shelfmark.release_sources.audiobookbay.handler.config.get")
|
||||
def test_download_rejects_source_url_host_mismatch(
|
||||
self, mock_config_get, mock_get_client, mock_extract_magnet
|
||||
):
|
||||
"""Test hostile detail URLs are rejected before page fetch."""
|
||||
mock_config_get.side_effect = lambda key, default="": (
|
||||
"audiobookbay.lu" if key == "ABB_HOSTNAME" else default
|
||||
)
|
||||
|
||||
handler = AudiobookBayHandler()
|
||||
task = DownloadTask(
|
||||
task_id="35f56a3e5734bfa69c3169ee8e605a60",
|
||||
source="audiobookbay",
|
||||
title="Test Book",
|
||||
content_type="audiobook",
|
||||
source_url="https://169.254.169.254/latest/meta-data/",
|
||||
)
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert recorder.last_status == "error"
|
||||
assert "details url" in recorder.last_message.lower()
|
||||
mock_extract_magnet.assert_not_called()
|
||||
mock_get_client.assert_not_called()
|
||||
|
||||
@patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link")
|
||||
@patch("shelfmark.release_sources.audiobookbay.handler.get_client")
|
||||
@patch("shelfmark.release_sources.audiobookbay.handler.config.get")
|
||||
def test_download_allows_configured_source_url_host(
|
||||
self, mock_config_get, mock_get_client, mock_extract_magnet
|
||||
):
|
||||
"""Test configured ABB host remains allowed for queued release URLs."""
|
||||
mock_config_get.side_effect = lambda key, default="": (
|
||||
"https://audiobookbay.lu/" if key == "ABB_HOSTNAME" else default
|
||||
)
|
||||
mock_extract_magnet.return_value = "magnet:?xt=urn:btih:abc123"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "qbittorrent"
|
||||
mock_client.find_existing.return_value = None
|
||||
mock_client.add_download.return_value = "download_id_123"
|
||||
mock_get_client.return_value = mock_client
|
||||
|
||||
handler = AudiobookBayHandler()
|
||||
task = DownloadTask(
|
||||
task_id="35f56a3e5734bfa69c3169ee8e605a60",
|
||||
source="audiobookbay",
|
||||
title="Test Book",
|
||||
content_type="audiobook",
|
||||
source_url="https://audiobookbay.lu/abss/test-book/",
|
||||
)
|
||||
cancel_flag = Event()
|
||||
recorder = ProgressRecorder()
|
||||
with patch.object(AudiobookBayHandler, "_poll_and_complete", return_value=None):
|
||||
result = handler.download(
|
||||
task=task,
|
||||
cancel_flag=cancel_flag,
|
||||
progress_callback=recorder.progress_callback,
|
||||
status_callback=recorder.status_callback,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
mock_extract_magnet.assert_called_once_with(
|
||||
"https://audiobookbay.lu/abss/test-book/", "audiobookbay.lu"
|
||||
)
|
||||
mock_client.add_download.assert_called_once()
|
||||
|
||||
@patch("shelfmark.release_sources.audiobookbay.handler.scraper.extract_magnet_link")
|
||||
@patch("shelfmark.release_sources.audiobookbay.handler.get_client")
|
||||
def test_download_existing_complete(self, mock_get_client, mock_extract_magnet):
|
||||
|
||||
@@ -10,6 +10,9 @@ from pathlib import Path
|
||||
ENTRYPOINT_PATH = Path(__file__).resolve().parents[2] / "entrypoint.sh"
|
||||
ENTRYPOINT_LOCK_PATH = Path("/tmp/shelfmark_entrypoint_test.lock")
|
||||
BASH_PATH = shutil.which("bash") or "/bin/bash"
|
||||
ID_PATH = shutil.which("id") or "/usr/bin/id"
|
||||
MKDIR_PATH = shutil.which("mkdir") or "/bin/mkdir"
|
||||
STAT_PATH = shutil.which("stat") or "/usr/bin/stat"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
@@ -55,6 +58,61 @@ exit 2
|
||||
printf '%s' "$HOME" > "$ENTRYPOINT_GUNICORN_HOME_FILE"
|
||||
printf '%s' "$*" > "$ENTRYPOINT_GUNICORN_ARGS_FILE"
|
||||
exit 0
|
||||
""",
|
||||
)
|
||||
_write_executable(
|
||||
bin_dir / "id",
|
||||
"""#!/bin/sh
|
||||
if [ -n "${ENTRYPOINT_STUB_CURRENT_UID:-}" ]; then
|
||||
if [ "$1" = "-u" ]; then
|
||||
printf '%s\\n' "$ENTRYPOINT_STUB_CURRENT_UID"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-g" ]; then
|
||||
printf '%s\\n' "$ENTRYPOINT_STUB_CURRENT_GID"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
exec "$ENTRYPOINT_REAL_ID" "$@"
|
||||
""",
|
||||
)
|
||||
_write_executable(
|
||||
bin_dir / "gosu",
|
||||
"""#!/bin/sh
|
||||
shift
|
||||
if [ "${ENTRYPOINT_STUB_GOSU_FAIL_WRITES:-false}" = "true" ] && [ "$1" = "sh" ] && [ "$2" = "-c" ]; then
|
||||
exit 1
|
||||
fi
|
||||
exec "$@"
|
||||
""",
|
||||
)
|
||||
_write_executable(
|
||||
bin_dir / "mkdir",
|
||||
"""#!/bin/sh
|
||||
for arg in "$@"; do
|
||||
if [ "$arg" = "/var/log/shelfmark" ]; then
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
exec "$ENTRYPOINT_REAL_MKDIR" "$@"
|
||||
""",
|
||||
)
|
||||
_write_executable(
|
||||
bin_dir / "stat",
|
||||
"""#!/bin/sh
|
||||
if [ "$1" = "-c" ]; then
|
||||
if [ "$2" = "%u:%g" ]; then
|
||||
printf '%s\\n' "${ENTRYPOINT_STUB_STAT_OWNER:-0:0}"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
exec "$ENTRYPOINT_REAL_STAT" "$@"
|
||||
""",
|
||||
)
|
||||
_write_executable(
|
||||
bin_dir / "chown",
|
||||
"""#!/bin/sh
|
||||
exit 0
|
||||
""",
|
||||
)
|
||||
|
||||
@@ -65,6 +123,8 @@ def _run_entrypoint(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
simulate_root_startup: bool = False,
|
||||
fail_gosu_writes: bool = False,
|
||||
stub_home: Path | str | None = None,
|
||||
) -> tuple[subprocess.CompletedProcess[str], Path, Path, Path]:
|
||||
runtime_home = tmp_path / "runtime-home"
|
||||
@@ -86,6 +146,9 @@ def _run_entrypoint(
|
||||
"ENABLE_LOGGING": "false",
|
||||
"ENTRYPOINT_GUNICORN_ARGS_FILE": str(runtime_args_file),
|
||||
"ENTRYPOINT_GUNICORN_HOME_FILE": str(runtime_home_file),
|
||||
"ENTRYPOINT_REAL_ID": ID_PATH,
|
||||
"ENTRYPOINT_REAL_MKDIR": MKDIR_PATH,
|
||||
"ENTRYPOINT_REAL_STAT": STAT_PATH,
|
||||
"ENTRYPOINT_STUB_GID": str(os.getgid()),
|
||||
"ENTRYPOINT_STUB_HOME": str(stub_home),
|
||||
"ENTRYPOINT_STUB_UID": str(os.getuid()),
|
||||
@@ -99,6 +162,18 @@ def _run_entrypoint(
|
||||
"USING_EXTERNAL_BYPASSER": "true",
|
||||
}
|
||||
)
|
||||
if simulate_root_startup:
|
||||
env.update(
|
||||
{
|
||||
"ENTRYPOINT_STUB_CURRENT_GID": "0",
|
||||
"ENTRYPOINT_STUB_CURRENT_UID": "0",
|
||||
"ENTRYPOINT_STUB_STAT_OWNER": "0:0",
|
||||
"PGID": str(os.getgid()),
|
||||
"PUID": str(os.getuid()),
|
||||
}
|
||||
)
|
||||
if fail_gosu_writes:
|
||||
env["ENTRYPOINT_STUB_GOSU_FAIL_WRITES"] = "true"
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
|
||||
@@ -168,3 +243,17 @@ def test_entrypoint_non_root_mode_requires_writable_config_dir(tmp_path):
|
||||
f"Config directory is not writable in non-root mode: {readonly_config_dir}" in result.stdout
|
||||
)
|
||||
assert "Prepare ownership outside the container" in result.stdout
|
||||
|
||||
|
||||
def test_entrypoint_root_bootstrap_fails_closed_when_config_repair_fails(tmp_path):
|
||||
result, _, _, _ = _run_entrypoint(
|
||||
tmp_path,
|
||||
simulate_root_startup=True,
|
||||
fail_gosu_writes=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 1
|
||||
assert "ERROR: Config directory is not writable!" in result.stdout
|
||||
assert f"Configured runtime identity: {os.getuid()}:{os.getgid()}" in result.stdout
|
||||
assert f"chown -R {os.getuid()}:{os.getgid()} /path/to/config" in result.stdout
|
||||
assert "Startup mode: root" not in result.stdout
|
||||
|
||||
@@ -381,6 +381,16 @@ class TestSecuritySettings:
|
||||
assert "inactive" in hint.label.lower()
|
||||
assert "local admin" in hint.label.lower()
|
||||
|
||||
def test_oidc_admin_requirement_hint_absent_when_local_auth_is_disabled(self):
|
||||
"""OIDC mode should not show the local-admin warning when local auth is disabled."""
|
||||
from shelfmark.config.security import security_settings
|
||||
|
||||
with patch("shelfmark.config.env.DISABLE_LOCAL_AUTH", True):
|
||||
fields = security_settings()
|
||||
|
||||
hint = next((f for f in fields if f.key == "oidc_admin_requirement"), None)
|
||||
assert hint is None
|
||||
|
||||
def test_builtin_option_label_is_local(self):
|
||||
"""Builtin auth option should be labeled Local."""
|
||||
from shelfmark.config.security import security_settings
|
||||
@@ -429,6 +439,28 @@ class TestSecurityOnSave:
|
||||
assert result["error"] is True
|
||||
assert "local admin" in result["message"].lower()
|
||||
|
||||
def test_on_save_allows_oidc_without_local_admin_when_local_auth_is_disabled(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
_set_config_dir(monkeypatch, tmp_path)
|
||||
UserDB(str(tmp_path / "users.db")).initialize()
|
||||
|
||||
with patch("shelfmark.config.security_handlers.DISABLE_LOCAL_AUTH", True):
|
||||
result = _on_save_security(
|
||||
{
|
||||
"AUTH_METHOD": "oidc",
|
||||
"OIDC_DISCOVERY_URL": (
|
||||
"https://auth.example.com/.well-known/openid-configuration"
|
||||
),
|
||||
"OIDC_CLIENT_ID": "shelfmark",
|
||||
"OIDC_CLIENT_SECRET": "secret123",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["error"] is False
|
||||
|
||||
def test_on_save_blocks_oidc_when_client_id_is_missing(self, tmp_path, monkeypatch):
|
||||
from shelfmark.config.security import _on_save_security
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
TOR_SCRIPT_PATH = Path(__file__).resolve().parents[2] / "tor.sh"
|
||||
|
||||
|
||||
def _generated_tor_healthcheck_script() -> str:
|
||||
script = TOR_SCRIPT_PATH.read_text()
|
||||
start = script.index("cat <<'HC' > /app/tor_healthcheck.sh")
|
||||
content_start = script.index("\n", start) + 1
|
||||
content_end = script.index("\nHC", content_start)
|
||||
return script[content_start:content_end]
|
||||
|
||||
|
||||
def _tor_script_rule_lines() -> list[str]:
|
||||
return [
|
||||
line.strip()
|
||||
for line in TOR_SCRIPT_PATH.read_text().splitlines()
|
||||
if line.strip().startswith("iptables ")
|
||||
]
|
||||
|
||||
|
||||
def _line_index(lines: list[str], needle: str) -> int:
|
||||
return next(index for index, line in enumerate(lines) if needle in line)
|
||||
|
||||
|
||||
def test_tor_nat_rules_bypass_private_networks_before_tcp_redirect():
|
||||
lines = _tor_script_rule_lines()
|
||||
tcp_redirect_index = _line_index(lines, "--syn -j REDIRECT --to-ports 9040")
|
||||
|
||||
for cidr in ("127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"):
|
||||
rule_index = _line_index(lines, f"-d {cidr} -j RETURN")
|
||||
assert rule_index < tcp_redirect_index
|
||||
|
||||
|
||||
def test_tor_nat_rules_exempt_tor_process_before_dns_and_tcp_redirects():
|
||||
lines = _tor_script_rule_lines()
|
||||
|
||||
owner_index = _line_index(lines, "-m owner --uid-owner")
|
||||
udp_dns_index = _line_index(lines, "-p udp --dport 53")
|
||||
tcp_dns_index = _line_index(lines, "-p tcp --dport 53")
|
||||
tcp_redirect_index = _line_index(lines, "--syn -j REDIRECT --to-ports 9040")
|
||||
|
||||
assert owner_index < udp_dns_index
|
||||
assert owner_index < tcp_dns_index
|
||||
assert owner_index < tcp_redirect_index
|
||||
|
||||
|
||||
def test_tor_nat_rules_handle_dns_before_tcp_redirect():
|
||||
lines = _tor_script_rule_lines()
|
||||
|
||||
tcp_redirect_index = _line_index(lines, "--syn -j REDIRECT --to-ports 9040")
|
||||
|
||||
assert _line_index(lines, "-p udp --dport 53") < tcp_redirect_index
|
||||
assert _line_index(lines, "-p tcp --dport 53") < tcp_redirect_index
|
||||
|
||||
|
||||
def test_tor_healthcheck_uses_local_tor_state_without_clear_net_probe():
|
||||
healthcheck_script = _generated_tor_healthcheck_script()
|
||||
|
||||
assert "google.com" not in healthcheck_script
|
||||
assert "curl " not in healthcheck_script
|
||||
assert "supervisorctl status tor" in healthcheck_script
|
||||
assert "Bootstrapped 100%" in healthcheck_script
|
||||
@@ -126,6 +126,21 @@ class TestLoginSemantics:
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()["error"] == "Local authentication is disabled"
|
||||
|
||||
@pytest.mark.parametrize("auth_mode", ["builtin", "oidc"])
|
||||
def test_login_rejects_password_auth_when_local_auth_is_disabled(
|
||||
self, main_module, client, auth_mode
|
||||
):
|
||||
with patch.object(main_module, "get_auth_mode", return_value=auth_mode):
|
||||
with patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": "alice", "password": "wrong", "remember_me": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()["error"] == "Local authentication is disabled"
|
||||
assert main_module.failed_login_attempts == {}
|
||||
|
||||
def test_auth_check_none_mode_reports_full_access(self, main_module, client):
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
response = client.get("/api/auth/check")
|
||||
@@ -138,6 +153,19 @@ class TestLoginSemantics:
|
||||
"is_admin": True,
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("auth_mode", ["builtin", "oidc"])
|
||||
def test_auth_check_hides_local_auth_when_disabled(self, main_module, client, auth_mode):
|
||||
with patch.object(main_module, "get_auth_mode", return_value=auth_mode):
|
||||
with patch.object(main_module, "DISABLE_LOCAL_AUTH", True):
|
||||
response = client.get("/api/auth/check")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.get_json()
|
||||
assert body["auth_mode"] == auth_mode
|
||||
assert body["auth_required"] is True
|
||||
assert body["authenticated"] is False
|
||||
assert body["hide_local_auth"] is True
|
||||
|
||||
def test_auth_check_includes_display_name_for_authenticated_user(
|
||||
self, main_module, client, temp_user_db, monkeypatch
|
||||
):
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Cover proxy API security tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def main_module():
|
||||
"""Import `shelfmark.main` with background startup disabled."""
|
||||
with patch("shelfmark.download.orchestrator.start"):
|
||||
import shelfmark.main as main
|
||||
|
||||
importlib.reload(main)
|
||||
return main
|
||||
|
||||
|
||||
def test_cover_proxy_requires_authentication(main_module) -> None:
|
||||
client = main_module.app.test_client()
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
response = client.get("/api/covers/test-id")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.get_json() == {"error": "Unauthorized"}
|
||||
@@ -856,3 +856,204 @@ class TestStatusEndpointGuardrails:
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert observed["user_id"] is None
|
||||
|
||||
|
||||
class TestQueueManagementEndpointGuardrails:
|
||||
def test_non_owner_cannot_set_priority(self, main_module, client):
|
||||
owner = _create_user(main_module, prefix="owner")
|
||||
actor = _create_user(main_module, prefix="actor")
|
||||
_set_authenticated_session(
|
||||
client,
|
||||
user_id=actor["username"],
|
||||
db_user_id=actor["id"],
|
||||
is_admin=False,
|
||||
)
|
||||
task = DownloadTask(
|
||||
task_id="owned-priority-1",
|
||||
source="direct_download",
|
||||
title="Owned Task",
|
||||
user_id=owner["id"],
|
||||
username=owner["username"],
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
|
||||
with patch.object(main_module.backend, "set_book_priority") as mock_set_priority:
|
||||
resp = client.put("/api/queue/owned-priority-1/priority", json={"priority": 1})
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()["code"] == "download_not_owned"
|
||||
mock_set_priority.assert_not_called()
|
||||
|
||||
def test_owner_can_set_priority(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_authenticated_session(
|
||||
client,
|
||||
user_id=user["username"],
|
||||
db_user_id=user["id"],
|
||||
is_admin=False,
|
||||
)
|
||||
task = DownloadTask(
|
||||
task_id="reader-priority-1",
|
||||
source="direct_download",
|
||||
title="Reader Task",
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
|
||||
with patch.object(
|
||||
main_module.backend, "set_book_priority", return_value=True
|
||||
) as mock_set_priority:
|
||||
resp = client.put("/api/queue/reader-priority-1/priority", json={"priority": 2})
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {
|
||||
"status": "updated",
|
||||
"book_id": "reader-priority-1",
|
||||
"priority": 2,
|
||||
}
|
||||
mock_set_priority.assert_called_once_with("reader-priority-1", 2)
|
||||
|
||||
def test_non_owner_cannot_reorder_other_users_task(self, main_module, client):
|
||||
owner = _create_user(main_module, prefix="owner")
|
||||
actor = _create_user(main_module, prefix="actor")
|
||||
_set_authenticated_session(
|
||||
client,
|
||||
user_id=actor["username"],
|
||||
db_user_id=actor["id"],
|
||||
is_admin=False,
|
||||
)
|
||||
owned_task = DownloadTask(
|
||||
task_id="actor-reorder-1",
|
||||
source="direct_download",
|
||||
title="Actor Task",
|
||||
user_id=actor["id"],
|
||||
username=actor["username"],
|
||||
)
|
||||
other_task = DownloadTask(
|
||||
task_id="owner-reorder-1",
|
||||
source="direct_download",
|
||||
title="Owner Task",
|
||||
user_id=owner["id"],
|
||||
username=owner["username"],
|
||||
)
|
||||
|
||||
def fake_get_task(task_id):
|
||||
return {
|
||||
"actor-reorder-1": owned_task,
|
||||
"owner-reorder-1": other_task,
|
||||
}.get(task_id)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(
|
||||
main_module.backend.book_queue, "get_task", side_effect=fake_get_task
|
||||
):
|
||||
with patch.object(main_module.backend, "reorder_queue") as mock_reorder:
|
||||
resp = client.post(
|
||||
"/api/queue/reorder",
|
||||
json={
|
||||
"book_priorities": {
|
||||
"actor-reorder-1": 1,
|
||||
"owner-reorder-1": 0,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
assert resp.status_code == 403
|
||||
assert resp.get_json()["code"] == "download_not_owned"
|
||||
mock_reorder.assert_not_called()
|
||||
|
||||
def test_non_admin_queue_order_is_scoped_to_owned_tasks(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
other = _create_user(main_module, prefix="other")
|
||||
_set_authenticated_session(
|
||||
client,
|
||||
user_id=user["username"],
|
||||
db_user_id=user["id"],
|
||||
is_admin=False,
|
||||
)
|
||||
user_task = DownloadTask(
|
||||
task_id="reader-order-1",
|
||||
source="direct_download",
|
||||
title="Reader Task",
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
)
|
||||
other_task = DownloadTask(
|
||||
task_id="other-order-1",
|
||||
source="direct_download",
|
||||
title="Other Task",
|
||||
user_id=other["id"],
|
||||
username=other["username"],
|
||||
)
|
||||
|
||||
def fake_get_task(task_id):
|
||||
return {
|
||||
"reader-order-1": user_task,
|
||||
"other-order-1": other_task,
|
||||
}.get(task_id)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(
|
||||
main_module.backend,
|
||||
"get_queue_order",
|
||||
return_value=[
|
||||
{"id": "reader-order-1", "title": "Reader Task", "priority": 0},
|
||||
{"id": "other-order-1", "title": "Other Task", "priority": 1},
|
||||
],
|
||||
):
|
||||
with patch.object(
|
||||
main_module.backend.book_queue, "get_task", side_effect=fake_get_task
|
||||
):
|
||||
resp = client.get("/api/queue/order")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["queue"] == [
|
||||
{"id": "reader-order-1", "title": "Reader Task", "priority": 0}
|
||||
]
|
||||
|
||||
def test_non_admin_active_downloads_are_scoped_to_owned_tasks(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
other = _create_user(main_module, prefix="other")
|
||||
_set_authenticated_session(
|
||||
client,
|
||||
user_id=user["username"],
|
||||
db_user_id=user["id"],
|
||||
is_admin=False,
|
||||
)
|
||||
user_task = DownloadTask(
|
||||
task_id="reader-active-1",
|
||||
source="direct_download",
|
||||
title="Reader Task",
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
)
|
||||
other_task = DownloadTask(
|
||||
task_id="other-active-1",
|
||||
source="direct_download",
|
||||
title="Other Task",
|
||||
user_id=other["id"],
|
||||
username=other["username"],
|
||||
)
|
||||
|
||||
def fake_get_task(task_id):
|
||||
return {
|
||||
"reader-active-1": user_task,
|
||||
"other-active-1": other_task,
|
||||
}.get(task_id)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(
|
||||
main_module.backend,
|
||||
"get_active_downloads",
|
||||
return_value=["reader-active-1", "other-active-1"],
|
||||
):
|
||||
with patch.object(
|
||||
main_module.backend.book_queue, "get_task", side_effect=fake_get_task
|
||||
):
|
||||
resp = client.get("/api/downloads/active")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json() == {"active_downloads": ["reader-active-1"]}
|
||||
|
||||
@@ -240,6 +240,26 @@ class TestAtomicCopy:
|
||||
assert result.exists()
|
||||
assert result.read_text() == "content"
|
||||
|
||||
def test_copy_falls_back_when_copy2_hits_fuse_eio(self, tmp_path):
|
||||
"""Fall back to content copy when FUSE rejects xattr metadata reads."""
|
||||
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"
|
||||
|
||||
with patch(
|
||||
"shelfmark.download.fs.shutil.copy2",
|
||||
side_effect=OSError(errno.EIO, "Input/output error"),
|
||||
):
|
||||
result = _atomic_copy(source, dest)
|
||||
|
||||
assert result == dest
|
||||
assert result.exists()
|
||||
assert result.read_text() == "content"
|
||||
|
||||
def test_copy_tolerates_post_publish_estale(self, tmp_path, monkeypatch):
|
||||
"""Treat ESTALE on the final destination as a successful NFS publish."""
|
||||
import errno
|
||||
|
||||
@@ -28,7 +28,6 @@ def _run_organize_post_process(
|
||||
task,
|
||||
library: Path,
|
||||
hardlink_enabled: bool = True,
|
||||
same_fs: bool = True,
|
||||
):
|
||||
from shelfmark.download.postprocess.router import (
|
||||
post_process_download as _post_process_download,
|
||||
@@ -37,10 +36,7 @@ def _run_organize_post_process(
|
||||
status_cb = MagicMock()
|
||||
cancel_flag = Event()
|
||||
|
||||
with (
|
||||
patch("shelfmark.core.config.config") as mock_config,
|
||||
patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=same_fs),
|
||||
):
|
||||
with patch("shelfmark.core.config.config") as mock_config:
|
||||
mock_config.CUSTOM_SCRIPT = None
|
||||
mock_config.get = MagicMock(
|
||||
side_effect=lambda key, default=None, **_kwargs: {
|
||||
@@ -407,6 +403,32 @@ class TestAtomicMove:
|
||||
assert mock_copy.called
|
||||
assert mock_fallback.called
|
||||
|
||||
def test_cross_filesystem_move_falls_back_when_copy2_hits_fuse_eio(self, tmp_path, monkeypatch):
|
||||
"""Falls back to content copy when FUSE rejects xattr metadata reads."""
|
||||
import errno
|
||||
|
||||
from shelfmark.download.fs import atomic_move as _atomic_move
|
||||
|
||||
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")
|
||||
|
||||
monkeypatch.setattr(os, "rename", _raise_exdev)
|
||||
|
||||
with patch(
|
||||
"shelfmark.download.fs.shutil.copy2",
|
||||
side_effect=OSError(errno.EIO, "Input/output error"),
|
||||
):
|
||||
result = _atomic_move(source, dest)
|
||||
|
||||
assert result == dest
|
||||
assert not source.exists()
|
||||
assert dest.exists()
|
||||
assert dest.read_text() == "content"
|
||||
|
||||
def test_cross_filesystem_move_recovers_when_metadata_step_hits_enoent(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
@@ -696,7 +718,6 @@ class TestHardlinkDecisionLogic:
|
||||
task=sample_task,
|
||||
library=library,
|
||||
hardlink_enabled=True,
|
||||
same_fs=True,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
@@ -750,6 +771,29 @@ class TestHardlinkDecisionLogic:
|
||||
assert result is not None
|
||||
assert not staged.exists()
|
||||
|
||||
def test_non_prowlarr_torrent_with_original_path_can_hardlink(self, tmp_path, sample_task):
|
||||
"""Torrent-backed sources such as AudiobookBay can hardlink client files."""
|
||||
library = tmp_path / "library"
|
||||
library.mkdir()
|
||||
source = tmp_path / "downloads" / "book.m4b"
|
||||
source.parent.mkdir()
|
||||
source.write_bytes(b"content")
|
||||
|
||||
sample_task.source = "audiobookbay"
|
||||
sample_task.content_type = "audiobook"
|
||||
sample_task.format = "m4b"
|
||||
sample_task.original_download_path = str(source)
|
||||
|
||||
result, _ = _run_organize_post_process(
|
||||
temp_file=source,
|
||||
task=sample_task,
|
||||
library=library,
|
||||
hardlink_enabled=True,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert Path(result).stat().st_ino == source.stat().st_ino
|
||||
|
||||
|
||||
class TestHardlinkInodeVerification:
|
||||
"""Tests that verify hardlinks share the same inode."""
|
||||
@@ -1321,7 +1365,7 @@ class TestTorrentSourceCleanupProtection:
|
||||
from shelfmark.download.postprocess.pipeline import transfer_file_to_library
|
||||
|
||||
# Simulate by directly calling transfer_file_to_library with use_hardlink=False
|
||||
# (this is what happens after same_filesystem check fails)
|
||||
# (this is what happens when hardlinking is disabled before transfer)
|
||||
downloads = tmp_path / "downloads"
|
||||
downloads.mkdir()
|
||||
torrent_file = downloads / "book.epub"
|
||||
|
||||
@@ -5,15 +5,67 @@ import requests
|
||||
from shelfmark.core.image_cache import ImageCacheService
|
||||
|
||||
|
||||
def test_fetch_and_cache_rejects_backslash_authority_bypass_before_request(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
cache = ImageCacheService(tmp_path)
|
||||
calls = []
|
||||
|
||||
def fake_get(url, **_kwargs):
|
||||
calls.append(url)
|
||||
raise AssertionError("unsafe URL should not be requested")
|
||||
|
||||
monkeypatch.setattr("shelfmark.core.image_cache.requests.get", fake_get)
|
||||
|
||||
assert cache.fetch_and_cache("cover-ssrf", "http://127.0.0.1:6666\\@1.1.1.1") is None
|
||||
assert calls == []
|
||||
assert "cover-ssrf" not in cache._index
|
||||
|
||||
|
||||
def test_is_safe_url_rejects_encoded_separator_in_authority() -> None:
|
||||
assert ImageCacheService._is_safe_url("http://127.0.0.1:6666%5c@1.1.1.1") is False
|
||||
assert ImageCacheService._is_safe_url("http://127.0.0.1:6666%2f@1.1.1.1") is False
|
||||
|
||||
|
||||
def test_is_safe_url_rejects_invalid_ipv6_url() -> None:
|
||||
assert ImageCacheService._is_safe_url("http://[") is False
|
||||
|
||||
|
||||
def test_fetch_and_cache_blocks_unsafe_redirect(tmp_path, monkeypatch) -> None:
|
||||
cache = ImageCacheService(tmp_path)
|
||||
|
||||
def fake_getaddrinfo(hostname, *_args, **_kwargs):
|
||||
addresses = {
|
||||
"example.com": "93.184.216.34",
|
||||
"127.0.0.1": "127.0.0.1",
|
||||
}
|
||||
return [(None, None, None, None, (addresses[hostname], 0))]
|
||||
|
||||
class RedirectResponse:
|
||||
is_redirect = True
|
||||
headers = {"location": "http://127.0.0.1/cover.jpg"}
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_get(url, **_kwargs):
|
||||
calls.append(url)
|
||||
return RedirectResponse()
|
||||
|
||||
monkeypatch.setattr("shelfmark.core.image_cache.socket.getaddrinfo", fake_getaddrinfo)
|
||||
monkeypatch.setattr("shelfmark.core.image_cache.requests.get", fake_get)
|
||||
|
||||
assert cache.fetch_and_cache("cover-redirect", "https://example.com/cover.jpg") is None
|
||||
assert calls == ["https://example.com/cover.jpg"]
|
||||
assert "cover-redirect" not in cache._index
|
||||
|
||||
|
||||
def test_fetch_and_cache_returns_none_on_request_exception(tmp_path, monkeypatch) -> None:
|
||||
cache = ImageCacheService(tmp_path)
|
||||
monkeypatch.setattr(cache, "_is_safe_url", lambda _url: True)
|
||||
|
||||
def fake_get(*args, **kwargs):
|
||||
def fake_get(*_args, **_kwargs):
|
||||
raise requests.exceptions.TooManyRedirects("too many redirects")
|
||||
|
||||
monkeypatch.setattr("shelfmark.core.image_cache.requests.get", fake_get)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.auth_modes import (
|
||||
determine_auth_mode,
|
||||
get_auth_check_admin_status,
|
||||
@@ -63,6 +65,31 @@ class TestDetermineAuthMode:
|
||||
}
|
||||
assert determine_auth_mode(config, cwa_db_path=None, has_local_admin=False) == "none"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("auth_mode", "config"),
|
||||
[
|
||||
("builtin", {"AUTH_METHOD": "builtin"}),
|
||||
(
|
||||
"oidc",
|
||||
{
|
||||
"AUTH_METHOD": "oidc",
|
||||
"OIDC_DISCOVERY_URL": "https://auth.example.com/.well-known/openid-configuration",
|
||||
"OIDC_CLIENT_ID": "shelfmark",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_disable_local_auth_keeps_configured_mode_without_admin(self, auth_mode, config):
|
||||
assert (
|
||||
determine_auth_mode(
|
||||
config,
|
||||
cwa_db_path=None,
|
||||
has_local_admin=False,
|
||||
disable_local_auth=True,
|
||||
)
|
||||
== auth_mode
|
||||
)
|
||||
|
||||
def test_load_active_auth_mode_reads_env_backed_cwa_setting(self, monkeypatch, tmp_path):
|
||||
from shelfmark.core.config import config as app_config
|
||||
|
||||
|
||||
@@ -445,6 +445,7 @@ class TestOIDCCallbackEndpoint:
|
||||
"userinfo": {
|
||||
"sub": "oidc-alice-sub",
|
||||
"email": "alice@example.com",
|
||||
"email_verified": True,
|
||||
"preferred_username": "alice_oidc",
|
||||
"groups": [],
|
||||
}
|
||||
@@ -548,6 +549,7 @@ class TestOIDCCallbackEndpoint:
|
||||
"userinfo": {
|
||||
"sub": "oidc-new-sub",
|
||||
"email": "shared@example.com",
|
||||
"email_verified": True,
|
||||
"preferred_username": "oidcuser",
|
||||
"groups": [],
|
||||
}
|
||||
@@ -574,6 +576,7 @@ class TestOIDCCallbackEndpoint:
|
||||
"userinfo": {
|
||||
"sub": "oidc-nomatch",
|
||||
"email": "different@example.com",
|
||||
"email_verified": True,
|
||||
"preferred_username": "newuser",
|
||||
"groups": [],
|
||||
}
|
||||
@@ -589,6 +592,60 @@ class TestOIDCCallbackEndpoint:
|
||||
original = user_db.get_user(username="existing")
|
||||
assert original["oidc_subject"] is None
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_does_not_link_with_unverified_email(self, mock_get_client, client, user_db):
|
||||
"""OIDC login should not link by email when email_verified is false."""
|
||||
user_db.create_user(username="existing", email="shared@example.com", password_hash="hash")
|
||||
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.return_value = {
|
||||
"userinfo": {
|
||||
"sub": "oidc-unverified",
|
||||
"email": "shared@example.com",
|
||||
"email_verified": False,
|
||||
"preferred_username": "attackeruser",
|
||||
"groups": [],
|
||||
}
|
||||
}
|
||||
mock_get_client.return_value = (fake_client, MOCK_OIDC_CONFIG)
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
assert resp.status_code == 302
|
||||
|
||||
with client.session_transaction() as sess:
|
||||
assert sess["user_id"] == "attackeruser"
|
||||
|
||||
original = user_db.get_user(username="existing")
|
||||
assert original["oidc_subject"] is None
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_rejects_unverified_email_link_when_no_provision(
|
||||
self, mock_get_client, client, user_db
|
||||
):
|
||||
"""OIDC login should not link by unverified email when creation is disabled."""
|
||||
config = {**MOCK_OIDC_CONFIG, "OIDC_AUTO_PROVISION": False}
|
||||
user_db.create_user(username="existing", email="shared@example.com", password_hash="hash")
|
||||
|
||||
fake_client = Mock()
|
||||
fake_client.authorize_access_token.return_value = {
|
||||
"userinfo": {
|
||||
"sub": "oidc-unverified-no-provision",
|
||||
"email": "shared@example.com",
|
||||
"email_verified": False,
|
||||
"preferred_username": "attackeruser",
|
||||
"groups": [],
|
||||
}
|
||||
}
|
||||
mock_get_client.return_value = (fake_client, config)
|
||||
|
||||
resp = client.get("/api/auth/oidc/callback?code=abc123&state=test-state")
|
||||
error = _get_oidc_error(resp)
|
||||
assert error is not None
|
||||
assert "Account not found" in error
|
||||
|
||||
original = user_db.get_user(username="existing")
|
||||
assert original["oidc_subject"] is None
|
||||
|
||||
@patch("shelfmark.core.oidc_routes._get_oidc_client")
|
||||
def test_callback_no_email_link_when_oidc_has_no_email(self, mock_get_client, client, user_db):
|
||||
"""OIDC login without email in claims should not attempt email linking."""
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from shelfmark.core.path_mappings import (
|
||||
RemotePathMapping,
|
||||
remap_remote_to_local_with_match,
|
||||
)
|
||||
|
||||
|
||||
def test_remap_rejects_parent_directory_remainder(tmp_path):
|
||||
mapping = RemotePathMapping(
|
||||
host="qbittorrent",
|
||||
remote_path="/remote/downloads",
|
||||
local_path=str(tmp_path / "local" / "downloads"),
|
||||
)
|
||||
remote_path = "/remote/downloads/../outside/book.epub"
|
||||
|
||||
remapped, matched = remap_remote_to_local_with_match(
|
||||
mappings=[mapping],
|
||||
host="qbittorrent",
|
||||
remote_path=remote_path,
|
||||
)
|
||||
|
||||
assert matched is True
|
||||
assert remapped is None
|
||||
|
||||
|
||||
def test_remap_rejects_path_that_resolves_outside_local_prefix(tmp_path):
|
||||
local_prefix = tmp_path / "local" / "downloads"
|
||||
mapping = RemotePathMapping(
|
||||
host="qbittorrent",
|
||||
remote_path="/remote/downloads",
|
||||
local_path=str(local_prefix),
|
||||
)
|
||||
remote_path = "/remote/downloads/subdir/../../outside/book.epub"
|
||||
|
||||
remapped, matched = remap_remote_to_local_with_match(
|
||||
mappings=[mapping],
|
||||
host="qbittorrent",
|
||||
remote_path=remote_path,
|
||||
)
|
||||
|
||||
assert matched is True
|
||||
assert remapped is None
|
||||
|
||||
|
||||
def test_remap_allows_normal_child_path_under_local_prefix(tmp_path):
|
||||
local_prefix = tmp_path / "local" / "downloads"
|
||||
mapping = RemotePathMapping(
|
||||
host="qbittorrent",
|
||||
remote_path="/remote/downloads",
|
||||
local_path=str(local_prefix),
|
||||
)
|
||||
|
||||
remapped, matched = remap_remote_to_local_with_match(
|
||||
mappings=[mapping],
|
||||
host="qbittorrent",
|
||||
remote_path="/remote/downloads/author/book.epub",
|
||||
)
|
||||
|
||||
assert matched is True
|
||||
assert remapped == local_prefix / "author" / "book.epub"
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Integration tests for real filesystem processing flows."""
|
||||
|
||||
import errno
|
||||
import os
|
||||
import zipfile
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from threading import Event
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -46,6 +48,15 @@ def _sync_config(mock_config, mock_core):
|
||||
mock_core.CUSTOM_SCRIPT = mock_config.CUSTOM_SCRIPT
|
||||
|
||||
|
||||
def _hardlink_support_patch(supported: bool):
|
||||
if supported:
|
||||
return nullcontext()
|
||||
return patch(
|
||||
"shelfmark.download.fs.os.link",
|
||||
side_effect=OSError(errno.EXDEV, "Invalid cross-device link"),
|
||||
)
|
||||
|
||||
|
||||
def test_direct_download_rename_moves_file(tmp_path):
|
||||
from shelfmark.download.postprocess.router import (
|
||||
post_process_download as _post_process_download,
|
||||
@@ -366,7 +377,7 @@ def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_pa
|
||||
with (
|
||||
patch("shelfmark.core.config.config") as mock_config,
|
||||
patch("shelfmark.config.env.TMP_DIR", staging),
|
||||
patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False),
|
||||
_hardlink_support_patch(False),
|
||||
):
|
||||
mock_config.get = _build_config(ingest, organization="none", hardlink=True)
|
||||
mock_config.CUSTOM_SCRIPT = None
|
||||
@@ -385,7 +396,7 @@ def test_torrent_hardlink_enabled_copy_fallback_does_not_extract_archives(tmp_pa
|
||||
# Most importantly: hardlink-setting-enabled fallback to copy should NOT extract.
|
||||
assert list(ingest.glob("*.epub")) == []
|
||||
|
||||
assert any(msg.startswith("Copying") for _, msg in statuses)
|
||||
assert any(msg.startswith("Hardlinking") for _, msg in statuses)
|
||||
|
||||
|
||||
def test_torrent_hardlink_enabled_copy_fallback_directory_archive_kept_when_zip_supported(tmp_path):
|
||||
@@ -422,7 +433,7 @@ def test_torrent_hardlink_enabled_copy_fallback_directory_archive_kept_when_zip_
|
||||
with (
|
||||
patch("shelfmark.core.config.config") as mock_config,
|
||||
patch("shelfmark.config.env.TMP_DIR", staging),
|
||||
patch("shelfmark.download.postprocess.transfer.same_filesystem", return_value=False),
|
||||
_hardlink_support_patch(False),
|
||||
):
|
||||
mock_config.get = _build_config(
|
||||
ingest,
|
||||
@@ -1028,20 +1039,20 @@ def test_postprocess_folder_blackbox_matrix(
|
||||
@pytest.mark.parametrize("content_kind", ["book", "audiobook"])
|
||||
@pytest.mark.parametrize("organization", ["none", "organize"])
|
||||
@pytest.mark.parametrize("hardlink_enabled", [False, True])
|
||||
@pytest.mark.parametrize("same_filesystem", [True, False])
|
||||
@pytest.mark.parametrize("hardlink_supported", [True, False])
|
||||
def test_postprocess_torrent_blackbox_matrix(
|
||||
tmp_path,
|
||||
input_kind: str,
|
||||
content_kind: str,
|
||||
organization: str,
|
||||
hardlink_enabled: bool,
|
||||
same_filesystem: bool,
|
||||
hardlink_supported: bool,
|
||||
):
|
||||
"""Torrent-like (original_download_path set) black-box test matrix.
|
||||
|
||||
This exercises:
|
||||
- hardlink enabled/disabled
|
||||
- same-filesystem hardlink vs copy fallback
|
||||
- successful hardlink vs copy fallback
|
||||
- content type differences (book vs audiobook)
|
||||
|
||||
Assertions focus on invariants:
|
||||
@@ -1087,7 +1098,7 @@ def test_postprocess_torrent_blackbox_matrix(
|
||||
source_file.write_text("content")
|
||||
|
||||
task = DownloadTask(
|
||||
task_id=f"torrent-matrix-{input_kind}-{content_kind}-{organization}-{hardlink_enabled}-{same_filesystem}",
|
||||
task_id=f"torrent-matrix-{input_kind}-{content_kind}-{organization}-{hardlink_enabled}-{hardlink_supported}",
|
||||
source="prowlarr",
|
||||
title=title,
|
||||
author=author,
|
||||
@@ -1100,9 +1111,7 @@ def test_postprocess_torrent_blackbox_matrix(
|
||||
with (
|
||||
patch("shelfmark.core.config.config") as mock_config,
|
||||
patch("shelfmark.config.env.TMP_DIR", staging),
|
||||
patch(
|
||||
"shelfmark.download.postprocess.transfer.same_filesystem", return_value=same_filesystem
|
||||
),
|
||||
_hardlink_support_patch(hardlink_supported),
|
||||
):
|
||||
mock_config.get = _build_config(
|
||||
ingest,
|
||||
@@ -1131,8 +1140,8 @@ def test_postprocess_torrent_blackbox_matrix(
|
||||
assert result_path.parent == ingest
|
||||
assert result_path.name == f"random.{extension}"
|
||||
|
||||
# Hardlink only when enabled and same filesystem.
|
||||
if hardlink_enabled and same_filesystem:
|
||||
# Hardlink only when enabled and supported by the filesystem.
|
||||
if hardlink_enabled and hardlink_supported:
|
||||
assert os.stat(source_file).st_ino == os.stat(result_path).st_ino
|
||||
else:
|
||||
assert os.stat(source_file).st_ino != os.stat(result_path).st_ino
|
||||
|
||||
@@ -354,6 +354,166 @@ class TestRequestRoutes:
|
||||
mock_notify_admin.assert_not_called()
|
||||
mock_notify_user.assert_not_called()
|
||||
|
||||
def test_download_policy_rejects_mismatched_context_and_release_source(
|
||||
self, main_module, client
|
||||
):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
policy = _policy(
|
||||
default_ebook="download",
|
||||
rules=[{"source": "direct_download", "content_type": "*", "mode": "blocked"}],
|
||||
)
|
||||
|
||||
payload = {
|
||||
"book_data": {
|
||||
"title": "Policy Source Mismatch",
|
||||
"author": "Shelfmark",
|
||||
"content_type": "ebook",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "policy-source-mismatch-1",
|
||||
},
|
||||
"context": {
|
||||
"source": "prowlarr",
|
||||
"content_type": "ebook",
|
||||
"request_level": "release",
|
||||
},
|
||||
"release_data": {
|
||||
"source": "direct_download",
|
||||
"source_id": "blocked-release-1",
|
||||
"title": "Blocked Release.epub",
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
):
|
||||
with patch.object(main_module.backend, "queue_release") as mock_queue:
|
||||
resp = client.post("/api/requests", json=payload)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json["code"] == "policy_source_mismatch"
|
||||
assert resp.json["error"] == "Policy context source must match release_data.source"
|
||||
assert main_module.user_db.list_requests(user_id=user["id"]) == []
|
||||
mock_queue.assert_not_called()
|
||||
|
||||
def test_release_result_source_rejects_mismatch_before_normalization(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
policy = _policy(
|
||||
default_ebook="download",
|
||||
rules=[{"source": "prowlarr", "content_type": "*", "mode": "blocked"}],
|
||||
)
|
||||
|
||||
payload = {
|
||||
"book_data": {
|
||||
"title": "Release Result Source Mismatch",
|
||||
"author": "Shelfmark",
|
||||
"content_type": "ebook",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "release-result-mismatch-1",
|
||||
},
|
||||
"context": {
|
||||
"source": "direct_download",
|
||||
"content_type": "ebook",
|
||||
"request_level": "release",
|
||||
},
|
||||
"release_data": {
|
||||
"source": "prowlarr",
|
||||
"source_id": "blocked-prowlarr-release-1",
|
||||
"title": "Blocked Prowlarr Release.epub",
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
):
|
||||
with patch.object(main_module.backend, "queue_release") as mock_queue:
|
||||
resp = client.post("/api/requests", json=payload)
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json["code"] == "policy_source_mismatch"
|
||||
assert resp.json["error"] == "Policy context source must match release_data.source"
|
||||
assert main_module.user_db.list_requests(user_id=user["id"]) == []
|
||||
mock_queue.assert_not_called()
|
||||
|
||||
def test_batch_rejects_release_result_source_mismatch_before_creating_any_requests(
|
||||
self, main_module, client
|
||||
):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
policy = _policy(
|
||||
default_ebook="request_release",
|
||||
rules=[{"source": "prowlarr", "content_type": "*", "mode": "blocked"}],
|
||||
)
|
||||
|
||||
payloads = [
|
||||
{
|
||||
"book_data": {
|
||||
"title": "Batch Valid Direct",
|
||||
"author": "Shelfmark",
|
||||
"content_type": "ebook",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "batch-valid-direct-1",
|
||||
},
|
||||
"context": {
|
||||
"source": "direct_download",
|
||||
"content_type": "ebook",
|
||||
"request_level": "release",
|
||||
},
|
||||
"release_data": {
|
||||
"source": "direct_download",
|
||||
"source_id": "batch-valid-direct-release-1",
|
||||
"title": "Batch Valid Direct.epub",
|
||||
},
|
||||
},
|
||||
{
|
||||
"book_data": {
|
||||
"title": "Batch Release Result Mismatch",
|
||||
"author": "Shelfmark",
|
||||
"content_type": "ebook",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "batch-release-result-mismatch-1",
|
||||
},
|
||||
"context": {
|
||||
"source": "direct_download",
|
||||
"content_type": "ebook",
|
||||
"request_level": "release",
|
||||
},
|
||||
"release_data": {
|
||||
"source": "prowlarr",
|
||||
"source_id": "batch-blocked-prowlarr-release-1",
|
||||
"title": "Batch Blocked Prowlarr.epub",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
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,
|
||||
):
|
||||
with patch.object(main_module.backend, "queue_release") as mock_queue:
|
||||
resp = client.post("/api/requests/batch", json={"requests": payloads})
|
||||
|
||||
assert resp.status_code == 400
|
||||
assert resp.json["code"] == "policy_source_mismatch"
|
||||
assert resp.json["error"] == "Policy context source must match release_data.source"
|
||||
assert main_module.user_db.list_requests(user_id=user["id"]) == []
|
||||
mock_queue.assert_not_called()
|
||||
|
||||
def test_batch_download_policy_queues_releases_without_creating_requests(
|
||||
self, main_module, client
|
||||
):
|
||||
@@ -1532,6 +1692,12 @@ class TestRequestRoutes:
|
||||
def test_admin_fulfil_uses_real_queue_and_preserves_requesting_identity(
|
||||
self, main_module, client
|
||||
):
|
||||
class AvailableSource:
|
||||
display_name = "Direct Download"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
other_user = _create_user(main_module, prefix="reader")
|
||||
admin = _create_user(main_module, prefix="admin", role="admin")
|
||||
@@ -1567,13 +1733,23 @@ class TestRequestRoutes:
|
||||
"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"]
|
||||
with patch.object(
|
||||
main_module.backend,
|
||||
"get_source",
|
||||
return_value=AvailableSource(),
|
||||
):
|
||||
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
|
||||
)
|
||||
fulfil_resp = client.post(f"/api/admin/requests/{request_id}/fulfil", json={})
|
||||
_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={}
|
||||
)
|
||||
|
||||
assert fulfil_resp.status_code == 200
|
||||
assert fulfil_resp.json["status"] == "fulfilled"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from shelfmark.download import archive as archive_mod
|
||||
|
||||
|
||||
class _FakeZipInfo:
|
||||
filename = "book.epub"
|
||||
flag_bits = 0
|
||||
|
||||
def is_dir(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _ChunkOnlyStream:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self._content = content
|
||||
self._offset = 0
|
||||
self.whole_read_called = False
|
||||
|
||||
def __enter__(self) -> _ChunkOnlyStream:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
if size < 0:
|
||||
self.whole_read_called = True
|
||||
msg = "archive member was read into memory"
|
||||
raise AssertionError(msg)
|
||||
|
||||
chunk = self._content[self._offset : self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
|
||||
class _FakeZipFile:
|
||||
stream: _ChunkOnlyStream
|
||||
|
||||
def __init__(self, _path: Path, _mode: str) -> None:
|
||||
self.stream = _ChunkOnlyStream(b"streamed archive content")
|
||||
|
||||
def __enter__(self) -> _FakeZipFile:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def infolist(self) -> list[_FakeZipInfo]:
|
||||
return [_FakeZipInfo()]
|
||||
|
||||
def testzip(self) -> None:
|
||||
return None
|
||||
|
||||
def open(self, _info: _FakeZipInfo) -> _ChunkOnlyStream:
|
||||
return self.stream
|
||||
|
||||
|
||||
def test_extract_archive_raw_streams_members_without_whole_read(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
fake_archives: list[_FakeZipFile] = []
|
||||
|
||||
def fake_zip_file(path: Path, mode: str) -> _FakeZipFile:
|
||||
archive = _FakeZipFile(path, mode)
|
||||
fake_archives.append(archive)
|
||||
return archive
|
||||
|
||||
monkeypatch.setattr(archive_mod.zipfile, "ZipFile", fake_zip_file)
|
||||
|
||||
extracted_files, warnings = archive_mod.extract_archive_raw(tmp_path / "book.zip", tmp_path)
|
||||
|
||||
assert warnings == []
|
||||
assert [path.name for path in extracted_files] == ["book.epub"]
|
||||
assert extracted_files[0].read_bytes() == b"streamed archive content"
|
||||
assert fake_archives[0].stream.whole_read_called is False
|
||||
@@ -4,10 +4,26 @@ from pathlib import Path
|
||||
from threading import Event
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.models import DownloadTask, QueueStatus
|
||||
from shelfmark.core.queue import BookQueue
|
||||
|
||||
|
||||
class _AvailableSource:
|
||||
display_name = "Test Source"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def source_available_by_default(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource())
|
||||
|
||||
|
||||
def test_retry_download_requeues_error_task(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
from threading import Event
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from shelfmark.core.models import SearchMode
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.models import DownloadTask, SearchMode
|
||||
|
||||
|
||||
class _AvailableSource:
|
||||
display_name = "Test Source"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
|
||||
class _UnavailableSource:
|
||||
display_name = "Direct Download"
|
||||
|
||||
def is_available(self):
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def source_available_by_default(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource())
|
||||
|
||||
|
||||
def enable_prowlarr_seed_preferences(monkeypatch, orchestrator):
|
||||
monkeypatch.setattr(
|
||||
orchestrator.config,
|
||||
"get",
|
||||
lambda key, default=None, user_id=None: (
|
||||
True if key == "PROWLARR_USE_SEED_PREFERENCES" else default
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_queue_release_uses_user_specific_books_output_mode(monkeypatch):
|
||||
@@ -76,6 +110,28 @@ def test_queue_release_preserves_direct_search_mode_from_payload(monkeypatch):
|
||||
assert captured["task"].search_mode == SearchMode.DIRECT
|
||||
|
||||
|
||||
def test_queue_release_rejects_unavailable_source(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _UnavailableSource())
|
||||
monkeypatch.setattr(orchestrator.book_queue, "add", MagicMock())
|
||||
|
||||
success, error = orchestrator.queue_release(
|
||||
{
|
||||
"source": "direct_download",
|
||||
"source_id": "release-disabled-direct",
|
||||
"title": "Disabled Direct Release",
|
||||
"content_type": "ebook",
|
||||
},
|
||||
user_id=42,
|
||||
username="alice",
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert error == "Direct Download is unavailable. Enable and configure the source in Settings."
|
||||
orchestrator.book_queue.add.assert_not_called()
|
||||
|
||||
|
||||
def test_queue_release_email_mode_without_recipient_is_queued(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
@@ -114,6 +170,41 @@ def test_queue_release_email_mode_without_recipient_is_queued(monkeypatch):
|
||||
assert task.output_args == {}
|
||||
|
||||
|
||||
def test_download_task_rejects_unavailable_source_before_handler(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
task = DownloadTask(
|
||||
task_id="disabled-task",
|
||||
source="direct_download",
|
||||
title="Disabled Direct Release",
|
||||
)
|
||||
status_messages: list[tuple[str, str]] = []
|
||||
|
||||
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _UnavailableSource())
|
||||
monkeypatch.setattr(orchestrator, "get_handler", MagicMock())
|
||||
monkeypatch.setattr(orchestrator.book_queue, "get_task", lambda _task_id: task)
|
||||
monkeypatch.setattr(
|
||||
orchestrator.book_queue,
|
||||
"update_status_message",
|
||||
lambda task_id, message: status_messages.append((task_id, message)),
|
||||
)
|
||||
|
||||
result = orchestrator._download_task("disabled-task", Event())
|
||||
|
||||
assert result is None
|
||||
assert task.last_error_type == "SourceUnavailable"
|
||||
assert task.last_error_message == (
|
||||
"Direct Download is unavailable. Enable and configure the source in Settings."
|
||||
)
|
||||
assert status_messages == [
|
||||
(
|
||||
"disabled-task",
|
||||
"Direct Download is unavailable. Enable and configure the source in Settings.",
|
||||
)
|
||||
]
|
||||
orchestrator.get_handler.assert_not_called()
|
||||
|
||||
|
||||
def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
@@ -125,6 +216,7 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
|
||||
|
||||
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
|
||||
monkeypatch.setattr(orchestrator, "ws_manager", None)
|
||||
enable_prowlarr_seed_preferences(monkeypatch, orchestrator)
|
||||
|
||||
success, error = orchestrator.queue_release(
|
||||
{
|
||||
@@ -135,8 +227,8 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
|
||||
"protocol": "torrent",
|
||||
"indexer": "MyIndexer",
|
||||
"extra": {
|
||||
"minimum_ratio": 1.25,
|
||||
"minimum_seed_time": 5400,
|
||||
"configured_ratio_limit": 1.25,
|
||||
"configured_seed_time_minutes": 90,
|
||||
"info_hash": "ABC123",
|
||||
},
|
||||
},
|
||||
@@ -156,6 +248,114 @@ def test_queue_release_persists_generic_retry_resolution_fields(monkeypatch):
|
||||
assert task.can_retry_without_staged_source is True
|
||||
|
||||
|
||||
def test_queue_release_prefers_configured_seed_time_minutes_for_retry(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_add(task):
|
||||
captured["task"] = task
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
|
||||
monkeypatch.setattr(orchestrator, "ws_manager", None)
|
||||
enable_prowlarr_seed_preferences(monkeypatch, orchestrator)
|
||||
|
||||
success, error = orchestrator.queue_release(
|
||||
{
|
||||
"source": "prowlarr",
|
||||
"source_id": "prowlarr-release-configured-seed-time",
|
||||
"title": "Queued Prowlarr Release",
|
||||
"download_url": "magnet:?xt=urn:btih:abc123",
|
||||
"protocol": "torrent",
|
||||
"extra": {
|
||||
"configured_ratio_limit": 2,
|
||||
"configured_seed_time_minutes": 7200,
|
||||
"minimum_ratio": 1,
|
||||
"minimum_seed_time": 259200,
|
||||
},
|
||||
},
|
||||
user_id=42,
|
||||
username="alice",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
task = captured["task"]
|
||||
assert task.retry_ratio_limit == 2.0
|
||||
assert task.retry_seeding_time_limit_minutes == 7200
|
||||
|
||||
|
||||
def test_queue_release_ignores_configured_seed_time_when_disabled_for_retry(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_add(task):
|
||||
captured["task"] = task
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
|
||||
monkeypatch.setattr(orchestrator, "ws_manager", None)
|
||||
|
||||
success, error = orchestrator.queue_release(
|
||||
{
|
||||
"source": "prowlarr",
|
||||
"source_id": "prowlarr-release-configured-seed-time-disabled",
|
||||
"title": "Queued Prowlarr Release",
|
||||
"download_url": "magnet:?xt=urn:btih:abc123",
|
||||
"protocol": "torrent",
|
||||
"extra": {
|
||||
"configured_ratio_limit": 2,
|
||||
"configured_seed_time_minutes": 7200,
|
||||
},
|
||||
},
|
||||
user_id=42,
|
||||
username="alice",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
task = captured["task"]
|
||||
assert task.retry_ratio_limit is None
|
||||
assert task.retry_seeding_time_limit_minutes is None
|
||||
|
||||
|
||||
def test_queue_release_ignores_torznab_minimum_seed_criteria_for_retry(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_add(task):
|
||||
captured["task"] = task
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
|
||||
monkeypatch.setattr(orchestrator, "ws_manager", None)
|
||||
|
||||
success, error = orchestrator.queue_release(
|
||||
{
|
||||
"source": "prowlarr",
|
||||
"source_id": "prowlarr-release-minimum-only",
|
||||
"title": "Queued Prowlarr Release",
|
||||
"download_url": "magnet:?xt=urn:btih:abc123",
|
||||
"protocol": "torrent",
|
||||
"extra": {
|
||||
"minimum_ratio": 1,
|
||||
"minimum_seed_time": 259200,
|
||||
},
|
||||
},
|
||||
user_id=42,
|
||||
username="alice",
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
task = captured["task"]
|
||||
assert task.retry_ratio_limit is None
|
||||
assert task.retry_seeding_time_limit_minutes is None
|
||||
|
||||
|
||||
def test_queue_release_returns_error_for_operational_queue_failure(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
from ipaddress import IPv4Address
|
||||
|
||||
from shelfmark.release_sources.irc.client import IRCClient, IRCEvent, IRCMessage
|
||||
|
||||
|
||||
def _dcc_raw(sender: str, filename: str, ip: str, port: int = 443) -> str:
|
||||
ip_int = int(IPv4Address(ip))
|
||||
return f':{sender}!user@example.test PRIVMSG reader :\x01DCC SEND "{filename}" {ip_int} {port} 1\x01'
|
||||
|
||||
|
||||
def test_wait_for_dcc_ignores_unexpected_sender(monkeypatch) -> None:
|
||||
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
|
||||
client.online_servers = {"BookBot"}
|
||||
|
||||
messages = [
|
||||
IRCMessage(
|
||||
raw=_dcc_raw("Mallory", "evil.epub", "8.8.8.8"),
|
||||
prefix="Mallory!user@example.test",
|
||||
event=IRCEvent.BOOK_RESULT,
|
||||
),
|
||||
IRCMessage(
|
||||
raw=_dcc_raw("BookBot", "book.epub", "8.8.8.8"),
|
||||
prefix="BookBot!user@example.test",
|
||||
event=IRCEvent.BOOK_RESULT,
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
|
||||
|
||||
offer = client.wait_for_dcc(timeout=1.0, result_type=False)
|
||||
|
||||
assert offer is not None
|
||||
assert offer.filename == "book.epub"
|
||||
|
||||
|
||||
def test_wait_for_dcc_uses_expected_sender_over_online_server_list(monkeypatch) -> None:
|
||||
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
|
||||
client.online_servers = {"OtherBot"}
|
||||
|
||||
messages = [
|
||||
IRCMessage(
|
||||
raw=_dcc_raw("BookBot", "book.epub", "8.8.8.8"),
|
||||
prefix="BookBot!user@example.test",
|
||||
event=IRCEvent.BOOK_RESULT,
|
||||
)
|
||||
]
|
||||
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
|
||||
|
||||
offer = client.wait_for_dcc(
|
||||
timeout=1.0,
|
||||
result_type=False,
|
||||
expected_senders={"BookBot"},
|
||||
)
|
||||
|
||||
assert offer is not None
|
||||
assert offer.filename == "book.epub"
|
||||
|
||||
|
||||
def test_wait_for_dcc_ignores_unsafe_offer_and_keeps_waiting(monkeypatch) -> None:
|
||||
client = IRCClient(nick="reader", server="irc.example.test", port=6697)
|
||||
client.online_servers = {"BookBot"}
|
||||
|
||||
messages = [
|
||||
IRCMessage(
|
||||
raw=_dcc_raw("BookBot", "../outside.epub", "8.8.8.8"),
|
||||
prefix="BookBot!user@example.test",
|
||||
event=IRCEvent.BOOK_RESULT,
|
||||
),
|
||||
IRCMessage(
|
||||
raw=_dcc_raw("BookBot", "internal.epub", "127.0.0.1"),
|
||||
prefix="BookBot!user@example.test",
|
||||
event=IRCEvent.BOOK_RESULT,
|
||||
),
|
||||
IRCMessage(
|
||||
raw=_dcc_raw("BookBot", "book.epub", "8.8.8.8"),
|
||||
prefix="BookBot!user@example.test",
|
||||
event=IRCEvent.BOOK_RESULT,
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(client, "read_messages", lambda: iter(messages))
|
||||
|
||||
offer = client.wait_for_dcc(timeout=1.0, result_type=False)
|
||||
|
||||
assert offer is not None
|
||||
assert offer.filename == "book.epub"
|
||||
@@ -0,0 +1,82 @@
|
||||
import socket
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.release_sources.irc.dcc import (
|
||||
DCCOffer,
|
||||
DCCParseError,
|
||||
DCCSecurityError,
|
||||
download_dcc,
|
||||
parse_dcc_send,
|
||||
safe_dcc_filename,
|
||||
validate_dcc_endpoint,
|
||||
)
|
||||
|
||||
|
||||
def test_safe_dcc_filename_allows_plain_filenames() -> None:
|
||||
assert safe_dcc_filename("results.txt") == "results.txt"
|
||||
assert safe_dcc_filename("Author - Title.epub") == "Author - Title.epub"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
[
|
||||
"",
|
||||
".",
|
||||
"..",
|
||||
"../outside.txt",
|
||||
"/tmp/outside.txt",
|
||||
r"..\outside.txt",
|
||||
r"C:\temp\outside.txt",
|
||||
],
|
||||
)
|
||||
def test_safe_dcc_filename_rejects_paths(filename: str) -> None:
|
||||
with pytest.raises(DCCSecurityError):
|
||||
safe_dcc_filename(filename)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ip",
|
||||
[
|
||||
"127.0.0.1",
|
||||
"10.0.0.1",
|
||||
"172.16.0.1",
|
||||
"192.168.1.1",
|
||||
"169.254.169.254",
|
||||
"0.0.0.0",
|
||||
],
|
||||
)
|
||||
def test_validate_dcc_endpoint_rejects_non_public_ips(ip: str) -> None:
|
||||
with pytest.raises(DCCSecurityError):
|
||||
validate_dcc_endpoint(DCCOffer(filename="book.epub", ip=ip, port=1234, size=1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("port", [0, 65536])
|
||||
def test_validate_dcc_endpoint_rejects_invalid_ports(port: int) -> None:
|
||||
with pytest.raises(DCCSecurityError):
|
||||
validate_dcc_endpoint(DCCOffer(filename="book.epub", ip="8.8.8.8", port=port, size=1))
|
||||
|
||||
|
||||
def test_validate_dcc_endpoint_allows_public_endpoint() -> None:
|
||||
validate_dcc_endpoint(DCCOffer(filename="book.epub", ip="8.8.8.8", port=443, size=1))
|
||||
|
||||
|
||||
def test_parse_dcc_send_rejects_out_of_range_ip_integer() -> None:
|
||||
with pytest.raises(DCCParseError):
|
||||
parse_dcc_send('DCC SEND "book.epub" 999999999999999999 443 1')
|
||||
|
||||
|
||||
def test_download_dcc_rejects_private_endpoint_before_socket_connect(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
) -> None:
|
||||
def fail_socket(*_args: object, **_kwargs: object) -> socket.socket:
|
||||
raise AssertionError("download should reject the endpoint before opening a socket")
|
||||
|
||||
monkeypatch.setattr(socket, "socket", fail_socket)
|
||||
|
||||
with pytest.raises(DCCSecurityError):
|
||||
download_dcc(
|
||||
DCCOffer(filename="book.epub", ip="127.0.0.1", port=1234, size=1),
|
||||
tmp_path / "book.epub",
|
||||
)
|
||||
@@ -1,6 +1,59 @@
|
||||
import requests
|
||||
|
||||
from shelfmark.core.cache import get_metadata_cache
|
||||
from shelfmark.metadata_providers import MetadataSearchOptions
|
||||
from shelfmark.metadata_providers.googlebooks import GoogleBooksProvider
|
||||
|
||||
|
||||
class _GoogleBooksResponse:
|
||||
def __init__(self, payload):
|
||||
self._payload = payload
|
||||
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
|
||||
class _FlakyGoogleBooksSession:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def get(self, *args, **kwargs):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
raise requests.Timeout
|
||||
return _GoogleBooksResponse(
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "volume-1",
|
||||
"volumeInfo": {
|
||||
"title": "Recovered Book",
|
||||
"authors": ["Alice Author"],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_googlebooks_search_does_not_cache_request_failures():
|
||||
get_metadata_cache().clear()
|
||||
provider = GoogleBooksProvider(api_key="test-key")
|
||||
session = _FlakyGoogleBooksSession()
|
||||
provider.session = session
|
||||
options = MetadataSearchOptions(query="Recovered Book")
|
||||
|
||||
assert provider.search(options) == []
|
||||
|
||||
result = provider.search(options)
|
||||
|
||||
assert session.calls == 2
|
||||
assert [book.title for book in result] == ["Recovered Book"]
|
||||
|
||||
|
||||
class TestGoogleBooksParseVolume:
|
||||
def test_parse_volume_returns_metadata_for_valid_payload(self):
|
||||
provider = GoogleBooksProvider(api_key="test-key")
|
||||
|
||||
@@ -96,6 +96,28 @@ class TestGetDownloadUrl:
|
||||
|
||||
|
||||
class TestHandlerErrors:
|
||||
def test_cache_miss_uses_persisted_retry_fields(self):
|
||||
with patch("shelfmark.release_sources.newznab.handler.get_release", return_value=None):
|
||||
handler = NewznabHandler()
|
||||
task = DownloadTask(
|
||||
task_id="retryable",
|
||||
source="newznab",
|
||||
title="Book",
|
||||
retry_download_url="https://indexer.example.com/nzb/42?apikey=secret",
|
||||
retry_download_protocol="usenet",
|
||||
retry_release_name="Book Release",
|
||||
retry_expected_hash="abc123",
|
||||
)
|
||||
recorder = ProgressRecorder()
|
||||
result = handler._resolve_download(task, recorder.status_callback)
|
||||
|
||||
assert result is not None
|
||||
assert result.url == "https://indexer.example.com/nzb/42?apikey=secret"
|
||||
assert result.protocol == "usenet"
|
||||
assert result.release_name == "Book Release"
|
||||
assert result.expected_hash == "abc123"
|
||||
assert recorder.status_updates == []
|
||||
|
||||
def test_cache_miss_returns_error(self):
|
||||
with patch("shelfmark.release_sources.newznab.handler.get_release", return_value=None):
|
||||
handler = NewznabHandler()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.search_plan import ReleaseSearchPlan, ReleaseSearchVariant
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
from shelfmark.release_sources import ReleaseProtocol
|
||||
@@ -13,6 +15,20 @@ from shelfmark.release_sources.newznab.source import (
|
||||
# ── fixtures / helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _AvailableSource:
|
||||
display_name = "Newznab"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def source_available_by_default(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource())
|
||||
|
||||
|
||||
def _make_book(**kwargs) -> BookMetadata:
|
||||
defaults = {
|
||||
"provider": "hardcover",
|
||||
@@ -60,7 +76,7 @@ class TestResultToRelease:
|
||||
assert r.size_bytes == 2097152
|
||||
assert r.indexer == "MyIndexer"
|
||||
assert r.source_id == "https://indexer.example.com/nzb/42"
|
||||
assert r.download_url == "https://indexer.example.com/nzb/42?apikey=secret"
|
||||
assert r.download_url is None
|
||||
|
||||
def test_torrent_result_has_torrent_protocol(self):
|
||||
r = _newznab_result_to_release(
|
||||
@@ -134,6 +150,33 @@ class TestResultToRelease:
|
||||
assert r.extra["book_title"] == "Dune"
|
||||
assert r.extra["info_hash"] == "abc123"
|
||||
|
||||
def test_redacted_result_still_builds_private_retry_payload(self, monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
secret_download_url = "https://indexer.example.com/nzb/42?apikey=secret"
|
||||
release = _newznab_result_to_release(_make_result(downloadUrl=secret_download_url))
|
||||
|
||||
assert release.download_url is None
|
||||
|
||||
captured_tasks = []
|
||||
|
||||
def fake_add(task):
|
||||
captured_tasks.append(task)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
|
||||
monkeypatch.setattr(
|
||||
orchestrator.config, "get", lambda key, default=None, user_id=None: default
|
||||
)
|
||||
|
||||
success, error = orchestrator.queue_release(release.__dict__)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert captured_tasks[0].retry_download_url == secret_download_url
|
||||
assert captured_tasks[0].retry_download_protocol == "usenet"
|
||||
assert "retry_download_url" not in orchestrator._task_to_dict(captured_tasks[0])
|
||||
|
||||
|
||||
# ── NewznabSource.is_available ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -236,7 +236,7 @@ class TestProwlarrHandlerDownloadErrors:
|
||||
class TestProwlarrHandlerSeedCriteria:
|
||||
"""Tests for seed criteria passed through from Prowlarr."""
|
||||
|
||||
def test_resolve_download_converts_seed_time_seconds_to_minutes(self):
|
||||
def test_resolve_download_ignores_torznab_minimum_seed_criteria(self):
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
@@ -257,22 +257,28 @@ class TestProwlarrHandlerSeedCriteria:
|
||||
request = handler._resolve_download(task, lambda *_: None)
|
||||
|
||||
assert request is not None
|
||||
assert request.seeding_time_limit == 4320
|
||||
assert request.ratio_limit == 1.0
|
||||
assert request.seeding_time_limit is None
|
||||
assert request.ratio_limit is None
|
||||
|
||||
def test_resolve_download_rounds_seed_time_up_to_next_minute(self):
|
||||
with patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"minimumSeedTime": 61,
|
||||
},
|
||||
def test_resolve_download_uses_configured_seed_time_minutes(self):
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"configuredSeedTimeMinutes": 7200,
|
||||
"configuredRatioLimit": 2,
|
||||
"minimumSeedTime": 259200,
|
||||
"minimumRatio": 1,
|
||||
},
|
||||
),
|
||||
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=True),
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id="seed-time-round-up",
|
||||
task_id="configured-seed-time",
|
||||
source="prowlarr",
|
||||
title="Test Book",
|
||||
)
|
||||
@@ -280,7 +286,35 @@ class TestProwlarrHandlerSeedCriteria:
|
||||
request = handler._resolve_download(task, lambda *_: None)
|
||||
|
||||
assert request is not None
|
||||
assert request.seeding_time_limit == 2
|
||||
assert request.seeding_time_limit == 7200
|
||||
assert request.ratio_limit == 2.0
|
||||
|
||||
def test_resolve_download_ignores_configured_seed_time_when_disabled(self):
|
||||
with (
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.get_release",
|
||||
return_value={
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"configuredSeedTimeMinutes": 7200,
|
||||
"configuredRatioLimit": 2,
|
||||
},
|
||||
),
|
||||
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=False),
|
||||
):
|
||||
handler = ProwlarrHandler()
|
||||
task = DownloadTask(
|
||||
task_id="configured-seed-time-disabled",
|
||||
source="prowlarr",
|
||||
title="Test Book",
|
||||
)
|
||||
|
||||
request = handler._resolve_download(task, lambda *_: None)
|
||||
|
||||
assert request is not None
|
||||
assert request.seeding_time_limit is None
|
||||
assert request.ratio_limit is None
|
||||
|
||||
def test_download_passes_seed_limits_to_client(self):
|
||||
mock_client = MagicMock()
|
||||
@@ -295,8 +329,10 @@ class TestProwlarrHandlerSeedCriteria:
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"configuredSeedTimeMinutes": 7200,
|
||||
"configuredRatioLimit": 1.25,
|
||||
"minimumSeedTime": 259200,
|
||||
"minimumRatio": 1.25,
|
||||
"minimumRatio": 1,
|
||||
},
|
||||
),
|
||||
patch(
|
||||
@@ -306,6 +342,7 @@ class TestProwlarrHandlerSeedCriteria:
|
||||
patch(
|
||||
"shelfmark.release_sources.prowlarr.handler.remove_release",
|
||||
),
|
||||
patch("shelfmark.release_sources.prowlarr.handler.config.get", return_value=True),
|
||||
patch.object(
|
||||
ProwlarrHandler,
|
||||
"_poll_and_complete",
|
||||
@@ -329,7 +366,7 @@ class TestProwlarrHandlerSeedCriteria:
|
||||
)
|
||||
|
||||
call_kwargs = mock_client.add_download.call_args.kwargs
|
||||
assert call_kwargs["seeding_time_limit"] == 4320
|
||||
assert call_kwargs["seeding_time_limit"] == 7200
|
||||
assert call_kwargs["ratio_limit"] == 1.25
|
||||
|
||||
|
||||
|
||||
@@ -406,6 +406,158 @@ class TestQBittorrentClientGetStatus:
|
||||
assert status.complete is True
|
||||
assert status.file_path == "/downloads/Some Torrent"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_name",
|
||||
["/escape/book.epub", "../escape/book.epub", "C:\\escape\\book.epub"],
|
||||
)
|
||||
def test_get_status_falls_back_after_unsafe_derived_file_path(self, monkeypatch, file_name):
|
||||
"""Unsafe file-derived paths do not block the safe legacy fallback."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
mock_torrent = MockTorrent(
|
||||
hash_val="abc123",
|
||||
progress=1.0,
|
||||
state="uploading",
|
||||
content_path="/downloads",
|
||||
name="Some Torrent",
|
||||
)
|
||||
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"}
|
||||
|
||||
def response(kind: str):
|
||||
r = MagicMock()
|
||||
r.status_code = 200
|
||||
r.raise_for_status = MagicMock()
|
||||
if kind == "info":
|
||||
r.json.return_value = [info_payload]
|
||||
elif kind == "properties":
|
||||
r.json.return_value = {"save_path": "/downloads"}
|
||||
elif kind == "files":
|
||||
r.json.return_value = [{"name": file_name}]
|
||||
else:
|
||||
raise AssertionError("unknown")
|
||||
return r
|
||||
|
||||
mock_client_instance = MagicMock()
|
||||
|
||||
def get_side_effect(url, params=None, timeout=None):
|
||||
if url.endswith("/api/v2/torrents/info"):
|
||||
return response("info")
|
||||
if url.endswith("/api/v2/torrents/properties"):
|
||||
return response("properties")
|
||||
if url.endswith("/api/v2/torrents/files"):
|
||||
return response("files")
|
||||
raise AssertionError(f"unexpected url: {url}")
|
||||
|
||||
mock_client_instance._session.get.side_effect = get_side_effect
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
status = client.get_status("abc123")
|
||||
path = client.get_download_path("abc123")
|
||||
|
||||
assert status.complete is True
|
||||
assert status.file_path == "/downloads/Some Torrent"
|
||||
assert path == "/downloads/Some Torrent"
|
||||
|
||||
@pytest.mark.parametrize("torrent_name", ["/escape", "../escape", "C:\\escape"])
|
||||
def test_get_status_rejects_unsafe_legacy_name_path(self, monkeypatch, torrent_name):
|
||||
"""Legacy save_path/name fallback must reject absolute and traversal names."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
mock_torrent = MockTorrent(
|
||||
hash_val="abc123",
|
||||
progress=1.0,
|
||||
state="uploading",
|
||||
content_path="/downloads",
|
||||
name=torrent_name,
|
||||
)
|
||||
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"}
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance._session.get.side_effect = [
|
||||
create_mock_session_response([info_payload], status_code=200),
|
||||
create_mock_session_response([], status_code=404),
|
||||
]
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
status = client.get_status("abc123")
|
||||
|
||||
assert status.complete is True
|
||||
assert status.file_path is None
|
||||
|
||||
def test_get_status_accepts_safe_legacy_name_path(self, monkeypatch):
|
||||
"""Normal relative torrent names still build under save_path."""
|
||||
config_values = {
|
||||
"QBITTORRENT_URL": "http://localhost:8080",
|
||||
"QBITTORRENT_USERNAME": "admin",
|
||||
"QBITTORRENT_PASSWORD": "password",
|
||||
"QBITTORRENT_CATEGORY": "test",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.qbittorrent.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
mock_torrent = MockTorrent(
|
||||
hash_val="abc123",
|
||||
progress=1.0,
|
||||
state="uploading",
|
||||
content_path="/downloads",
|
||||
name="Some Torrent",
|
||||
)
|
||||
info_payload = mock_torrent.to_dict() | {"save_path": "/downloads"}
|
||||
mock_client_instance = MagicMock()
|
||||
mock_client_instance._session.get.side_effect = [
|
||||
create_mock_session_response([info_payload], status_code=200),
|
||||
create_mock_session_response([], status_code=404),
|
||||
]
|
||||
mock_client_class = MagicMock(return_value=mock_client_instance)
|
||||
|
||||
with patch.dict("sys.modules", {"qbittorrentapi": MagicMock(Client=mock_client_class)}):
|
||||
import importlib
|
||||
|
||||
import shelfmark.download.clients.qbittorrent as qb_module
|
||||
|
||||
importlib.reload(qb_module)
|
||||
|
||||
client = qb_module.QBittorrentClient()
|
||||
status = client.get_status("abc123")
|
||||
|
||||
assert status.complete is True
|
||||
assert status.file_path == "/downloads/Some Torrent"
|
||||
|
||||
def test_get_status_not_found(self, monkeypatch):
|
||||
"""Test status for non-existent torrent."""
|
||||
config_values = {
|
||||
|
||||
@@ -410,3 +410,83 @@ def test_windows_path_case_insensitive_matching():
|
||||
|
||||
assert result == str(local_file)
|
||||
assert task.original_download_path == str(local_file)
|
||||
|
||||
|
||||
def test_resolve_download_path_rejects_unsafe_mapping_remainder_even_if_original_exists(
|
||||
tmp_path,
|
||||
):
|
||||
remote_dir = tmp_path / "remote" / "downloads"
|
||||
local_dir = tmp_path / "local" / "downloads"
|
||||
escaped_file = tmp_path / "escape" / "book.epub"
|
||||
remote_dir.mkdir(parents=True)
|
||||
local_dir.mkdir(parents=True)
|
||||
escaped_file.parent.mkdir(parents=True)
|
||||
escaped_file.write_text("escaped content")
|
||||
|
||||
raw_path = f"{remote_dir}/../../escape/book.epub"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "qbittorrent"
|
||||
mock_client.get_download_path.return_value = raw_path
|
||||
|
||||
def config_get(key: str, default=""):
|
||||
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
|
||||
return [
|
||||
{
|
||||
"host": "qbittorrent",
|
||||
"remotePath": str(remote_dir),
|
||||
"localPath": str(local_dir),
|
||||
}
|
||||
]
|
||||
return default
|
||||
|
||||
with patch("shelfmark.download.clients.base_handler.config.get", side_effect=config_get):
|
||||
handler = ProwlarrHandler()
|
||||
|
||||
resolved_path, error = handler._resolve_download_path_once(
|
||||
mock_client,
|
||||
"download_id",
|
||||
log_details=False,
|
||||
)
|
||||
|
||||
assert Path(raw_path).exists()
|
||||
assert resolved_path is None
|
||||
assert error is not None
|
||||
assert "rejected unsafe path" in error
|
||||
|
||||
|
||||
def test_delete_local_download_data_skips_unsafe_mapping_remainder_even_if_original_exists(
|
||||
tmp_path,
|
||||
):
|
||||
remote_dir = tmp_path / "remote" / "downloads"
|
||||
local_dir = tmp_path / "local" / "downloads"
|
||||
escaped_file = tmp_path / "escape" / "book.epub"
|
||||
remote_dir.mkdir(parents=True)
|
||||
local_dir.mkdir(parents=True)
|
||||
escaped_file.parent.mkdir(parents=True)
|
||||
escaped_file.write_text("escaped content")
|
||||
|
||||
raw_path = f"{remote_dir}/../../escape/book.epub"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.name = "nzbget"
|
||||
mock_client.get_download_path.return_value = raw_path
|
||||
|
||||
def config_get(key: str, default=""):
|
||||
if key == "PROWLARR_REMOTE_PATH_MAPPINGS":
|
||||
return [
|
||||
{
|
||||
"host": "nzbget",
|
||||
"remotePath": str(remote_dir),
|
||||
"localPath": str(local_dir),
|
||||
}
|
||||
]
|
||||
return default
|
||||
|
||||
with patch("shelfmark.download.clients.base_handler.config.get", side_effect=config_get):
|
||||
handler = ProwlarrHandler()
|
||||
handler._delete_local_download_data(mock_client, "download_id")
|
||||
|
||||
assert Path(raw_path).exists()
|
||||
assert escaped_file.exists()
|
||||
assert escaped_file.read_text() == "escaped content"
|
||||
|
||||
@@ -476,6 +476,7 @@ class TestSABnzbdClientAddDownload:
|
||||
"SABNZBD_URL": "http://localhost:8080",
|
||||
"SABNZBD_API_KEY": "abc123",
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
"PROWLARR_URL": "https://example.com",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
@@ -512,6 +513,7 @@ class TestSABnzbdClientAddDownload:
|
||||
"SABNZBD_URL": "http://localhost:8080",
|
||||
"SABNZBD_API_KEY": "abc123",
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
"PROWLARR_URL": "https://example.com",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
@@ -551,6 +553,7 @@ class TestSABnzbdClientAddDownload:
|
||||
"SABNZBD_URL": "http://localhost:8080",
|
||||
"SABNZBD_API_KEY": "abc123",
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
"PROWLARR_URL": "https://example.com",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
@@ -592,6 +595,7 @@ class TestSABnzbdClientAddDownload:
|
||||
"SABNZBD_URL": "http://localhost:8080",
|
||||
"SABNZBD_API_KEY": "abc123",
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
"PROWLARR_URL": "https://example.com",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
@@ -620,6 +624,36 @@ class TestSABnzbdClientAddDownload:
|
||||
assert result == "SABnzbd_nzo_fallback"
|
||||
assert mock_api_call.call_args[0][0] == "addurl"
|
||||
|
||||
def test_add_download_does_not_prefetch_untrusted_nzb_url(self, monkeypatch):
|
||||
"""Untrusted NZB URLs should be handed to SABnzbd without backend prefetch."""
|
||||
config_values = {
|
||||
"SABNZBD_URL": "http://localhost:8080",
|
||||
"SABNZBD_API_KEY": "abc123",
|
||||
"SABNZBD_CATEGORY": "books",
|
||||
"PROWLARR_URL": "https://prowlarr.example",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.sabnzbd.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"status": True, "nzo_ids": ["SABnzbd_nzo_addurl"]}
|
||||
|
||||
with patch(
|
||||
"shelfmark.download.clients.sabnzbd.requests.get",
|
||||
return_value=mock_response,
|
||||
) as mock_get:
|
||||
from shelfmark.download.clients.sabnzbd import SABnzbdClient
|
||||
|
||||
client = SABnzbdClient()
|
||||
result = client.add_download("https://attacker.example/download.nzb", "Test Book")
|
||||
|
||||
assert result == "SABnzbd_nzo_addurl"
|
||||
called_urls = [call.args[0] for call in mock_get.call_args_list]
|
||||
assert "https://attacker.example/download.nzb" not in called_urls
|
||||
assert called_urls == ["http://localhost:8080/api"]
|
||||
|
||||
|
||||
class TestSABnzbdClientRemove:
|
||||
"""Tests for SABnzbdClient.remove()."""
|
||||
|
||||
@@ -5,7 +5,10 @@ Tests the utility functions for parsing release metadata.
|
||||
"""
|
||||
|
||||
# Import the functions to test
|
||||
import pytest
|
||||
|
||||
from shelfmark.metadata_providers import BookMetadata
|
||||
from shelfmark.release_sources.prowlarr.api import ProwlarrClient
|
||||
from shelfmark.release_sources.prowlarr.source import (
|
||||
ProwlarrSource,
|
||||
_detect_content_type_from_categories,
|
||||
@@ -15,6 +18,20 @@ from shelfmark.release_sources.prowlarr.source import (
|
||||
from shelfmark.release_sources.prowlarr.utils import get_protocol_display, sanitize_download_url
|
||||
|
||||
|
||||
class _AvailableSource:
|
||||
display_name = "Prowlarr"
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def source_available_by_default(monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
|
||||
monkeypatch.setattr(orchestrator, "get_source", lambda _source: _AvailableSource())
|
||||
|
||||
|
||||
class TestParseSize:
|
||||
"""Tests for the _parse_size function."""
|
||||
|
||||
@@ -210,9 +227,12 @@ class TestDetectContentType:
|
||||
|
||||
|
||||
class FakeTorznabClient:
|
||||
def __init__(self):
|
||||
def __init__(self, search_results=None, seed_settings=None):
|
||||
self.calls: list[tuple[str, object]] = []
|
||||
self.queries: list[str] = []
|
||||
self.seed_settings_calls: list[object] = []
|
||||
self.search_results = search_results or []
|
||||
self.seed_settings = seed_settings or {}
|
||||
|
||||
def get_enabled_indexers_detailed(self):
|
||||
return [
|
||||
@@ -241,12 +261,47 @@ class FakeTorznabClient:
|
||||
del indexer_id, search_type, limit, offset
|
||||
self.calls.append((query, categories))
|
||||
self.queries.append(query)
|
||||
return []
|
||||
return self.search_results
|
||||
|
||||
def get_enriched_indexer_ids(self, restrict_to=None):
|
||||
del restrict_to
|
||||
return []
|
||||
|
||||
def get_indexer_seed_settings(self, restrict_to=None):
|
||||
self.seed_settings_calls.append(restrict_to)
|
||||
return self.seed_settings
|
||||
|
||||
|
||||
class TestProwlarrIndexerSeedSettings:
|
||||
def test_get_indexer_seed_settings_reads_prowlarr_minutes_field(self, monkeypatch):
|
||||
client = ProwlarrClient("http://prowlarr:9696", "apikey")
|
||||
monkeypatch.setattr(
|
||||
client,
|
||||
"get_enabled_indexers_detailed",
|
||||
lambda: [
|
||||
{
|
||||
"id": 13,
|
||||
"protocol": "torrent",
|
||||
"fields": [
|
||||
{"name": "torrentBaseSettings.seedRatio", "value": "2.5"},
|
||||
{"name": "torrentBaseSettings.seedTime", "value": "7200"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"protocol": "usenet",
|
||||
"fields": [
|
||||
{"name": "torrentBaseSettings.seedRatio", "value": "3"},
|
||||
{"name": "torrentBaseSettings.seedTime", "value": "9999"},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
assert client.get_indexer_seed_settings() == {
|
||||
13: {"ratio_limit": 2.5, "seeding_time_limit_minutes": 7200}
|
||||
}
|
||||
|
||||
|
||||
class TestProwlarrLocalizedQueries:
|
||||
def test_manual_query_still_applies_content_type_categories(self, monkeypatch):
|
||||
@@ -309,6 +364,163 @@ class TestProwlarrLocalizedQueries:
|
||||
|
||||
assert fake_client.calls == [("my custom", None)]
|
||||
|
||||
def test_search_attaches_configured_seed_time_minutes_to_release(self, monkeypatch):
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
|
||||
def fake_get(key: str, default=None):
|
||||
values = {
|
||||
"PROWLARR_INDEXERS": "",
|
||||
"PROWLARR_AUTO_EXPAND": False,
|
||||
"PROWLARR_USE_SEED_PREFERENCES": True,
|
||||
}
|
||||
return values.get(key, default)
|
||||
|
||||
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
|
||||
|
||||
fake_client = FakeTorznabClient(
|
||||
search_results=[
|
||||
{
|
||||
"guid": "mam-result-1",
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"indexerId": 1,
|
||||
"indexer": "MyAnonamouse",
|
||||
"minimumSeedTime": 259200,
|
||||
"minimumRatio": 1,
|
||||
}
|
||||
],
|
||||
seed_settings={1: {"ratio_limit": 2.0, "seeding_time_limit_minutes": 7200}},
|
||||
)
|
||||
source = ProwlarrSource()
|
||||
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
|
||||
|
||||
book = BookMetadata(
|
||||
provider="hardcover",
|
||||
provider_id="123",
|
||||
title="Anything",
|
||||
authors=["Someone"],
|
||||
)
|
||||
|
||||
from shelfmark.core.search_plan import build_release_search_plan
|
||||
|
||||
plan = build_release_search_plan(book, languages=["en"])
|
||||
releases = source.search(book, plan, content_type="ebook")
|
||||
|
||||
assert len(releases) == 1
|
||||
assert fake_client.seed_settings_calls == [None]
|
||||
assert releases[0].extra["configured_ratio_limit"] == 2.0
|
||||
assert releases[0].extra["configured_seed_time_minutes"] == 7200
|
||||
assert "minimum_seed_time" not in releases[0].extra
|
||||
assert "minimum_ratio" not in releases[0].extra
|
||||
|
||||
def test_search_ignores_configured_seed_time_when_disabled(self, monkeypatch):
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
|
||||
def fake_get(key: str, default=None):
|
||||
values = {
|
||||
"PROWLARR_INDEXERS": "",
|
||||
"PROWLARR_AUTO_EXPAND": False,
|
||||
"PROWLARR_USE_SEED_PREFERENCES": False,
|
||||
}
|
||||
return values.get(key, default)
|
||||
|
||||
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
|
||||
|
||||
fake_client = FakeTorznabClient(
|
||||
search_results=[
|
||||
{
|
||||
"guid": "mam-result-1",
|
||||
"protocol": "torrent",
|
||||
"title": "Test Release",
|
||||
"magnetUrl": "magnet:?xt=urn:btih:abc123",
|
||||
"indexerId": 1,
|
||||
"indexer": "MyAnonamouse",
|
||||
}
|
||||
],
|
||||
seed_settings={1: {"ratio_limit": 2.0, "seeding_time_limit_minutes": 7200}},
|
||||
)
|
||||
source = ProwlarrSource()
|
||||
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
|
||||
|
||||
book = BookMetadata(
|
||||
provider="hardcover",
|
||||
provider_id="123",
|
||||
title="Anything",
|
||||
authors=["Someone"],
|
||||
)
|
||||
|
||||
from shelfmark.core.search_plan import build_release_search_plan
|
||||
|
||||
plan = build_release_search_plan(book, languages=["en"])
|
||||
releases = source.search(book, plan, content_type="ebook")
|
||||
|
||||
assert len(releases) == 1
|
||||
assert fake_client.seed_settings_calls == []
|
||||
assert releases[0].extra["configured_ratio_limit"] is None
|
||||
assert releases[0].extra["configured_seed_time_minutes"] is None
|
||||
|
||||
def test_redacted_search_result_still_builds_private_retry_payload(self, monkeypatch):
|
||||
import shelfmark.download.orchestrator as orchestrator
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
|
||||
secret_download_url = "https://prowlarr.example.com/1/download?apikey=secret"
|
||||
|
||||
def fake_get(key: str, default=None, user_id=None):
|
||||
values = {
|
||||
"PROWLARR_INDEXERS": "",
|
||||
"PROWLARR_AUTO_EXPAND": False,
|
||||
"PROWLARR_USE_SEED_PREFERENCES": False,
|
||||
}
|
||||
return values.get(key, default)
|
||||
|
||||
monkeypatch.setattr(prowlarr_source.config, "get", fake_get)
|
||||
monkeypatch.setattr(orchestrator.config, "get", fake_get)
|
||||
|
||||
fake_client = FakeTorznabClient(
|
||||
search_results=[
|
||||
{
|
||||
"guid": "secret-prowlarr-release",
|
||||
"protocol": "usenet",
|
||||
"title": "Secret Bearing Release",
|
||||
"downloadUrl": secret_download_url,
|
||||
}
|
||||
]
|
||||
)
|
||||
source = ProwlarrSource()
|
||||
monkeypatch.setattr(source, "_get_client", lambda: fake_client)
|
||||
|
||||
book = BookMetadata(
|
||||
provider="hardcover",
|
||||
provider_id="123",
|
||||
title="Anything",
|
||||
authors=["Someone"],
|
||||
)
|
||||
|
||||
from shelfmark.core.search_plan import build_release_search_plan
|
||||
|
||||
plan = build_release_search_plan(book, languages=["en"])
|
||||
releases = source.search(book, plan, content_type="ebook")
|
||||
|
||||
assert len(releases) == 1
|
||||
assert releases[0].download_url is None
|
||||
|
||||
captured_tasks = []
|
||||
|
||||
def fake_add(task):
|
||||
captured_tasks.append(task)
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(orchestrator.book_queue, "add", fake_add)
|
||||
|
||||
success, error = orchestrator.queue_release(releases[0].__dict__)
|
||||
|
||||
assert success is True
|
||||
assert error is None
|
||||
assert captured_tasks[0].retry_download_url == secret_download_url
|
||||
assert captured_tasks[0].retry_download_protocol == "usenet"
|
||||
assert "retry_download_url" not in orchestrator._task_to_dict(captured_tasks[0])
|
||||
|
||||
def test_search_uses_localized_titles_when_available(self, monkeypatch):
|
||||
import shelfmark.release_sources.prowlarr.source as prowlarr_source
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Tests:
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -18,6 +19,7 @@ from shelfmark.download.clients.torrent_utils import (
|
||||
bencode_encode,
|
||||
extract_hash_from_magnet,
|
||||
extract_info_hash_from_torrent,
|
||||
extract_torrent_info,
|
||||
parse_transmission_url,
|
||||
)
|
||||
|
||||
@@ -356,6 +358,121 @@ class TestExtractInfoHash:
|
||||
assert extract_info_hash_from_torrent(torrent_bytes) == expected
|
||||
|
||||
|
||||
class TestExtractTorrentInfo:
|
||||
"""Tests for extracting torrent info from user-supplied URLs."""
|
||||
|
||||
def test_does_not_fetch_untrusted_http_torrent_url(self, monkeypatch):
|
||||
"""Arbitrary HTTP torrent URLs are passed through without backend prefetch."""
|
||||
expected_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.torrent_utils.config.get",
|
||||
lambda key, default="": "",
|
||||
)
|
||||
mock_get = MagicMock()
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
result = extract_torrent_info(
|
||||
"https://attacker.example/book.torrent",
|
||||
fetch_torrent=True,
|
||||
expected_hash=expected_hash,
|
||||
)
|
||||
|
||||
assert result.info_hash == expected_hash
|
||||
assert result.torrent_data is None
|
||||
assert result.is_magnet is False
|
||||
mock_get.assert_not_called()
|
||||
|
||||
def test_fetches_configured_prowlarr_torrent_url(self, monkeypatch):
|
||||
"""Configured Prowlarr download URLs can still be prefetched and parsed."""
|
||||
info_dict = {
|
||||
b"name": b"trusted.txt",
|
||||
b"length": 100,
|
||||
b"piece length": 16384,
|
||||
b"pieces": b"\x00" * 20,
|
||||
}
|
||||
torrent_data = bencode_encode({b"info": info_dict})
|
||||
expected_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
|
||||
|
||||
config_values = {
|
||||
"PROWLARR_URL": "https://prowlarr.example",
|
||||
"PROWLARR_API_KEY": "secret",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.torrent_utils.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
response = MagicMock(status_code=200, content=torrent_data)
|
||||
response.raise_for_status = MagicMock()
|
||||
mock_get = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
result = extract_torrent_info(
|
||||
"https://prowlarr.example/1/download?apikey=secret&indexer=7",
|
||||
fetch_torrent=True,
|
||||
)
|
||||
|
||||
assert result.info_hash == expected_hash
|
||||
assert result.torrent_data == torrent_data
|
||||
assert result.is_magnet is False
|
||||
mock_get.assert_called_once()
|
||||
|
||||
def test_normalizes_configured_origin_before_trusting_torrent_url(self, monkeypatch):
|
||||
"""Configured Prowlarr URLs match the same normalization used by the source."""
|
||||
info_dict = {
|
||||
b"name": b"trusted.txt",
|
||||
b"length": 100,
|
||||
b"piece length": 16384,
|
||||
b"pieces": b"\x00" * 20,
|
||||
}
|
||||
torrent_data = bencode_encode({b"info": info_dict})
|
||||
expected_hash = hashlib.sha1(bencode_encode(info_dict)).hexdigest().lower()
|
||||
|
||||
config_values = {
|
||||
"PROWLARR_URL": "prowlarr.example:9696/",
|
||||
"PROWLARR_API_KEY": "secret",
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.torrent_utils.config.get",
|
||||
lambda key, default="": config_values.get(key, default),
|
||||
)
|
||||
response = MagicMock(status_code=200, content=torrent_data)
|
||||
response.raise_for_status = MagicMock()
|
||||
mock_get = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
result = extract_torrent_info(
|
||||
"http://prowlarr.example:9696/1/download?apikey=secret&indexer=7",
|
||||
fetch_torrent=True,
|
||||
)
|
||||
|
||||
assert result.info_hash == expected_hash
|
||||
assert result.torrent_data == torrent_data
|
||||
mock_get.assert_called_once()
|
||||
|
||||
def test_does_not_follow_trusted_torrent_url_redirect_to_untrusted_host(self, monkeypatch):
|
||||
"""Trusted HTTP prefetch does not continue through arbitrary redirects."""
|
||||
expected_hash = "3b245504cf5f11bbdbe1201cea6a6bf45aee1bc0"
|
||||
monkeypatch.setattr(
|
||||
"shelfmark.download.clients.torrent_utils.config.get",
|
||||
lambda key, default="": "https://prowlarr.example" if key == "PROWLARR_URL" else "",
|
||||
)
|
||||
response = MagicMock(status_code=302)
|
||||
response.headers = {"Location": "https://attacker.example/book.torrent"}
|
||||
mock_get = MagicMock(return_value=response)
|
||||
monkeypatch.setattr("shelfmark.download.clients.torrent_utils.requests.get", mock_get)
|
||||
|
||||
result = extract_torrent_info(
|
||||
"https://prowlarr.example/1/download?apikey=secret&indexer=7",
|
||||
fetch_torrent=True,
|
||||
expected_hash=expected_hash,
|
||||
)
|
||||
|
||||
assert result.info_hash == expected_hash
|
||||
assert result.torrent_data is None
|
||||
assert result.is_magnet is False
|
||||
mock_get.assert_called_once()
|
||||
|
||||
|
||||
class TestExtractHashFromMagnet:
|
||||
"""Tests for extracting hash from magnet links."""
|
||||
|
||||
|
||||
@@ -155,11 +155,14 @@ wait_for_tor() {
|
||||
return 1
|
||||
}
|
||||
|
||||
tor_is_healthy() {
|
||||
supervisorctl status tor | grep -q "RUNNING" &&
|
||||
grep -q "Bootstrapped 100%" /var/log/tor/notices.log 2>/dev/null
|
||||
}
|
||||
|
||||
FAIL_COUNT=0
|
||||
while true; do
|
||||
# Try to resolve/connect to google.com (timeout 10s)
|
||||
if curl -s --head --max-time 10 https://google.com > /dev/null; then
|
||||
# Success
|
||||
if tor_is_healthy; then
|
||||
FAIL_COUNT=0
|
||||
else
|
||||
FAIL_COUNT=$((FAIL_COUNT+1))
|
||||
@@ -213,20 +216,29 @@ echo "[*] Setting up iptables rules..."
|
||||
|
||||
iptables -F
|
||||
iptables -t nat -F
|
||||
TOR_UID=$(id -u debian-tor)
|
||||
|
||||
# Allow loopback
|
||||
iptables -t nat -A OUTPUT -o lo -j RETURN
|
||||
|
||||
# Redirect all TCP to Tor's TransPort
|
||||
iptables -t nat -A OUTPUT -p tcp --syn -j REDIRECT --to-ports 9040
|
||||
# Allow Tor itself to reach the network
|
||||
iptables -t nat -A OUTPUT -m owner --uid-owner "$TOR_UID" -j RETURN
|
||||
|
||||
# For UDP DNS queries
|
||||
iptables -t nat -A OUTPUT -p udp --dport 53 ! -d 127.0.0.1 -j DNAT --to-destination 127.0.0.1:53
|
||||
|
||||
|
||||
# For TCP DNS queries (some DNS queries may use TCP)
|
||||
iptables -t nat -A OUTPUT -p tcp --dport 53 ! -d 127.0.0.1 -j DNAT --to-destination 127.0.0.1:53
|
||||
|
||||
# Bypass Tor for local/private networks
|
||||
iptables -t nat -A OUTPUT -d 127.0.0.0/8 -j RETURN
|
||||
iptables -t nat -A OUTPUT -d 10.0.0.0/8 -j RETURN
|
||||
iptables -t nat -A OUTPUT -d 172.16.0.0/12 -j RETURN
|
||||
iptables -t nat -A OUTPUT -d 192.168.0.0/16 -j RETURN
|
||||
|
||||
# Redirect all TCP to Tor's TransPort
|
||||
iptables -t nat -A OUTPUT -p tcp --syn -j REDIRECT --to-ports 9040
|
||||
|
||||
echo "[✓] Transparent Tor routing enabled."
|
||||
|
||||
sleep 5
|
||||
|
||||
@@ -4,7 +4,7 @@ requires-python = ">=3.14"
|
||||
|
||||
[[package]]
|
||||
name = "apprise"
|
||||
version = "1.9.9"
|
||||
version = "1.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -15,9 +15,9 @@ dependencies = [
|
||||
{ name = "requests-oauthlib" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/f4/be5c7e39b83a2285ab62ae7c19bb10704836f59c0a5b4c471730f54c9f98/apprise-1.9.9.tar.gz", hash = "sha256:fd622c0df16bdc79ed385539735573488cafe2405d25747e87eebd6b09b26012", size = 2032822, upload-time = "2026-03-21T17:49:14.041Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2f/74/9c16829d3e7e45ce7daf1b704687fa4fde7ea00d72eafe8de18c72bf5995/apprise-1.10.0.tar.gz", hash = "sha256:b768f32d99e45ed5f4c3eef1f67903e803c97f97ba61a531a5d0a45d40df90a8", size = 2188611, upload-time = "2026-04-26T14:23:51.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/2f/54d068d7e011a8b4e0aae3e93b09a30b33bcf780829fe70c6e8876aeb0e0/apprise-1.9.9-py3-none-any.whl", hash = "sha256:55ceb8827a1c783d683881c9f77fa42eb43b3fc91b854419c452d557101c7068", size = 1519940, upload-time = "2026-03-21T17:49:11.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/f9/177a73589d34e676d10bc4c6a8328710e28af5907234e9f25bb149a04eec/apprise-1.10.0-py3-none-any.whl", hash = "sha256:e685303d3568bb7a057d6ddeafd27ee12fff183ca36483ad4bacc0b9b4efa82c", size = 1632292, upload-time = "2026-04-26T14:23:49.28Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -31,15 +31,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "authlib"
|
||||
version = "1.7.0"
|
||||
version = "1.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
{ name = "joserfc" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d9/82/4d0603f30c1b4629b1f091bb266b0d7986434891d6940a8c87f8098db24e/authlib-1.7.0.tar.gz", hash = "sha256:b3e326c9aa9cc3ea95fe7d89fd880722d3608da4d00e8a27e061e64b48d801d5", size = 175890, upload-time = "2026-04-18T11:00:28.559Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/48/c954218b2a250e23f178f10167c4173fecb5a75d2c206f0a67ba58006c26/authlib-1.7.0-py2.py3-none-any.whl", hash = "sha256:e36817afb02f6f0b6bf55f150782499ddd6ddf44b402bb055d3263cc65ac9ae0", size = 258779, upload-time = "2026-04-18T11:00:26.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -101,11 +101,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
version = "2026.4.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -498,11 +498,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -666,11 +666,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.1"
|
||||
version = "26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -720,11 +720,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pip"
|
||||
version = "26.0.1"
|
||||
version = "26.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/83/0d7d4e9efe3344b8e2fe25d93be44f64b65364d3c8d7bc6dc90198d5422e/pip-26.0.1.tar.gz", hash = "sha256:c4037d8a277c89b320abe636d59f91e6d0922d08a05b60e85e53b296613346d8", size = 1812747, upload-time = "2026-02-05T02:20:18.702Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/f0/c81e05b613866b76d2d1066490adf1a3dbc4ee9d9c839961c3fc8a6997af/pip-26.0.1-py3-none-any.whl", hash = "sha256:bdb1b08f4274833d62c1aa29e20907365a2ceb950410df15fc9521bad440122b", size = 1787723, upload-time = "2026-02-05T02:20:16.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -747,26 +747,26 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "prek"
|
||||
version = "0.3.10"
|
||||
version = "0.3.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/31/3e68cd8f45cb5f3f81009ff42d3a3cde0887b83f214a4eb0ded9ae239cc2/prek-0.3.10.tar.gz", hash = "sha256:f4e9c533612bbaa9f89eca0e80ab6e59a05b0fd15ca7c6642a35bb303731ad6f", size = 425926, upload-time = "2026-04-21T11:29:37.122Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/59/0a279983f96bd5d538b4975f0a23121082aa3b8560b6649fdf61f8011b07/prek-0.3.13.tar.gz", hash = "sha256:c48586ee3708bfbf3df80121f55583e9a7d0fa166b08172c091fe5971e92a0ac", size = 444848, upload-time = "2026-05-05T18:07:09.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/b7/306d57aaaf503be1ee58929f9e58e812fbde9bea52c1a578e4996d82425a/prek-0.3.10-py3-none-linux_armv6l.whl", hash = "sha256:7250661f003d902b7b601141d64c7015f93eeeeeb1c485f714374582714d7c51", size = 5416913, upload-time = "2026-04-21T11:29:33.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/ec/4b4b60d17c5eb639779a059d7f66a8fff5b38c7cb26c9c5b3a853cc6a9c8/prek-0.3.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9da699368bb11e85fc58b57ae4c1ccc703364b1d95b595bd17a27b27fa6df50d", size = 5784725, upload-time = "2026-04-21T11:29:38.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c3/6d4dcc9446a1630a888e3f105c4775536afe58c20f18833fec8e0d841ac0/prek-0.3.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f245fa9935cee6ea7d5ba1c6e7dd7546ccf6f01fb7d604bb6a344c8e3b49a9c2", size = 5360071, upload-time = "2026-04-21T11:29:10.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/2d/d1f63ca15f4a275fcdc712fd9ef64787abb4ba86f2e79986a26108a5f1e7/prek-0.3.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d321cad394ef4436ed9fce74150076a5eefabe310ed01b3f5735746284b9d046", size = 5615550, upload-time = "2026-04-21T11:29:30.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/1b/a925050ba30791e50e7690a6dabe33dc08a0133e7efab4b9d91088261d40/prek-0.3.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e80017ecf06a11bf9c8ebcb67d38183e0abf2f18e012ee6a5f91d1b9c065d6", size = 5338159, upload-time = "2026-04-21T11:29:25.314Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/ec/7d563a333198ead10f9a13fcf72ca910a59135b5d58429ed3b1fff9a0ff9/prek-0.3.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba24b4c8e40cb9ae96d58efa3eb91813cdff4f10b78b79201c93a0dc6b8763e2", size = 5728600, upload-time = "2026-04-21T11:29:23.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/88/10d493ef308f10321b92bb4e65ab60030b3c5c6304790c678be249e383e3/prek-0.3.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aac1c6f8dabb0a202a27516bf78cbfbe8a05b7cfebe2078ff6d0e12e7c77c7d5", size = 6610055, upload-time = "2026-04-21T11:29:27.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/14/552b239d99fdd09fd6664b69089359340778f84a9f866a1fecc38b66c811/prek-0.3.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a9a446e178562e2c3cfc71c8f0b21043b571209533a104042b6b9e36662849e", size = 6007394, upload-time = "2026-04-21T11:29:14.466Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/80/12b9de6b6721a27f57ed633a8864435ec2fa7535cba7f48f3ea4a52ce683/prek-0.3.10-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d7344b2acb88b52592c0e0f3dd33349f75e6a4813d3bb44385aa8c70084990ca", size = 5616625, upload-time = "2026-04-21T11:29:29.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/02/7235f6b6d65a3e6e036b211a240432bc4f51adfdbbdcc03b5ce60a238a84/prek-0.3.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f2fe5ca3fe797a647243c10c0a9f9c2c27c1d4a0cdcb7f836a0cb8c5c2c603d8", size = 5431581, upload-time = "2026-04-21T11:29:20.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/0c/0dd0013c6a9c6b23d070a57fe5c015c40c7b3e84217e4fb1ca9e465768c6/prek-0.3.10-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:6d39031e4acf7670b905d77ea807e2ce1b6309604d54740f3152d601b71ae5e0", size = 5318252, upload-time = "2026-04-21T11:29:40.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/d7/5407ca904a7f2ffe63b1333e503d2f21ec23eda93e93fb45a491d78e0d76/prek-0.3.10-py3-none-musllinux_1_1_i686.whl", hash = "sha256:27865f3134dd566ffd76388a9f0de6ee931336794e32741b8e22c90a61f3fc39", size = 5589312, upload-time = "2026-04-21T11:29:18.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/05/460b0c498db65e9a2e590b6918aad3395291d8d9f2c080fce7201d12a5de/prek-0.3.10-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3e6b65338a06add2bc47a86f837607b4705855ade9457f2f50676a7a5a509cd9", size = 6122323, upload-time = "2026-04-21T11:29:12.6Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/63/fd715bcd9ed5debd60dc589d6117ab9eaf0ef0b106e0bb976e1bcaa96e29/prek-0.3.10-py3-none-win32.whl", hash = "sha256:e2529515ce81292c938181702dbfc75516efa5bb1d4dc1295e6f8b2655eec881", size = 5115503, upload-time = "2026-04-21T11:29:35.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0d/a34f57ca3efa228fa75a976fcf698d5705b5b9a750c8dd1b86857b24f3b0/prek-0.3.10-py3-none-win_amd64.whl", hash = "sha256:0846228fa277a8bdf5a3eeea90eb3ac8eff2bf71900919373f12d142e19c8f8c", size = 5496962, upload-time = "2026-04-21T11:29:16.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/de/8b9f20f712c21756e52feed95fdacd52f48811a7bc6e3816cd04725b1240/prek-0.3.10-py3-none-win_arm64.whl", hash = "sha256:dedcdb4e5a52ee3e0463c061cabdcb94a7443875053cebfadb8228b3bf917035", size = 5340403, upload-time = "2026-04-21T11:29:22.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/6a/9baa2bda21dccc2927e952416f6cc23a75eb99c9ed18837164ac2e4a5640/prek-0.3.13-py3-none-linux_armv6l.whl", hash = "sha256:b00d38f01235073c35aa5f48df57fefef45a6cec2ae0884d750345a2c7220370", size = 5506622, upload-time = "2026-05-05T18:06:53.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/77/d44b5d9bdca0879b865f8e47bf84cf5dc9e8b358d029e6d9b83d8809c116/prek-0.3.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0d89ac712c60e34d1550a606ad5fdfb8ad71d44ced8afa2fa5cbc106be4abd9e", size = 5878743, upload-time = "2026-05-05T18:07:23.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/cf/19e8525cde8b3aa12858aca434d1fa653ef3b152da5af11eafc857634dc2/prek-0.3.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f9b5265863d18b5be4ea094fdce4fd6ca61a8c89a70ee3d8ee153b3e0ed6b272", size = 5434909, upload-time = "2026-05-05T18:07:25.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/9a/e5f97194782de4dab622ce09dafb3ebdd2ee4d354a83ac4def7ebeee236c/prek-0.3.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:64b59a1550780af2bba37297c704b17f81d8e9df6288af1fab4017938e33b1db", size = 5697536, upload-time = "2026-05-05T18:07:05.475Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/8c/e1f548ffc4b227e4c2b5a9b30f5978a7e0e6dad51305b97a2ba5b2a923e7/prek-0.3.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9ce6cd8f114ba9bbdbe97422103fd886101949b1c42e588a7543c4436ead2020", size = 5428160, upload-time = "2026-05-05T18:07:01.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/44/abd919b00905a32d21dca2cec32c707860cf217da2431b62dd52684b310e/prek-0.3.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc03e924a24d8d961f56195853c8b206cb196be6db4ad8312125dae847d718ac", size = 5827275, upload-time = "2026-05-05T18:07:17.437Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/ed/cafd2b80d58a83faf8371c6543bd1475a2224242a3294da7f8582f6aa551/prek-0.3.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ca8c526a23873177fb3b92013500b08ef5f8bedc7263f9f3a44dd2f49645a26", size = 6710293, upload-time = "2026-05-05T18:07:10.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/09/52a4a27596b764173a34d74db09356b30faaacb4a1075b75adbc036a0008/prek-0.3.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a5bbb175478438a871e3281d2c3c3f067288af73ad81707a9bdebfd769766c7d", size = 6096556, upload-time = "2026-05-05T18:07:19.46Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/60/80f61729ce6498815d46d5580cf76da2c157c9b6494046183682441a0ea3/prek-0.3.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:a65327a014d838341af757dfc05a706d10e8e33f039bc32bb3dbe2fa21c440c0", size = 5693267, upload-time = "2026-05-05T18:07:03.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/9d/c7a663fe70676ffab2e0c6c9a71997a3ccd002ed5bc60b7422a937911af0/prek-0.3.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b6a200843a36a5b0c41764ce7639ccb3471d48b097f1c5e3fc8f034219b42626", size = 5532865, upload-time = "2026-05-05T18:07:15.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/68/506ef5a235536030e16f61e7210474554f6e05f845f27df5877d2dbb1a06/prek-0.3.13-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:bdacaad8f35f343e063d251211fe34db1de9e5cc591795361ad69a6485202258", size = 5395951, upload-time = "2026-05-05T18:06:55.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/00/22d7c6db7f43b58f7d015913c12660c9bbc82751cff6cfd8c31993cf30eb/prek-0.3.13-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f00328f1c520d8fefb910ab0d3c6764ee330d227952baa19b7e3de7242bd8b3b", size = 5681195, upload-time = "2026-05-05T18:07:12.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e3/fdf9882238796914ddaf11381a9083b374980156200a953324f6c795f34d/prek-0.3.13-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e5530a867bcf5b172b7513a64e71b06a337d1d184696227ae953845867376b8d", size = 6212085, upload-time = "2026-05-05T18:07:07.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/1d/528759931344b5c7103085798f5fa2e86d27d9410b753a6bcbe7726aa8ba/prek-0.3.13-py3-none-win32.whl", hash = "sha256:326fac2bdce00074ce6c5046b861d310638aee2b9de1ed241ba7eb32bdc83898", size = 5199566, upload-time = "2026-05-05T18:07:21.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/d0/8715ee837c73314a02767d20652cc312d1b6ff6733fa00f52de2b648bc3a/prek-0.3.13-py3-none-win_amd64.whl", hash = "sha256:841049f89f5ec9f4035299283d11e566ac5a068e3742ead1055ea04f886831fc", size = 5589599, upload-time = "2026-05-05T18:06:57.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/cf/0af0b15be0ebd82f7e50adee149b05a73533d78cb1b97cb889f0647ebffe/prek-0.3.13-py3-none-win_arm64.whl", hash = "sha256:a9fd74e0aec550c6b8d41076fdcdd6ff121cd7d94d743c1338bd794784e3c775", size = 5419029, upload-time = "2026-05-05T18:06:59.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1195,27 +1195,27 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.15.11"
|
||||
version = "0.15.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1246,7 +1246,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "seleniumbase"
|
||||
version = "4.48.2"
|
||||
version = "4.48.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "attrs" },
|
||||
@@ -1309,9 +1309,9 @@ dependencies = [
|
||||
{ name = "wheel" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/07/3d/5b14700c7329375144d97bab149647489556da043c454206b64bfff24dd3/seleniumbase-4.48.2.tar.gz", hash = "sha256:593ddaeb7e2d48cf3df4bf366c5eecfad12a34220ac45b5c894eeacb1f485bd1", size = 656769, upload-time = "2026-04-18T02:30:16.732Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b4/bc/08d186179157ffb214c483da88c061145ac86207829565b320de86d6375e/seleniumbase-4.48.4.tar.gz", hash = "sha256:014b5303914aa18d83467bf1866a3598a35a58cccf039d051e4bd693b217bd18", size = 656881, upload-time = "2026-04-30T19:26:55.055Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/1b/49ffbaf90a72aa99318845761b0601c1ab3a68e57560fed40bbbbc2164e0/seleniumbase-4.48.2-py3-none-any.whl", hash = "sha256:6e82eccb2aebebb285f3f091a068744b11ee9af644844d71671eab3c3400c00c", size = 663581, upload-time = "2026-04-18T02:30:13.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/b2/d284f5b740cf4cb605d653c0d3b6792cf1b931d573af274d46bef08bede9/seleniumbase-4.48.4-py3-none-any.whl", hash = "sha256:900763122c82b14372698580b19fb39d3d86e958bffc16c5206f5554f8f7c7ed", size = 663699, upload-time = "2026-04-30T19:26:51.658Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1370,8 +1370,8 @@ dev = [
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "apprise", specifier = ">=1.9.0" },
|
||||
{ name = "authlib", specifier = ">=1.7.0,<1.8" },
|
||||
{ name = "apprise", specifier = ">=1.10.0" },
|
||||
{ name = "authlib", specifier = ">=1.7.2,<1.8" },
|
||||
{ name = "beautifulsoup4" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "dnspython" },
|
||||
@@ -1390,7 +1390,7 @@ requires-dist = [
|
||||
{ name = "qbittorrent-api" },
|
||||
{ name = "rarfile" },
|
||||
{ name = "requests", extras = ["socks"] },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.48.2" },
|
||||
{ name = "seleniumbase", marker = "extra == 'browser'", specifier = "==4.48.4" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "transmission-rpc" },
|
||||
]
|
||||
@@ -1403,7 +1403,7 @@ dev = [
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.8.0" },
|
||||
{ name = "ruff", specifier = "==0.15.11" },
|
||||
{ name = "ruff", specifier = "==0.15.12" },
|
||||
{ name = "vulture", specifier = ">=2.14" },
|
||||
]
|
||||
|
||||
@@ -1457,14 +1457,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tabcompleter"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/1a/ed3544579628c5709bae6fae2255e94c6982a9ff77d42d8ba59fd2f3b21a/tabcompleter-1.4.0.tar.gz", hash = "sha256:7562a9938e62f8e7c3be612c3ac4e14c5ec4307b58ba9031c148260e866e8814", size = 10431, upload-time = "2024-10-28T00:44:52.665Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0c/f1/6cc9c5ff8905fa1bd963eedbe11380b48bb980b24ae5ef2aa1c33523db66/tabcompleter-1.4.1.tar.gz", hash = "sha256:8894c3fba38371aef27358225acf9aed89937e6a12999844bd525a424ee380a6", size = 8764, upload-time = "2026-04-28T19:32:49.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/44/bb509c3d2c0b5a87e7a5af1d5917a402a32ff026f777a6d7cb6990746cbb/tabcompleter-1.4.0-py3-none-any.whl", hash = "sha256:d744aa735b49c0a6cc2fb8fcd40077fec47425e4388301010b14e6ce3311368b", size = 6725, upload-time = "2024-10-28T00:44:51.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/fc/8c82be70b8f96d09943360f34cfb2ecdd3035294c51bce4131eeabe56645/tabcompleter-1.4.1-py3-none-any.whl", hash = "sha256:26b5cf330a48f32625b00e1664aa589f67c8e98275b6d9c2b85d19917dac1601", size = 6922, upload-time = "2026-04-28T19:32:48.01Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1543,11 +1543,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "urllib3"
|
||||
version = "2.6.3"
|
||||
version = "2.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -1614,14 +1614,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "wheel"
|
||||
version = "0.46.3"
|
||||
version = "0.47.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/89/24/a2eb353a6edac9a0303977c4cb048134959dd2a51b48a269dfc9dde00c8a/wheel-0.46.3.tar.gz", hash = "sha256:e3e79874b07d776c40bd6033f8ddf76a7dad46a7b8aa1b2787a83083519a1803", size = 60605, upload-time = "2026-01-22T12:39:49.136Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/39/62/75f18a0f03b4219c456652c7780e4d749b929eb605c098ce3a5b6b6bc081/wheel-0.47.0.tar.gz", hash = "sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3", size = 63854, upload-time = "2026-04-22T15:51:27.727Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/87/22/b76d483683216dde3d67cba61fb2444be8d5be289bf628c13fc0fd90e5f9/wheel-0.46.3-py3-none-any.whl", hash = "sha256:4b399d56c9d9338230118d705d9737a2a468ccca63d5e813e2a4fc7815d8bc4d", size = 30557, upload-time = "2026-01-22T12:39:48.099Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/1b/9e33c09813d65e248f7f773119148a612516a4bea93e9c6f545f78455b7c/wheel-0.47.0-py3-none-any.whl", hash = "sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced", size = 32218, upload-time = "2026-04-22T15:51:26.296Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user