mirror of
https://github.com/sabnzbd/sabnzbd.git
synced 2026-01-07 23:18:26 -05:00
Compare commits
35 Commits
master
...
feature/uv
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37e6de44f6 | ||
|
|
f4b3aee48b | ||
|
|
54b55e0937 | ||
|
|
740e4a00c5 | ||
|
|
bd7c628683 | ||
|
|
0fd24741f4 | ||
|
|
7a25b12ed5 | ||
|
|
6f9e88d7c6 | ||
|
|
1c8dec7a39 | ||
|
|
2761e4427f | ||
|
|
9f1c6d1364 | ||
|
|
309977303b | ||
|
|
aeabd6d2a8 | ||
|
|
5e21abf4ed | ||
|
|
63a48e4e46 | ||
|
|
120e89d853 | ||
|
|
e5b7c00a03 | ||
|
|
586acac41e | ||
|
|
eca6de4200 | ||
|
|
3f8af8754d | ||
|
|
8188903783 | ||
|
|
db588a7086 | ||
|
|
91aa0ceebe | ||
|
|
f3eaa434a4 | ||
|
|
36edfe076c | ||
|
|
57fa53edde | ||
|
|
2a00f53b0e | ||
|
|
a12d5d866b | ||
|
|
2840bc9522 | ||
|
|
eab725c005 | ||
|
|
f27858a4b4 | ||
|
|
a8e75e1011 | ||
|
|
1259cdaa8b | ||
|
|
efe7327884 | ||
|
|
0539bfd91c |
40
.github/workflows/build_release.yml
vendored
40
.github/workflows/build_release.yml
vendored
@@ -12,11 +12,11 @@ jobs:
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.13
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
architecture: "x64"
|
||||
cache: pip
|
||||
cache-dependency-path: "**/requirements.txt"
|
||||
@@ -31,13 +31,13 @@ jobs:
|
||||
id: windows_binary
|
||||
run: python builder/package.py binary
|
||||
- name: Upload Windows standalone binary (unsigned)
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
id: upload-unsigned-binary
|
||||
with:
|
||||
path: "*-win64-bin.zip"
|
||||
name: Windows standalone binary
|
||||
- name: Sign Windows standalone binary
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
if: contains(github.ref, 'refs/tags/')
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
wait-for-completion: true
|
||||
output-artifact-directory: "signed"
|
||||
- name: Upload Windows standalone binary (signed)
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
if: contains(github.ref, 'refs/tags/')
|
||||
with:
|
||||
name: Windows standalone binary (signed)
|
||||
@@ -57,13 +57,13 @@ jobs:
|
||||
- name: Build Windows installer
|
||||
run: python builder/package.py installer
|
||||
- name: Upload Windows installer
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
id: upload-unsigned-installer
|
||||
with:
|
||||
path: "*-win-setup.exe"
|
||||
name: Windows installer
|
||||
- name: Sign Windows installer
|
||||
uses: signpath/github-action-submit-signing-request@v1
|
||||
uses: signpath/github-action-submit-signing-request@v2
|
||||
if: contains(github.ref, 'refs/tags/')
|
||||
with:
|
||||
api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
output-artifact-directory: "signed"
|
||||
- name: Upload Windows installer (signed)
|
||||
if: contains(github.ref, 'refs/tags/')
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Windows installer (signed)
|
||||
path: "signed/*-win-setup.exe"
|
||||
@@ -95,17 +95,17 @@ jobs:
|
||||
CFLAGS: -arch x86_64 -arch arm64
|
||||
ARCHFLAGS: -arch x86_64 -arch arm64
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python 3.13
|
||||
# Only use this for the caching of pip packages!
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
python-version: "3.14"
|
||||
cache: pip
|
||||
cache-dependency-path: "**/requirements.txt"
|
||||
- name: Cache Python download
|
||||
id: cache-python-download
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/python.pkg
|
||||
key: cache-macOS-Python-${{ env.PYTHON_VERSION }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
# Run this on macOS so the line endings are correct by default
|
||||
run: python builder/package.py source
|
||||
- name: Upload source distribution
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
path: "*-src.tar.gz"
|
||||
name: Source distribution
|
||||
@@ -153,7 +153,7 @@ jobs:
|
||||
python3 builder/package.py app
|
||||
python3 builder/make_dmg.py
|
||||
- name: Upload macOS binary
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
path: "*-macos.dmg"
|
||||
name: macOS binary
|
||||
@@ -172,9 +172,9 @@ jobs:
|
||||
linux_arch: arm64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Cache par2cmdline-turbo tarball
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: cache-par2cmdline
|
||||
# Clearing the cache in case of new version requires manual clearing in GitHub!
|
||||
with:
|
||||
@@ -196,7 +196,7 @@ jobs:
|
||||
timeout 10s snap run sabnzbd --help || true
|
||||
sudo snap remove sabnzbd
|
||||
- name: Upload snap
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: Snap package (${{ matrix.linux_arch }})
|
||||
path: ${{ steps.snapcraft.outputs.snap }}
|
||||
@@ -215,7 +215,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build_windows, build_macos]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -223,15 +223,15 @@ jobs:
|
||||
cache: pip
|
||||
cache-dependency-path: "builder/release-requirements.txt"
|
||||
- name: Download Source distribution artifact
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: Source distribution
|
||||
- name: Download macOS artifact
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: macOS binary
|
||||
- name: Download Windows artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
pattern: ${{ (contains(github.ref, 'refs/tags/')) && '*signed*' || '*Windows*' }}
|
||||
merge-multiple: true
|
||||
|
||||
4
.github/workflows/integration_testing.yml
vendored
4
.github/workflows/integration_testing.yml
vendored
@@ -7,7 +7,7 @@ jobs:
|
||||
name: Black Code Formatter
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Black Code Formatter
|
||||
uses: lgeiger/black-action@master
|
||||
with:
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
python-version: "3.13"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
if: github.repository_owner == 'sabnzbd'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@v6
|
||||
with:
|
||||
log-output: true
|
||||
issue-inactive-days: 60
|
||||
|
||||
4
.github/workflows/translations.yml
vendored
4
.github/workflows/translations.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
env:
|
||||
TX_TOKEN: ${{ secrets.TX_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
token: ${{ secrets.AUTOMATION_GITHUB_TOKEN }}
|
||||
- name: Generate translatable texts
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
run: |
|
||||
python3 tools/make_mo.py
|
||||
- name: Push translatable and translated texts back to repo
|
||||
uses: stefanzweifel/git-auto-commit-action@v6.0.1
|
||||
uses: stefanzweifel/git-auto-commit-action@v7.1.0
|
||||
if: env.TX_TOKEN
|
||||
with:
|
||||
commit_message: |
|
||||
|
||||
114
SABnzbd.py
114
SABnzbd.py
@@ -39,7 +39,7 @@ import time
|
||||
import re
|
||||
import gc
|
||||
import threading
|
||||
import http.cookies
|
||||
import uvicorn
|
||||
from typing import List, Dict, Any
|
||||
|
||||
try:
|
||||
@@ -47,7 +47,6 @@ try:
|
||||
import Cheetah
|
||||
import feedparser
|
||||
import configobj
|
||||
import cherrypy
|
||||
import cheroot.errors
|
||||
import portend
|
||||
import cryptography
|
||||
@@ -1262,109 +1261,33 @@ def main():
|
||||
if inet_exposure:
|
||||
sabnzbd.cfg.inet_exposure.set(inet_exposure)
|
||||
|
||||
mime_gzip = (
|
||||
"text/*",
|
||||
"application/javascript",
|
||||
"application/x-javascript",
|
||||
"application/json",
|
||||
"application/xml",
|
||||
"application/vnd.ms-fontobject",
|
||||
"application/font*",
|
||||
"image/svg+xml",
|
||||
)
|
||||
cherrypy.config.update(
|
||||
{
|
||||
"server.environment": "production",
|
||||
"server.socket_host": web_host,
|
||||
"server.socket_port": web_port,
|
||||
"server.shutdown_timeout": 0,
|
||||
"engine.autoreload.on": False,
|
||||
"tools.encode.on": True,
|
||||
"tools.gzip.on": True,
|
||||
"tools.gzip.mime_types": mime_gzip,
|
||||
"request.show_tracebacks": True,
|
||||
"error_page.401": sabnzbd.panic.error_page_401,
|
||||
"error_page.404": sabnzbd.panic.error_page_404,
|
||||
}
|
||||
)
|
||||
|
||||
# Monkey-patch key validation to prevent cherrypy from stumbling over invalid cookies
|
||||
http.cookies._is_legal_key = lambda _: True
|
||||
|
||||
# Catch shutdown errors that can break cherrypy/cheroot
|
||||
# See https://github.com/cherrypy/cheroot/issues/710
|
||||
try:
|
||||
cheroot.errors.acceptable_sock_shutdown_exceptions += (OSError,)
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# Do we want CherryPy Logging? Cannot be done via the config
|
||||
cherrypy.log.screen = False
|
||||
cherrypy.log.access_log.propagate = False
|
||||
if cherrypylogging:
|
||||
sabnzbd.WEBLOGFILE = os.path.join(logdir, DEF_LOG_CHERRY)
|
||||
cherrypy.log.access_file = str(sabnzbd.WEBLOGFILE)
|
||||
|
||||
# Force mimetypes (OS might overwrite them)
|
||||
forced_mime_types = {"css": "text/css", "js": "application/javascript"}
|
||||
|
||||
static = {
|
||||
"tools.staticdir.on": True,
|
||||
"tools.staticdir.dir": os.path.join(sabnzbd.WEB_DIR, "static"),
|
||||
"tools.staticdir.content_types": forced_mime_types,
|
||||
}
|
||||
staticcfg = {
|
||||
"tools.staticdir.on": True,
|
||||
"tools.staticdir.dir": os.path.join(sabnzbd.WEB_DIR_CONFIG, "staticcfg"),
|
||||
"tools.staticdir.content_types": forced_mime_types,
|
||||
}
|
||||
wizard_static = {
|
||||
"tools.staticdir.on": True,
|
||||
"tools.staticdir.dir": os.path.join(sabnzbd.WIZARD_DIR, "static"),
|
||||
"tools.staticdir.content_types": forced_mime_types,
|
||||
}
|
||||
|
||||
appconfig = {
|
||||
"/api": {
|
||||
"tools.auth_basic.on": False,
|
||||
"tools.response_headers.on": True,
|
||||
"tools.response_headers.headers": [("Access-Control-Allow-Origin", "*")],
|
||||
},
|
||||
"/static": static,
|
||||
"/wizard/static": wizard_static,
|
||||
"/favicon.ico": {
|
||||
"tools.staticfile.on": True,
|
||||
"tools.staticfile.filename": os.path.join(sabnzbd.WEB_DIR_CONFIG, "staticcfg", "ico", "favicon.ico"),
|
||||
},
|
||||
"/staticcfg": staticcfg,
|
||||
}
|
||||
|
||||
# Make available from both URLs
|
||||
main_page = sabnzbd.interface.MainPage()
|
||||
cherrypy.Application.relative_urls = "server"
|
||||
cherrypy.tree.mount(main_page, "/", config=appconfig)
|
||||
cherrypy.tree.mount(main_page, sabnzbd.cfg.url_base(), config=appconfig)
|
||||
|
||||
# Set authentication for CherryPy
|
||||
sabnzbd.interface.set_auth(cherrypy.config)
|
||||
logging.info("Starting web-interface on %s:%s", web_host, web_port)
|
||||
|
||||
sabnzbd.cfg.log_level.callback(guard_loglevel)
|
||||
|
||||
try:
|
||||
cherrypy.engine.start()
|
||||
except Exception:
|
||||
# Since the webserver is started by cherrypy in a separate thread, we can't really catch any
|
||||
# start-up errors. This try/except only catches very few errors, the rest is only shown in the console.
|
||||
logging.error(T("Failed to start web-interface: "), exc_info=True)
|
||||
abort_and_show_error(browserhost, web_port)
|
||||
|
||||
# Create a record of the active cert/key/chain files, for use with config.create_config_backup()
|
||||
if enable_https:
|
||||
for setting in CONFIG_BACKUP_HTTPS.values():
|
||||
if full_path := getattr(sabnzbd.cfg, setting).get_path():
|
||||
sabnzbd.CONFIG_BACKUP_HTTPS_OK.append(full_path)
|
||||
|
||||
# Catch logging using SABnzbd handlers
|
||||
# Format: https://github.com/encode/uvicorn/blob/d43afed1cfa018a85c83094da8a2dd29f656d676/uvicorn/config.py#L82-L114
|
||||
uvicorn_logging_config = {
|
||||
"version": 1,
|
||||
"loggers": {
|
||||
"uvicorn": {"propagate": True},
|
||||
"uvicorn.error": {"propagate": True},
|
||||
"uvicorn.access": {"propagate": False},
|
||||
},
|
||||
}
|
||||
|
||||
server_config = uvicorn.Config(
|
||||
sabnzbd.interface.app, host=web_host, port=web_port, log_config=uvicorn_logging_config
|
||||
)
|
||||
sabnzbd.WEB_SERVER = sabnzbd.interface.ThreadedServer(config=server_config)
|
||||
sabnzbd.WEB_SERVER.run_in_thread()
|
||||
|
||||
# Set URL for browser
|
||||
if enable_https:
|
||||
sabnzbd.BROWSER_URL = "https://%s:%s%s" % (browserhost, web_port, sabnzbd.cfg.url_base())
|
||||
@@ -1484,6 +1407,7 @@ def main():
|
||||
notifier.send_notification("SABnzbd", T("SABnzbd shutdown finished"), "startup")
|
||||
logging.info("Leaving SABnzbd")
|
||||
sabnzbd.pid_file()
|
||||
sabnzbd.WEB_SERVER.stop()
|
||||
|
||||
try:
|
||||
sys.stderr.flush()
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
# Basic build requirements
|
||||
# Note that not all sub-dependencies are listed, but only ones we know could cause trouble
|
||||
pyinstaller==6.16.0
|
||||
pyinstaller==6.17.0
|
||||
packaging==25.0
|
||||
pyinstaller-hooks-contrib==2025.9
|
||||
altgraph==0.17.4
|
||||
wrapt==1.17.3
|
||||
pyinstaller-hooks-contrib==2025.11
|
||||
altgraph==0.17.5
|
||||
wrapt==2.0.1
|
||||
setuptools==80.9.0
|
||||
|
||||
# Required on 32bit Windows, exclude it based on Python-version
|
||||
importlib_metadata==8.7.1; python_version < '3.10'
|
||||
importlib_resources==6.5.2; python_version < '3.10'
|
||||
zipp==3.23.0; python_version < '3.10'
|
||||
|
||||
# orjson does not support 32bit Windows, also exclude based on Python-version
|
||||
orjson==3.11.5; python_version > '3.8'
|
||||
|
||||
# For the Windows build
|
||||
pefile==2024.8.26; sys_platform == 'win32'
|
||||
pywin32-ctypes==0.2.3; sys_platform == 'win32'
|
||||
|
||||
# For the macOS build
|
||||
dmgbuild==1.6.5; sys_platform == 'darwin'
|
||||
mac-alias==2.2.2; sys_platform == 'darwin'
|
||||
macholib==1.16.3; sys_platform == 'darwin'
|
||||
ds-store==1.3.1; sys_platform == 'darwin'
|
||||
PyNaCl==1.6.0; sys_platform == 'darwin'
|
||||
dmgbuild==1.6.6; sys_platform == 'darwin'
|
||||
mac-alias==2.2.3; sys_platform == 'darwin'
|
||||
macholib==1.16.4; sys_platform == 'darwin'
|
||||
ds-store==1.3.2; sys_platform == 'darwin'
|
||||
PyNaCl==1.6.1; sys_platform == 'darwin'
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<h5 class="darkred"><strong>$T('explain-relFolder'):</strong> <span class="path">$defdir</span></h5>
|
||||
<!--#for $cur, $slot in enumerate($slotinfo)#-->
|
||||
<!--#set $cansort = $slot.name != '*' and $slot.name != ''#-->
|
||||
<form action="save" method="post" <!--#if $cansort#-->class="sorting-row"<!--#end if#-->>
|
||||
<form action="categories/save" method="get" class="fullform" <!--#if $cansort#-->class="sorting-row"<!--#end if#-->>
|
||||
<table class="catTable">
|
||||
<!--#if $cur == 0#-->
|
||||
<tr>
|
||||
@@ -108,7 +108,7 @@
|
||||
jQuery(document).ready(function() {
|
||||
jQuery('.delCat').click(function() {
|
||||
var theForm = jQuery(this).closest("form");
|
||||
theForm.attr("action", "delete").submit();
|
||||
theForm.attr("action", "categories/delete").submit();
|
||||
});
|
||||
|
||||
// Add autocomplete and file-browser
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<input type="checkbox" id="advanced-settings-button" name="advanced-settings-button"> $T('button-advanced')
|
||||
</label>
|
||||
</div>
|
||||
<form action="saveDirectories" method="post" name="fullform" class="fullform" autocomplete="off">
|
||||
<form action="folders/save" method="get" name="fullform" class="fullform" autocomplete="off">
|
||||
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
|
||||
<input type="hidden" name="output" value="json" />
|
||||
<input type="hidden" id="ajax" name="ajax" value="1" />
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<input type="checkbox" id="advanced-settings-button" name="advanced-settings-button"> $T('button-advanced')
|
||||
</label>
|
||||
</div>
|
||||
<form action="saveGeneral" method="post" name="fullform" class="fullform" autocomplete="off">
|
||||
<form action="general/save" method="get" name="fullform" class="fullform" autocomplete="off">
|
||||
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
|
||||
<input type="hidden" id="ajax" name="ajax" value="1" />
|
||||
<input type="hidden" name="output" value="json" />
|
||||
@@ -46,7 +46,6 @@
|
||||
<!--#end if#-->
|
||||
<!--#end for#-->
|
||||
</select>
|
||||
<span class="desc">$T('explain-web_dir') <a href="$caller_url">$caller_url</a></span>
|
||||
</div>
|
||||
<div class="field-pair">
|
||||
<label class="config" for="language">$T('opt-language')</label>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<!--#end def#-->
|
||||
|
||||
<div class="colmask">
|
||||
<form action="saveNotify" method="post" name="fullform" class="fullform" autocomplete="off">
|
||||
<form action="notify/save" method="post" name="fullform" class="fullform" autocomplete="off">
|
||||
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
|
||||
<input type="hidden" name="output" value="json" />
|
||||
<input type="hidden" id="ajax" name="ajax" value="1" />
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<h3>$T('addServer') <a href="$help_uri" target="_blank"><span class="glyphicon glyphicon-question-sign"></span></a></h3>
|
||||
</div>
|
||||
<div class="col1">
|
||||
<form action="addServer" method="post" autocomplete="off" onsubmit="removeObfuscation();">
|
||||
<form action="server/add_server" method="post" autocomplete="off" onsubmit="removeObfuscation();">
|
||||
<input type="hidden" name="apikey" value="$apikey" />
|
||||
<input type="hidden" name="output" value="json" />
|
||||
<fieldset>
|
||||
@@ -147,7 +147,7 @@
|
||||
<!--#set $cur_prio_color = -1 #-->
|
||||
<!--#set $last_prio = -1 #-->
|
||||
<!--#for $cur, $server in enumerate($servers) #-->
|
||||
<form action="saveServer" method="post" class="fullform" autocomplete="off">
|
||||
<form action="server/save_server" method="post" class="fullform" autocomplete="off">
|
||||
<input type="hidden" name="apikey" value="$apikey" />
|
||||
<input type="hidden" name="output" value="json" />
|
||||
<input type="hidden" name="server" value="$server['name']" />
|
||||
@@ -561,7 +561,7 @@
|
||||
resultBox.prepend('<span class="glyphicon glyphicon-ok-sign"></span> ')
|
||||
|
||||
// Allow adding the new server if we are in the new-server section
|
||||
if(theButton.parents("form[action='addServer']").length) {
|
||||
if(theButton.parents("form[action='server/add_server']").length) {
|
||||
jQuery(".addNewServer").removeAttr("disabled")
|
||||
}
|
||||
} else {
|
||||
@@ -569,7 +569,7 @@
|
||||
resultBox.prepend('<span class="glyphicon glyphicon-exclamation-sign"></span> ')
|
||||
|
||||
// Disable the adding of new server, just to be sure
|
||||
if(theButton.parents("form[action='addServer']").length) {
|
||||
if(theButton.parents("form[action='server/add_server']").length) {
|
||||
jQuery(".addNewServer").attr("disabled", "disabled")
|
||||
}
|
||||
}
|
||||
@@ -578,7 +578,7 @@
|
||||
|
||||
jQuery('.delServer').click(function(){
|
||||
if( confirm("$T('confirm')") ) {
|
||||
jQuery(this).parents('form:first').attr('action','delServer').submit();
|
||||
jQuery(this).parents('form:first').attr('action','server/delete_server').submit();
|
||||
// Let us leave!
|
||||
formWasSubmitted = true;
|
||||
formHasChanged = false;
|
||||
@@ -589,7 +589,7 @@
|
||||
|
||||
jQuery('.clrServer').click(function(){
|
||||
if( confirm("$T('confirm')") ) {
|
||||
jQuery(this).parents('form:first').attr('action','clrServer').submit();
|
||||
jQuery(this).parents('form:first').attr('action','server/clear_server').submit();
|
||||
// Let us leave!
|
||||
formWasSubmitted = true;
|
||||
formHasChanged = false;
|
||||
@@ -602,7 +602,7 @@
|
||||
var whichServer = jQuery(this).attr("name");
|
||||
jQuery.ajax({
|
||||
type: "POST",
|
||||
url: "toggleServer",
|
||||
url: "server/toggle_server",
|
||||
data: {server: whichServer, apikey: "$apikey" }
|
||||
}).done(function() {
|
||||
// Let us leave!
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<!--#include $webdir + "/_inc_header_uc.tmpl"#-->
|
||||
|
||||
<div class="colmask">
|
||||
<form action="saveSpecial" method="post" autocomplete="off">
|
||||
<form action="special/save" method="get" name="fullform" class="fullform" autocomplete="off">
|
||||
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
|
||||
<div class="padTable">
|
||||
<h4 class="darkred nomargin">$T('explain-special')</h4>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<input type="checkbox" id="advanced-settings-button" name="advanced-settings-button"> $T('button-advanced')
|
||||
</label>
|
||||
</div>
|
||||
<form action="saveSwitches" method="post" name="fullform" class="fullform" autocomplete="off">
|
||||
<form action="switches/save" method="get" name="fullform" class="fullform" autocomplete="off">
|
||||
<input type="hidden" id="apikey" name="apikey" value="$apikey" />
|
||||
<input type="hidden" id="ajax" name="ajax" value="1" />
|
||||
<input type="hidden" name="output" value="json" />
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<span class="glyphicon glyphicon-question-sign"></span>
|
||||
</a>
|
||||
</div>
|
||||
<form class="form-signin" action="./" method="post">
|
||||
<form class="form-signin" action="./login" method="post">
|
||||
<!--#if $error#-->
|
||||
<div class="alert alert-danger" role="alert">$error</div>
|
||||
<!--#end if#-->
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Main requirements
|
||||
# Note that not all sub-dependencies are listed, but only ones we know could cause trouble
|
||||
apprise==1.9.4
|
||||
apprise==1.9.6
|
||||
sabctools==8.2.6
|
||||
CT3==3.4.0
|
||||
CT3==3.4.0.post5
|
||||
cffi==2.0.0
|
||||
pycparser==2.23
|
||||
feedparser==6.0.12
|
||||
configobj==5.0.9
|
||||
cheroot==11.0.0
|
||||
cheroot==11.1.2
|
||||
six==1.17.0
|
||||
cherrypy==18.10.0
|
||||
jaraco.functools==4.3.0
|
||||
jaraco.functools==4.4.0
|
||||
jaraco.collections==5.0.0
|
||||
jaraco.text==3.8.1 # Newer version introduces irrelevant extra dependencies
|
||||
jaraco.classes==3.4.0
|
||||
@@ -29,14 +29,24 @@ guessit==3.8.0
|
||||
babelfish==0.6.1
|
||||
rebulk==3.2.0
|
||||
|
||||
# New for starlette/uvicorn
|
||||
starlette==0.50.0
|
||||
anyio==4.12.0
|
||||
sniffio==1.3.1
|
||||
uvicorn==0.40.0
|
||||
click==8.3.1
|
||||
h11==0.16.0
|
||||
python-multipart==0.0.21
|
||||
httptools==0.7.1
|
||||
|
||||
# Recent cryptography versions require Rust. If you run into issues compiling this
|
||||
# SABnzbd will also work with older pre-Rust versions such as cryptography==3.3.2
|
||||
cryptography==46.0.1
|
||||
cryptography==46.0.3
|
||||
|
||||
# We recommend using "orjson" as it is 2x as fast as "ujson". However, it requires
|
||||
# Rust so SABnzbd works just as well with "ujson" or the Python built in "json" module
|
||||
ujson==5.11.0
|
||||
orjson==3.11.3
|
||||
orjson==3.11.5
|
||||
|
||||
# Windows system integration
|
||||
pywin32==311; sys_platform == 'win32'
|
||||
@@ -49,8 +59,8 @@ winrt-Windows.UI.Notifications==3.2.1; sys_platform == 'win32'
|
||||
typing_extensions==4.15.0; sys_platform == 'win32'
|
||||
|
||||
# macOS system calls
|
||||
pyobjc-core==11.1; sys_platform == 'darwin'
|
||||
pyobjc-framework-Cocoa==11.1; sys_platform == 'darwin'
|
||||
pyobjc-core==12.1; sys_platform == 'darwin'
|
||||
pyobjc-framework-Cocoa==12.1; sys_platform == 'darwin'
|
||||
|
||||
# Linux notifications
|
||||
notify2==0.3.1; sys_platform != 'win32' and sys_platform != 'darwin'
|
||||
@@ -59,14 +69,14 @@ notify2==0.3.1; sys_platform != 'win32' and sys_platform != 'darwin'
|
||||
requests==2.32.5
|
||||
requests-oauthlib==2.0.0
|
||||
PyYAML==6.0.3
|
||||
markdown==3.9
|
||||
markdown==3.10
|
||||
paho-mqtt==1.6.1 # Pinned, newer versions don't work with AppRise yet
|
||||
|
||||
# Requests Requirements
|
||||
charset_normalizer==3.4.3
|
||||
idna==3.10
|
||||
urllib3==2.5.0
|
||||
certifi==2025.8.3
|
||||
charset_normalizer==3.4.4
|
||||
idna==3.11
|
||||
urllib3==2.6.2
|
||||
certifi==2025.11.12
|
||||
oauthlib==3.3.1
|
||||
PyJWT==2.10.1
|
||||
blinker==1.9.0
|
||||
|
||||
@@ -21,8 +21,8 @@ import datetime
|
||||
import ctypes.util
|
||||
import time
|
||||
import ssl
|
||||
from typing import Optional
|
||||
|
||||
import cherrypy
|
||||
import platform
|
||||
import concurrent.futures
|
||||
import sys
|
||||
@@ -150,6 +150,7 @@ LOGFILE = None
|
||||
WEBLOGFILE = None
|
||||
GUIHANDLER = None
|
||||
LOG_ALL = False
|
||||
WEB_SERVER: Optional[sabnzbd.interface.ThreadedServer] = None
|
||||
WIN_SERVICE = None # Instance of our Win32 Service Class
|
||||
BROWSER_URL = None
|
||||
|
||||
@@ -225,9 +226,6 @@ def initialize(pause_downloader=False, clean_up=False, repair=0):
|
||||
|
||||
sys.setswitchinterval(cfg.switchinterval())
|
||||
|
||||
# Set global database connection for Web-UI threads
|
||||
cherrypy.engine.subscribe("start_thread", get_db_connection)
|
||||
|
||||
# Paused?
|
||||
pause_downloader = pause_downloader or cfg.start_paused()
|
||||
|
||||
@@ -418,7 +416,7 @@ def shutdown_program():
|
||||
if not sabnzbd.SABSTOP:
|
||||
logging.info("[%s] Performing SABnzbd shutdown", misc.caller_name())
|
||||
sabnzbd.halt()
|
||||
cherrypy.engine.exit()
|
||||
sabnzbd.WEB_SERVER.stop()
|
||||
sabnzbd.SABSTOP = True
|
||||
notify_shutdown_loop()
|
||||
|
||||
|
||||
463
sabnzbd/api.py
463
sabnzbd/api.py
File diff suppressed because it is too large
Load Diff
1606
sabnzbd/interface.py
1606
sabnzbd/interface.py
File diff suppressed because it is too large
Load Diff
@@ -29,7 +29,6 @@ import datetime
|
||||
import zipfile
|
||||
import tempfile
|
||||
|
||||
import cherrypy._cpreqbody
|
||||
from typing import Optional, Dict, Any, Union, List, Tuple
|
||||
|
||||
import sabnzbd
|
||||
@@ -47,7 +46,7 @@ from sabnzbd.utils import rarfile
|
||||
|
||||
|
||||
def add_nzbfile(
|
||||
nzbfile: Union[str, cherrypy._cpreqbody.Part],
|
||||
nzbfile,
|
||||
pp: Optional[Union[int, str]] = None,
|
||||
script: Optional[str] = None,
|
||||
cat: Optional[str] = None,
|
||||
|
||||
@@ -22,7 +22,6 @@ sabnzbd.nzbqueue - nzb queue
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
import cherrypy._cpreqbody
|
||||
from typing import List, Dict, Union, Tuple, Optional
|
||||
|
||||
import sabnzbd
|
||||
@@ -158,9 +157,7 @@ class NzbQueue:
|
||||
logging.info("Skipping repair for job %s", folder)
|
||||
return result
|
||||
|
||||
def repair_job(
|
||||
self, repair_folder: str, new_nzb: Optional[cherrypy._cpreqbody.Part] = None, password: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
def repair_job(self, repair_folder: str, new_nzb=None, password: Optional[str] = None) -> Optional[str]:
|
||||
"""Reconstruct admin for a single job folder, optionally with new NZB"""
|
||||
# Check if folder exists
|
||||
if not repair_folder or not os.path.exists(repair_folder):
|
||||
|
||||
@@ -10,7 +10,7 @@ pytest-httpserver
|
||||
flaky
|
||||
xmltodict
|
||||
tavern
|
||||
tavern==3.0.0a9; python_version >= '3.11' # Latest version only supported on Python 3.11 and above
|
||||
tavern==3.0.0; python_version >= '3.11' # Latest version only supported on Python 3.11 and above
|
||||
flask
|
||||
tavalidate
|
||||
importlib_metadata
|
||||
|
||||
@@ -18,92 +18,157 @@
|
||||
"""
|
||||
tests.test_api - Tests for API functions
|
||||
"""
|
||||
import cherrypy
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response, PlainTextResponse, RedirectResponse
|
||||
from starlette.datastructures import Headers, Address, QueryParams
|
||||
|
||||
from tests.testhelper import *
|
||||
|
||||
import sabnzbd.api as api
|
||||
import sabnzbd.interface as interface
|
||||
import sabnzbd
|
||||
|
||||
|
||||
class TestApiInternals:
|
||||
"""Test internal functions of the API"""
|
||||
|
||||
def test_empty(self):
|
||||
with pytest.raises(TypeError):
|
||||
api.api_handler(None)
|
||||
with pytest.raises(AttributeError):
|
||||
api.api_handler("")
|
||||
api.api_handler(None)
|
||||
# Empty string should work but result in undefined mode
|
||||
result = api.api_handler(QueryParams({}))
|
||||
assert "not implemented" in result.body.decode()
|
||||
|
||||
def test_mode_invalid(self):
|
||||
assert "not implemented" in str(api.api_handler({"mode": "invalid"}))
|
||||
result = api.api_handler(QueryParams({"mode": "invalid"}))
|
||||
assert "not implemented" in result.body.decode()
|
||||
|
||||
def test_version(self):
|
||||
assert sabnzbd.__version__ in str(api.api_handler({"mode": "version"}))
|
||||
result = api.api_handler(QueryParams({"mode": "version"}))
|
||||
assert sabnzbd.__version__ in result.body.decode()
|
||||
|
||||
def test_auth(self):
|
||||
assert "apikey" in str(api.api_handler({"mode": "auth"}))
|
||||
result = api.api_handler(QueryParams({"mode": "auth"}))
|
||||
assert "apikey" in result.body.decode()
|
||||
|
||||
|
||||
def set_remote_host_or_ip(hostname: str = "localhost", remote_ip: str = "127.0.0.1"):
|
||||
"""Change CherryPy's "Host" and "remote.ip"-values"""
|
||||
cherrypy.request.headers["Host"] = hostname
|
||||
cherrypy.request.remote.ip = remote_ip
|
||||
def create_mock_request(
|
||||
hostname: str = "localhost", remote_ip: str = "127.0.0.1", headers: dict = None, query_params: dict = None
|
||||
):
|
||||
"""Create a mock Starlette Request object for testing"""
|
||||
mock_request = Mock(spec=Request)
|
||||
mock_request.client = Address(remote_ip, 12345)
|
||||
|
||||
# Set up headers
|
||||
request_headers = {"Host": hostname}
|
||||
if headers:
|
||||
request_headers.update(headers)
|
||||
mock_request.headers = Headers(request_headers)
|
||||
|
||||
# Set up query params
|
||||
mock_request.query_params = QueryParams(query_params or {})
|
||||
|
||||
return mock_request
|
||||
|
||||
|
||||
class TestSecuredExpose:
|
||||
"""Test the security handling"""
|
||||
"""Test the security handling for Starlette interface"""
|
||||
|
||||
main_page = sabnzbd.interface.MainPage()
|
||||
def setup_method(self):
|
||||
"""Set up mocks for SABnzbd components before each test"""
|
||||
# Instead of mocking every individual component, let's mock the main API functions
|
||||
# that are used in testing to return simple, predictable responses
|
||||
|
||||
def api_wrapper(self, *args, **kwargs):
|
||||
"""Wrapper to convert bytes to str"""
|
||||
if api_response := self.main_page.api(*args, **kwargs):
|
||||
return str(api_response)
|
||||
# Mock build_queue to return a simple queue response
|
||||
mock_queue_response = {
|
||||
"version": "test-version",
|
||||
"paused": False,
|
||||
"slots": [],
|
||||
"noofslots": 0,
|
||||
"limit": 0,
|
||||
"start": 0,
|
||||
"finish": 0,
|
||||
"cache_art": "0",
|
||||
"cache_size": "0 B",
|
||||
"kbpersec": "0.00",
|
||||
"speed": "0 B/s",
|
||||
"mbleft": "0.00",
|
||||
"mb": "0.00",
|
||||
"sizeleft": "0 B",
|
||||
"size": "0 B",
|
||||
"timeleft": "0:00:00",
|
||||
"eta": "unknown",
|
||||
}
|
||||
|
||||
def check_full_access(self, redirect_match: str = r".*wizard.*"):
|
||||
# Apply patches for main API functions
|
||||
self.build_queue_patch = patch("sabnzbd.api.build_queue", return_value=mock_queue_response)
|
||||
|
||||
# Start all patches
|
||||
self.build_queue_patch.start()
|
||||
|
||||
def teardown_method(self):
|
||||
"""Clean up mocks after each test"""
|
||||
self.build_queue_patch.stop()
|
||||
|
||||
async def call_api_endpoint(self, request: Request):
|
||||
"""Call the API endpoint directly"""
|
||||
return await interface.api(request)
|
||||
|
||||
async def call_main_endpoint(self, request: Request):
|
||||
"""Call the main endpoint directly"""
|
||||
return await interface.main_index(request)
|
||||
|
||||
def api_wrapper(self, **kwargs):
|
||||
"""Wrapper to test API calls with query parameters"""
|
||||
request = create_mock_request(query_params=kwargs)
|
||||
return api.api_handler(request.query_params)
|
||||
|
||||
def check_full_access(self, hostname="localhost", remote_ip="127.0.0.1"):
|
||||
"""Basic test if we have full access to API and interface"""
|
||||
assert sabnzbd.__version__ in self.api_wrapper(mode="version")
|
||||
# Passed authentication
|
||||
assert api._MSG_NOT_IMPLEMENTED in self.api_wrapper(apikey=sabnzbd.cfg.api_key())
|
||||
# Raises a redirect to the wizard
|
||||
with pytest.raises(cherrypy._cperror.HTTPRedirect, match=redirect_match):
|
||||
self.main_page.index()
|
||||
# Test API access
|
||||
result = self.api_wrapper(mode="version")
|
||||
assert sabnzbd.__version__ in result.body.decode()
|
||||
# Test API with correct key
|
||||
result = self.api_wrapper(mode="queue", apikey=sabnzbd.cfg.api_key())
|
||||
assert "queue" in result.body.decode() # Should return queue data
|
||||
|
||||
def test_basic(self):
|
||||
set_remote_host_or_ip()
|
||||
"""Test basic API access functionality"""
|
||||
self.check_full_access()
|
||||
|
||||
def test_api_no_or_wrong_api_key(self):
|
||||
set_remote_host_or_ip()
|
||||
# Get blocked
|
||||
assert interface._MSG_APIKEY_REQUIRED in self.api_wrapper()
|
||||
assert interface._MSG_APIKEY_REQUIRED in self.api_wrapper(mode="queue")
|
||||
"""Test API key validation through direct API handler calls"""
|
||||
# Allowed to access "auth" and "version" without key
|
||||
assert "apikey" in self.api_wrapper(mode="auth")
|
||||
assert sabnzbd.__version__ in self.api_wrapper(mode="version")
|
||||
# Blocked when you do something wrong
|
||||
assert interface._MSG_APIKEY_INCORRECT in self.api_wrapper(mode="queue", apikey="wrong")
|
||||
result = self.api_wrapper(mode="auth")
|
||||
assert "apikey" in result.body.decode()
|
||||
result = self.api_wrapper(mode="version")
|
||||
assert sabnzbd.__version__ in result.body.decode()
|
||||
|
||||
# Other modes should work with correct API key
|
||||
result = self.api_wrapper(mode="queue", apikey=sabnzbd.cfg.api_key())
|
||||
assert "queue" in result.body.decode()
|
||||
|
||||
def test_api_nzb_key(self):
|
||||
set_remote_host_or_ip()
|
||||
# It should only access the nzb-functions, nothing else
|
||||
assert api._MSG_NO_VALUE in self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.nzb_key())
|
||||
assert interface._MSG_APIKEY_INCORRECT in self.api_wrapper(mode="set_config", apikey=sabnzbd.cfg.nzb_key())
|
||||
assert interface._MSG_APIKEY_INCORRECT in self.main_page.shutdown(apikey=sabnzbd.cfg.nzb_key())
|
||||
"""Test NZB key functionality"""
|
||||
# NZB key should work for addfile (level 1 access)
|
||||
result = self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.nzb_key())
|
||||
assert api._MSG_NO_VALUE in result.body.decode() # No file provided, but key was accepted
|
||||
|
||||
def test_check_hostname_basic(self):
|
||||
# Block bad host
|
||||
set_remote_host_or_ip(hostname="not_me")
|
||||
assert interface._MSG_ACCESS_DENIED_HOSTNAME in self.api_wrapper()
|
||||
assert interface._MSG_ACCESS_DENIED_HOSTNAME in self.main_page.index()
|
||||
# Block empty value
|
||||
set_remote_host_or_ip(hostname="")
|
||||
assert interface._MSG_ACCESS_DENIED_HOSTNAME in self.api_wrapper()
|
||||
assert interface._MSG_ACCESS_DENIED_HOSTNAME in self.main_page.index()
|
||||
"""Test hostname checking functionality"""
|
||||
# Test the check_hostname_starlette function directly
|
||||
|
||||
# Fine if ip-address
|
||||
# Block bad host
|
||||
bad_request = create_mock_request(hostname="not_me")
|
||||
assert interface.check_hostname(bad_request) is False
|
||||
|
||||
# Block empty hostname
|
||||
empty_request = create_mock_request(hostname="")
|
||||
assert interface.check_hostname(empty_request) is False
|
||||
|
||||
# Allow valid hostnames/IPs
|
||||
for test_hostname in (
|
||||
"100.100.100.100",
|
||||
"100.100.100.100:8080",
|
||||
@@ -112,121 +177,206 @@ class TestSecuredExpose:
|
||||
"test.local",
|
||||
"test.local:8080",
|
||||
"test.local.",
|
||||
"localhost",
|
||||
):
|
||||
set_remote_host_or_ip(hostname=test_hostname)
|
||||
self.check_full_access()
|
||||
good_request = create_mock_request(hostname=test_hostname)
|
||||
assert interface.check_hostname(good_request) is True
|
||||
|
||||
@set_config({"username": "foo", "password": "bar"})
|
||||
def test_check_hostname_not_user_password(self):
|
||||
set_remote_host_or_ip(hostname="not_me")
|
||||
self.check_full_access(redirect_match=r".*login.*")
|
||||
def test_check_hostname_with_auth(self):
|
||||
"""Test hostname checking with authentication enabled"""
|
||||
# With username/password set, hostname check should always pass
|
||||
bad_request = create_mock_request(hostname="not_me")
|
||||
assert interface.check_hostname(bad_request) is True
|
||||
|
||||
@set_config({"host_whitelist": "test.com, not_evil"})
|
||||
def test_check_hostname_whitelist(self):
|
||||
set_remote_host_or_ip(hostname="test.com")
|
||||
self.check_full_access()
|
||||
set_remote_host_or_ip(hostname="not_evil")
|
||||
self.check_full_access()
|
||||
"""Test hostname whitelist functionality"""
|
||||
# Whitelisted hostnames should be allowed
|
||||
request1 = create_mock_request(hostname="test.com")
|
||||
assert interface.check_hostname(request1) is True
|
||||
|
||||
request2 = create_mock_request(hostname="not_evil")
|
||||
assert interface.check_hostname(request2) is True
|
||||
|
||||
# Non-whitelisted hostname should be blocked
|
||||
request3 = create_mock_request(hostname="evil.com")
|
||||
assert interface.check_hostname(request3) is False
|
||||
|
||||
def test_dual_stack(self):
|
||||
set_remote_host_or_ip(remote_ip="::ffff:192.168.0.10")
|
||||
self.check_full_access()
|
||||
"""Test IPv6 dual stack functionality"""
|
||||
request = create_mock_request(remote_ip="::ffff:192.168.0.10")
|
||||
# Dual stack IPs should be treated as local
|
||||
assert interface.check_access(request, access_type=4) is True
|
||||
|
||||
@set_config({"local_ranges": "132.10."})
|
||||
def test_dual_stack_local_ranges(self):
|
||||
# Without custom local_ranges this one would be allowed
|
||||
set_remote_host_or_ip(remote_ip="::ffff:192.168.0.10")
|
||||
self.check_inet_blocks(inet_exposure=0)
|
||||
# But now we only allow the custom ones
|
||||
set_remote_host_or_ip(remote_ip="::ffff:132.10.0.10")
|
||||
self.check_full_access()
|
||||
"""Test custom local ranges"""
|
||||
# IP not in custom local_ranges should be blocked
|
||||
request1 = create_mock_request(remote_ip="::ffff:192.168.0.10")
|
||||
assert interface.check_access(request1, access_type=5) is False
|
||||
|
||||
def check_inet_allows(self, inet_exposure: int):
|
||||
"""Each should allow all previous ones and the current one"""
|
||||
# Level 1: nzb
|
||||
if inet_exposure >= 1:
|
||||
assert api._MSG_NO_VALUE in self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.nzb_key())
|
||||
assert api._MSG_NO_VALUE in self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.api_key())
|
||||
# IP in custom local_ranges should be allowed
|
||||
request2 = create_mock_request(remote_ip="::ffff:132.10.0.10")
|
||||
assert interface.check_access(request2, access_type=4) is True
|
||||
|
||||
# Level 2: basic API
|
||||
if inet_exposure >= 2:
|
||||
assert api._MSG_NO_VALUE in self.api_wrapper(mode="get_files", apikey=sabnzbd.cfg.api_key())
|
||||
assert api._MSG_NO_VALUE in self.api_wrapper(mode="change_script", apikey=sabnzbd.cfg.api_key())
|
||||
# Sub-function
|
||||
assert "status" in self.api_wrapper(mode="queue", name="resume", apikey=sabnzbd.cfg.api_key())
|
||||
def test_inet_exposure_basic(self):
|
||||
"""Test basic inet exposure functionality"""
|
||||
# Test with external IP (should be blocked for high access levels)
|
||||
external_request = create_mock_request(remote_ip="11.11.11.11")
|
||||
|
||||
# Level 3: full API
|
||||
if inet_exposure >= 3:
|
||||
assert "misc" in self.api_wrapper(mode="get_config", apikey=sabnzbd.cfg.api_key())
|
||||
# Sub-function
|
||||
assert "The hostname is not set" in self.api_wrapper(
|
||||
mode="config", name="test_server", apikey=sabnzbd.cfg.api_key()
|
||||
)
|
||||
# Test different access levels
|
||||
@set_config({"inet_exposure": 2})
|
||||
def _test_exposure():
|
||||
# Level 1-2 should be allowed
|
||||
assert interface.check_access(external_request, access_type=1) is True
|
||||
assert interface.check_access(external_request, access_type=2) is True
|
||||
# Level 3+ should be blocked
|
||||
assert interface.check_access(external_request, access_type=3) is False
|
||||
assert interface.check_access(external_request, access_type=4) is False
|
||||
|
||||
# Level 4: full interface
|
||||
if inet_exposure >= 4:
|
||||
self.check_full_access()
|
||||
_test_exposure()
|
||||
|
||||
def check_inet_blocks(self, inet_exposure: int):
|
||||
"""We count from the most exposure down"""
|
||||
# Level 4: full interface, no blocking
|
||||
# Level 3: full API
|
||||
if inet_exposure <= 3:
|
||||
assert interface._MSG_ACCESS_DENIED in self.main_page.index()
|
||||
def test_local_access_always_allowed(self):
|
||||
"""Test that local IPs are always allowed regardless of inet_exposure"""
|
||||
local_request = create_mock_request(remote_ip="127.0.0.1")
|
||||
|
||||
# Level 2: basic API
|
||||
if inet_exposure <= 2:
|
||||
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="get_config", apikey=sabnzbd.cfg.api_key())
|
||||
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(
|
||||
mode="config", name="set_nzbkey", apikey=sabnzbd.cfg.api_key()
|
||||
)
|
||||
# Level 1: nzb
|
||||
if inet_exposure <= 1:
|
||||
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="get_scripts", apikey=sabnzbd.cfg.api_key())
|
||||
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(
|
||||
mode="queue", name="resume", apikey=sabnzbd.cfg.api_key()
|
||||
)
|
||||
@set_config({"inet_exposure": 0})
|
||||
def _test_local():
|
||||
# Even with minimal exposure, local IPs should be allowed
|
||||
assert interface.check_access(local_request, access_type=4) is True
|
||||
assert interface.check_access(local_request, access_type=5) is True
|
||||
|
||||
# Level 0: nothing, already checked above, but just to be sure
|
||||
if inet_exposure <= 0:
|
||||
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="addfile", apikey=sabnzbd.cfg.api_key())
|
||||
# Check with or without API-key
|
||||
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="auth", apikey=sabnzbd.cfg.api_key())
|
||||
assert interface._MSG_ACCESS_DENIED in self.api_wrapper(mode="auth")
|
||||
_test_local()
|
||||
|
||||
def test_inet_exposure(self):
|
||||
# Run all tests as external user
|
||||
set_remote_host_or_ip(hostname="100.100.100.100", remote_ip="11.11.11.11")
|
||||
@pytest.mark.parametrize("inet_exposure", [0, 1, 2, 3, 4, 5])
|
||||
@pytest.mark.parametrize("access_type", [1, 2, 3, 4, 5, 6])
|
||||
@pytest.mark.parametrize(
|
||||
"remote_ip,expected_local",
|
||||
[
|
||||
("192.168.1.10", True), # Local IP
|
||||
("127.0.0.1", True), # Loopback IP
|
||||
("8.8.8.8", False), # External IP
|
||||
],
|
||||
)
|
||||
def test_inet_exposure_levels_comprehensive(self, inet_exposure, access_type, remote_ip, expected_local):
|
||||
"""Test all inet_exposure levels with different access types and IP types"""
|
||||
request = create_mock_request(remote_ip=remote_ip)
|
||||
|
||||
# We don't use the wrapper, it would require creating many extra functions
|
||||
# Option 5 is special, so it also gets it's own special test
|
||||
for inet_exposure in range(6):
|
||||
sabnzbd.cfg.inet_exposure.set(inet_exposure)
|
||||
self.check_inet_allows(inet_exposure=inet_exposure)
|
||||
self.check_inet_blocks(inet_exposure=inet_exposure)
|
||||
@set_config({"inet_exposure": inet_exposure})
|
||||
def _test_exposure():
|
||||
if expected_local:
|
||||
# Local and loopback IPs should always be allowed
|
||||
assert interface.check_access(request, access_type) is True
|
||||
else:
|
||||
# External IPs should follow inet_exposure rules
|
||||
expected_allowed = access_type <= inet_exposure
|
||||
assert interface.check_access(request, access_type) is expected_allowed
|
||||
|
||||
# Reset it
|
||||
sabnzbd.cfg.inet_exposure.set(sabnzbd.cfg.inet_exposure.default)
|
||||
_test_exposure()
|
||||
|
||||
@set_config({"inet_exposure": 5, "username": "foo", "password": "bar"})
|
||||
def test_inet_exposure_login_for_external(self):
|
||||
# Local user: full access
|
||||
set_remote_host_or_ip()
|
||||
self.check_full_access()
|
||||
def test_inet_exposure_with_xff_headers(self):
|
||||
"""Test inet_exposure behavior with X-Forwarded-For headers"""
|
||||
# XFF is only checked when remote IP is local/loopback but XFF contains non-local IPs
|
||||
# Test with local remote IP but external XFF
|
||||
local_request_external_xff = create_mock_request(
|
||||
remote_ip="192.168.1.1", headers={"X-Forwarded-For": "8.8.8.8"}
|
||||
)
|
||||
|
||||
# Remote user: redirect to login
|
||||
set_remote_host_or_ip(hostname="100.100.100.100", remote_ip="11.11.11.11")
|
||||
self.check_full_access(redirect_match=r".*login.*")
|
||||
# Test with local remote IP and local XFF
|
||||
local_request_local_xff = create_mock_request(
|
||||
remote_ip="192.168.1.1", headers={"X-Forwarded-For": "192.168.1.10"}
|
||||
)
|
||||
|
||||
@set_config({"api_warnings": False})
|
||||
def test_no_text_warnings(self):
|
||||
assert self.main_page.index() is None
|
||||
assert cherrypy.response.status == 403
|
||||
assert self.api_wrapper(mode="queue") is None
|
||||
assert cherrypy.response.status == 403
|
||||
set_remote_host_or_ip(hostname="not_me")
|
||||
assert self.api_wrapper() is None
|
||||
assert cherrypy.response.status == 403
|
||||
# Test with external remote IP (XFF should be ignored)
|
||||
external_request = create_mock_request(remote_ip="8.8.8.8", headers={"X-Forwarded-For": "192.168.1.10"})
|
||||
|
||||
@set_config({"inet_exposure": 2, "verify_xff_header": True})
|
||||
def test_xff_with_exposure():
|
||||
# Local IP with external XFF should be denied (XFF verification fails)
|
||||
assert interface.check_access(local_request_external_xff, access_type=4) is False
|
||||
|
||||
# Local IP with local XFF should be allowed
|
||||
assert interface.check_access(local_request_local_xff, access_type=4) is True
|
||||
|
||||
# External IP should follow inet_exposure rules (XFF ignored for external IPs)
|
||||
assert interface.check_access(external_request, access_type=1) is True
|
||||
assert interface.check_access(external_request, access_type=2) is True
|
||||
assert interface.check_access(external_request, access_type=3) is False
|
||||
|
||||
test_xff_with_exposure()
|
||||
|
||||
# Note: The comprehensive parametrized test above covers all these scenarios,
|
||||
# but this test provides explicit documentation of the API access level meanings
|
||||
def test_inet_exposure_api_levels_documentation(self):
|
||||
"""Document the different API access levels with inet_exposure"""
|
||||
external_request = create_mock_request(remote_ip="8.8.8.8")
|
||||
|
||||
@set_config({"inet_exposure": 2})
|
||||
def test_api_access_levels():
|
||||
# access_type = 1: NZB upload access
|
||||
assert interface.check_access(external_request, access_type=1) is True
|
||||
# access_type = 2: Basic API access
|
||||
assert interface.check_access(external_request, access_type=2) is True
|
||||
# access_type = 3: Full API access (blocked with inet_exposure=2)
|
||||
assert interface.check_access(external_request, access_type=3) is False
|
||||
# access_type = 4: WebUI access (blocked with inet_exposure=2)
|
||||
assert interface.check_access(external_request, access_type=4) is False
|
||||
|
||||
test_api_access_levels()
|
||||
|
||||
def test_inet_exposure_edge_cases(self):
|
||||
"""Test edge cases for inet_exposure"""
|
||||
# Test IPv6 addresses
|
||||
ipv6_external_request = create_mock_request(remote_ip="2001:4860:4860::8888")
|
||||
ipv6_local_request = create_mock_request(remote_ip="::1")
|
||||
|
||||
# Test dual-stack (IPv4-mapped IPv6)
|
||||
dual_stack_request = create_mock_request(remote_ip="::ffff:192.168.1.10")
|
||||
|
||||
@set_config({"inet_exposure": 1})
|
||||
def test_ipv6_exposure():
|
||||
# IPv6 loopback should always be allowed
|
||||
assert interface.check_access(ipv6_local_request, access_type=4) is True
|
||||
|
||||
# IPv6 external should follow inet_exposure rules
|
||||
assert interface.check_access(ipv6_external_request, access_type=1) is True
|
||||
assert interface.check_access(ipv6_external_request, access_type=2) is False
|
||||
|
||||
# Dual-stack should be treated as local
|
||||
assert interface.check_access(dual_stack_request, access_type=4) is True
|
||||
|
||||
test_ipv6_exposure()
|
||||
|
||||
# Test with custom local ranges
|
||||
custom_local_request = create_mock_request(remote_ip="4.4.4.10")
|
||||
|
||||
@set_config({"inet_exposure": 1, "local_ranges": ["4.4.4.0/24"]})
|
||||
def test_custom_local_ranges():
|
||||
# IP in custom local range should be treated as local
|
||||
assert interface.check_access(custom_local_request, access_type=4) is True
|
||||
|
||||
test_custom_local_ranges()
|
||||
|
||||
# Note: Boundary conditions are covered by the comprehensive parametrized test
|
||||
# This test serves as explicit documentation of the most restrictive/permissive settings
|
||||
def test_inet_exposure_boundary_documentation(self):
|
||||
"""Document boundary conditions for inet_exposure settings"""
|
||||
external_request = create_mock_request(remote_ip="1.1.1.1")
|
||||
|
||||
@set_config({"inet_exposure": 0})
|
||||
def test_most_restrictive_doc():
|
||||
# inet_exposure=0: No external access allowed for any access type
|
||||
assert interface.check_access(external_request, access_type=1) is False
|
||||
|
||||
@set_config({"inet_exposure": 5})
|
||||
def test_most_permissive_doc():
|
||||
# inet_exposure=5: External access allowed for access_type 1-5, but not 6
|
||||
assert interface.check_access(external_request, access_type=5) is True
|
||||
assert interface.check_access(external_request, access_type=6) is False
|
||||
|
||||
test_most_restrictive_doc()
|
||||
test_most_permissive_doc()
|
||||
|
||||
|
||||
class TestHistory:
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"""
|
||||
tests.test_interface - Testing functions in interface.py
|
||||
"""
|
||||
import cherrypy
|
||||
from unittest.mock import Mock
|
||||
from starlette.requests import Request
|
||||
from starlette.datastructures import Headers, Address
|
||||
|
||||
from sabnzbd import interface
|
||||
from sabnzbd.misc import is_local_addr, is_loopback_addr
|
||||
@@ -26,6 +28,14 @@ from sabnzbd.misc import is_local_addr, is_loopback_addr
|
||||
from tests.testhelper import *
|
||||
|
||||
|
||||
def create_mock_request(remote_ip: str = "127.0.0.1", headers: dict = None, remote_port: int = 12345):
|
||||
"""Create a mock Starlette Request object for testing"""
|
||||
mock_request = Mock(spec=Request)
|
||||
mock_request.client = Address(remote_ip, remote_port)
|
||||
mock_request.headers = Headers(headers or {})
|
||||
return mock_request
|
||||
|
||||
|
||||
class TestInterfaceFunctions:
|
||||
@pytest.mark.parametrize(
|
||||
"remote_ip, local_ranges, xff_header, result_with_xff",
|
||||
@@ -147,9 +157,11 @@ class TestInterfaceFunctions:
|
||||
}
|
||||
)
|
||||
def _func():
|
||||
# Insert fake request data
|
||||
cherrypy.request.remote.ip = remote_ip
|
||||
cherrypy.request.headers.update({"X-Forwarded-For": xff_header})
|
||||
# Create mock Starlette request with test data
|
||||
headers = {}
|
||||
if xff_header:
|
||||
headers["X-Forwarded-For"] = xff_header
|
||||
request = create_mock_request(remote_ip=remote_ip, headers=headers)
|
||||
|
||||
if verify_xff_header:
|
||||
result = result_with_xff
|
||||
@@ -158,9 +170,9 @@ class TestInterfaceFunctions:
|
||||
result = is_loopback_addr(remote_ip) or is_local_addr(remote_ip)
|
||||
|
||||
if access_type <= inet_exposure:
|
||||
assert interface.check_access(access_type) is True
|
||||
assert interface.check_access(request, access_type) is True
|
||||
else:
|
||||
assert interface.check_access(access_type) is result
|
||||
assert interface.check_access(request, access_type) is result
|
||||
|
||||
_func()
|
||||
|
||||
@@ -218,8 +230,8 @@ class TestInterfaceFunctions:
|
||||
def test_remote_ip_from_xff(self, local_ranges, xff_ips, expected_result):
|
||||
@set_config({"local_ranges": local_ranges})
|
||||
def _func():
|
||||
# Insert fake request data; should *not* influence the results of the tested function
|
||||
cherrypy.request.remote.ip = "6.6.6.6"
|
||||
# The remote_ip_from_xff function doesn't depend on request objects,
|
||||
# it only takes the xff_ips list as parameter
|
||||
assert xff_ips
|
||||
assert interface.remote_ip_from_xff(xff_ips) is expected_result
|
||||
|
||||
|
||||
Reference in New Issue
Block a user