mirror of
https://github.com/sabnzbd/sabnzbd.git
synced 2026-09-08 19:50:15 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a5d715b94 | ||
|
|
888ee46b40 | ||
|
|
737d61759b | ||
|
|
c62391e983 | ||
|
|
19e53229e0 | ||
|
|
d224d72814 | ||
|
|
c4803e1c3a | ||
|
|
bc09facc17 | ||
|
|
86e989509f | ||
|
|
02a748a807 | ||
|
|
7bf6958d1d | ||
|
|
f39f99c827 | ||
|
|
febc1287bd | ||
|
|
d1839d8c40 | ||
|
|
e3b62e0474 | ||
|
|
8b8342a378 | ||
|
|
2112dfbb23 | ||
|
|
0fe4b31a5e | ||
|
|
c49644f4e2 | ||
|
|
919bcbf5a3 | ||
|
|
6525703a94 | ||
|
|
a0e2408933 | ||
|
|
79e37e6b73 | ||
|
|
c57af32131 | ||
|
|
6ddabb5a45 | ||
|
|
8848e5b2fb | ||
|
|
9a12300877 | ||
|
|
31e052796d | ||
|
|
9ae6b71d2b | ||
|
|
944816e765 | ||
|
|
3e75a76602 | ||
|
|
54e868dd72 |
No files matched your search
@@ -214,6 +214,9 @@ jobs:
|
||||
env:
|
||||
AUTOMATION_GITHUB_TOKEN: ${{ secrets.AUTOMATION_GITHUB_TOKEN }}
|
||||
REDDIT_TOKEN: ${{ secrets.REDDIT_TOKEN }}
|
||||
LINUXSERVER_WEBHOOK_TOKEN: ${{ secrets.LINUXSERVER_WEBHOOK_TOKEN }}
|
||||
DISCORD_WEBHOOK_TESTING: ${{ secrets.DISCORD_WEBHOOK_TESTING }}
|
||||
DISCORD_WEBHOOK_STABLE: ${{ secrets.DISCORD_WEBHOOK_STABLE }}
|
||||
run: |
|
||||
pip3 install -r builder/release-requirements.txt --no-dependencies
|
||||
python3 builder/release.py
|
||||
+61
-2
@@ -1,7 +1,64 @@
|
||||
Release Notes - SABnzbd 5.1.0 Release Candidate 2
|
||||
Release Notes - SABnzbd 5.1.3
|
||||
=========================================================
|
||||
|
||||
This is the second release candidate of version 5.1.
|
||||
## Security fixes
|
||||
|
||||
SABnzbd 5.1.1, 5.1.2 and 5.1.3 resolve a number of security vulnerabilities.
|
||||
|
||||
Over the past releases, several security researchers have put a lot of time and effort
|
||||
examining SABnzbd, responsibly reporting their findings, and helping us verify the fixes.
|
||||
We are grateful for their hard work, it has made SABnzbd meaningfully safer for everyone.
|
||||
|
||||
For most users the risk is limited: you are only affected if your setup is exposed to
|
||||
untrusted parties. The web interface issues only apply if it can be reached by someone
|
||||
you do not trust. By default, SABnzbd is only accessible from your own device and
|
||||
`External internet access` is set to `No access`. If either of those is still at its
|
||||
default, or if you use a proxy service for authentication, you are not affected.
|
||||
The download-processing issues only apply if SABnzbd handles NZBs from a source you
|
||||
do not fully trust, such as a public indexer.
|
||||
If none of that describes your setup, you were never at risk.
|
||||
|
||||
The safest action is simply to update to 5.1.3.
|
||||
Full details, affected versions, actions and mitigations for each vulnerability are described at:
|
||||
https://sabnzbd.org/5-1-vulnerabilities
|
||||
|
||||
Vulnerabilities resolved:
|
||||
|
||||
* GHSA-q326-jpxx-jmjc: `__wrapped__` dispatch bypass allows unauthenticated API access, fixed in 5.1.3.
|
||||
* GHSA-mjwj-v5mr-cmcg: PAR2 symlink bypass allows pickle remote code execution, fixed in 5.1.3.
|
||||
* GHSA-rgqj-28c2-gxwp: Critical remote code execution via authentication bypass on configuration endpoints, fixed in 5.1.2.
|
||||
* GHSA-75g3-96fr-7p2r: High-severity path traversal during post-processing via crafted PAR2/SFV files, fixed in 5.1.2.
|
||||
* GHSA-xrfq-jhgh-wqch: Critical authentication bypass allowing a valid session without credentials, fixed in 5.1.1.
|
||||
|
||||
If you rely on the SABnzbd username and password to keep out untrusted parties, and your
|
||||
setup was exposed, it is recommended that you rotate your SABnzbd username/password and
|
||||
API-key, Usenet server passwords, indexer API-keys used in RSS-feeds, and notification
|
||||
service credentials after updating. See the page above for the full guidance.
|
||||
|
||||
## Other changes and bug fixes in 5.1.3
|
||||
|
||||
* Tighten Compact display mode.
|
||||
* Directly show usenet server errors instead of parsing them.
|
||||
* Prevent `database is locked` error when re-evaluating stored RSS jobs.
|
||||
* Added tooltip to explain search filters in Queue and History.
|
||||
* Windows and macOS: Updated 7zip to 26.03.
|
||||
|
||||
## Other bug fixes in 5.1.2
|
||||
|
||||
* RSS filters were not applied directly.
|
||||
|
||||
## Other bug fixes in 5.1.1
|
||||
|
||||
* Styling cache issues could occur after updating.
|
||||
* RSS items were removed too eagerly from RSS-history after download.
|
||||
* The RSS-feed `Clear Downloaded` button did not do anything.
|
||||
|
||||
## Changelog 5.1.0
|
||||
|
||||
This release brings a fundamental improvement to "Retry": instead of
|
||||
re-downloading any files with missing data, only the articles that were actually
|
||||
missing are fetched again. RSS got an overhaul under the hood, the interface is
|
||||
refreshed, and we added quite a long list of long-requested features and bugfixes.
|
||||
|
||||
## New features in 5.1.0
|
||||
|
||||
@@ -39,10 +96,12 @@ This is the second release candidate of version 5.1.
|
||||
* Diskspace checks would be too strict when unpacking to different disk.
|
||||
* Prevent deadlock when a second signal arrives during shutdown procedure.
|
||||
* Verification using SFV-checks could fail, even though files were correct.
|
||||
* Don't log Warning during clean shutdowns.
|
||||
* Windows: Uninstall would not remove Settings or Windows Service.
|
||||
* Windows: Warn for OS-limit if more than 1024 connections are configured.
|
||||
* macOS: AppleDouble files could result in failures in `Moving` stage.
|
||||
* macOS: Restarting the application resulted in Terminal window.
|
||||
* macOS: Included par2cmdline-turbo will now use all available CPU-features.
|
||||
|
||||
## Upgrade notices
|
||||
|
||||
|
||||
@@ -1325,6 +1325,7 @@ def main():
|
||||
}
|
||||
|
||||
appconfig = {
|
||||
"/": {"request.dispatch": sabnzbd.interface.SecureDispatcher()},
|
||||
"/api": {
|
||||
"tools.auth_basic.on": False,
|
||||
"tools.response_headers.on": True,
|
||||
|
||||
@@ -21,6 +21,7 @@ import re
|
||||
|
||||
import github
|
||||
import praw
|
||||
import requests
|
||||
|
||||
from common import (
|
||||
RELEASE_VERSION,
|
||||
@@ -186,6 +187,20 @@ if RELEASE_THIS and gh_token:
|
||||
print("Merging pull request in sabnzbd/sabnzbd.github.io for the update")
|
||||
update_pr.merge(merge_method="squash")
|
||||
|
||||
# Trigger the Docker image build at linuxserver.io
|
||||
# Branch "develop" builds the pre-releases, "master" the stable releases
|
||||
if linuxserver_token := os.environ.get("LINUXSERVER_WEBHOOK_TOKEN", ""):
|
||||
linuxserver_branch = "develop" if PRERELEASE else "master"
|
||||
print("Triggering linuxserver.io Docker build for branch %s" % linuxserver_branch)
|
||||
requests.post(
|
||||
"https://ci.linuxserver.io/generic-webhook-trigger/invoke?sabnzbd",
|
||||
headers={"Authorization": "Bearer %s" % linuxserver_token},
|
||||
json={"branch": linuxserver_branch},
|
||||
timeout=30,
|
||||
).raise_for_status()
|
||||
else:
|
||||
print("Missing LINUXSERVER_WEBHOOK_TOKEN")
|
||||
|
||||
# Only with GitHub success we proceed to Reddit
|
||||
if reddit_token := os.environ.get("REDDIT_TOKEN", ""):
|
||||
# Token format (without whitespace):
|
||||
@@ -233,6 +248,34 @@ if RELEASE_THIS and gh_token:
|
||||
else:
|
||||
print("Missing REDDIT_TOKEN")
|
||||
|
||||
# Push release notes to Discord via an incoming webhook
|
||||
# Separate channels for testing (pre-release) and stable, both are required
|
||||
discord_webhook_testing = os.environ.get("DISCORD_WEBHOOK_TESTING", "")
|
||||
discord_webhook_stable = os.environ.get("DISCORD_WEBHOOK_STABLE", "")
|
||||
if discord_webhook_testing and discord_webhook_stable:
|
||||
discord_webhook = discord_webhook_testing if PRERELEASE else discord_webhook_stable
|
||||
print("Posting release notes to Discord")
|
||||
# Reuse the release notes read earlier: line 0 is the title header
|
||||
discord_lines = readme_data.splitlines(keepends=True)
|
||||
discord_title = discord_lines[0].lstrip("# ").strip()
|
||||
discord_body = "".join(discord_lines[2:])[:4096]
|
||||
requests.post(
|
||||
discord_webhook,
|
||||
json={
|
||||
"embeds": [
|
||||
{
|
||||
"title": discord_title,
|
||||
"url": gh_release.html_url,
|
||||
"description": discord_body,
|
||||
"color": 0xFFA500 if PRERELEASE else 0x00A550,
|
||||
}
|
||||
]
|
||||
},
|
||||
timeout=30,
|
||||
).raise_for_status()
|
||||
else:
|
||||
print("Missing DISCORD_WEBHOOK_TESTING and/or DISCORD_WEBHOOK_STABLE")
|
||||
|
||||
else:
|
||||
print("To push release to GitHub, first tag the commit.")
|
||||
print("Or missing the AUTOMATION_GITHUB_TOKEN, cannot push to GitHub without it.")
|
||||
@@ -1 +1 @@
|
||||
@import url('Night.css') screen and (prefers-color-scheme: dark);
|
||||
@import url('Night.css?lastmajorupdate=510') screen and (prefers-color-scheme: dark);
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
<div class="input-group search-box">
|
||||
<input type="text" class="form-control" placeholder="$T('Glitter-search')" aria-label="$T('Glitter-search')" required data-bind="textInput: history.searchTerm, event: { keydown: history.clearSearchTerm }" />
|
||||
<a href="#" data-bind="click: history.clearSearchTerm, attr: { 'aria-label': history.searchTerm() ? '$T('Glitter-clearSearch')' : '$T('Glitter-search')' }">
|
||||
<a href="#" data-tooltip="true" data-html="true" data-placement="right" title="$T('Glitter-searchExplain')" data-bind="click: history.clearSearchTerm, attr: { 'aria-label': history.searchTerm() ? '$T('Glitter-clearSearch')' : '$T('Glitter-search')' }">
|
||||
<span class="glyphicon" data-bind="css: { 'glyphicon-search' : !history.searchTerm(), 'glyphicon-remove' : history.searchTerm() }"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -232,7 +232,7 @@
|
||||
|
||||
<div class="input-group search-box" data-bind="visible: queue.hasQueueSearch" style="display: none;">
|
||||
<input type="text" class="form-control" placeholder="$T('Glitter-search')" aria-label="$T('Glitter-search')" required data-bind="textInput: queue.searchTerm, event: { keydown: queue.clearSearchTerm }" />
|
||||
<a href="#" data-bind="click: queue.clearSearchTerm, attr: { 'aria-label': queue.searchTerm() ? '$T('Glitter-clearSearch')' : '$T('Glitter-search')' }">
|
||||
<a href="#" data-tooltip="true" data-html="true" data-placement="right" title="$T('Glitter-searchExplain')" data-bind="click: queue.clearSearchTerm, attr: { 'aria-label': queue.searchTerm() ? '$T('Glitter-clearSearch')' : '$T('Glitter-search')' }">
|
||||
<span class="glyphicon" data-bind="css: { 'glyphicon-search' : !queue.searchTerm(), 'glyphicon-remove' : queue.searchTerm() }"></span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1 +1 @@
|
||||
@import url('Night.css') screen and (prefers-color-scheme: dark);
|
||||
@import url('Night.css?lastmajorupdate=510') screen and (prefers-color-scheme: dark);
|
||||
@@ -40,12 +40,15 @@ h2 {
|
||||
|
||||
.container {
|
||||
margin-top: 1em;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.main-navbar {
|
||||
margin-bottom: 2px;
|
||||
z-index: 999;
|
||||
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.navbar.main-navbar {
|
||||
@@ -1978,8 +1981,34 @@ input[name="nzbURL"] {
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
/* Tighten the gaps between the main containers */
|
||||
.container-compact .main-navbar {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.container-compact:not(.container-tabbed) .main-content {
|
||||
padding-top: 0;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.container-compact .queue,
|
||||
.container-compact .history,
|
||||
.container-compact .queue-messages {
|
||||
padding: 4px 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.container-compact .queue-footer,
|
||||
.container-compact .history-footer {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.container-compact .table {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.container-full-width .container {
|
||||
width: calc(100% - 60px);
|
||||
width: calc(100% - 40px);
|
||||
}
|
||||
|
||||
/***
|
||||
|
||||
@@ -1,237 +1,246 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Copyright 2022-2026 by The SABnzbd-Team (sabnzbd.org) -->
|
||||
<component type="desktop-application">
|
||||
<id>org.sabnzbd.sabnzbd</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<name>SABnzbd</name>
|
||||
<summary>Free and easy binary newsreader</summary>
|
||||
<summary xml:lang="fr">Lecteur de news binaire libre et facile à utiliser</summary>
|
||||
<summary xml:lang="tr">Özgür ve kullanımı kolay Usenet ikili okuyucu</summary>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#e7e7e7</color>
|
||||
<color type="primary" scheme_preference="dark">#444444</color>
|
||||
</branding>
|
||||
<description>
|
||||
<p>
|
||||
SABnzbd is a free and Open Source web-based binary newsreader,
|
||||
with support for the popular nzb file format. It greatly simplifies
|
||||
the process of downloading from Usenet, thanks to a friendly
|
||||
web-based user interface and advanced built-in post-processing
|
||||
options including the ability to automatically verify, repair,
|
||||
extract and clean up downloaded posts. It runs anywhere, comes in
|
||||
over a dozen languages, and integrates with a host of tools, apps
|
||||
and services that help automate the download process.
|
||||
</p>
|
||||
<p xml:lang="fr">
|
||||
SABnzbd est un lecteur binaire de newsgroups libre et gratuit prenant
|
||||
en charge le format nzb très répandu. Il simplifie grandement le processus
|
||||
de téléchargement depuis Usenet grâce à une interface web conviviale
|
||||
et à des options avancées de post-traitement intégrées qui comprennent
|
||||
la capacité à vérifier, réparer, extraire et nettoyer automatiquement
|
||||
les messages téléchargés. Il fonctionne partout, est fourni avec une
|
||||
douzaine de traductions et s'intègre avec de nombreux outils, applications
|
||||
et services qui aident à automatiser le processus de téléchargement.
|
||||
</p>
|
||||
<p xml:lang="tr">
|
||||
SABnzbd, popüler nzb biçimini destekleyen özgür ve ücretsiz bir ikili
|
||||
haber grupları okuyucusudur. Kullanıcı dostu Web tabanlı arayüzü ve
|
||||
indirilen mesajları otomatik olarak doğrulama, tamir etme, çıkarma ve
|
||||
temizleme kabiliyetine sahip gelişmiş yerleşik son işlem seçenekleri
|
||||
sayesinde Usenet ağından indirmeyi büyük ölçüde kolaylaştırır. Her yerde
|
||||
çalışabilir, bir düzineden fazla dil desteği sunar ve indirme sürecini
|
||||
otomatik hâle getirmeye yardımcı olan birçok araç, uygulama ve hizmet ile
|
||||
birlikte çalışabilir.
|
||||
</p>
|
||||
</description>
|
||||
<keywords>
|
||||
<keyword>usenet</keyword>
|
||||
<keyword>nzb</keyword>
|
||||
<keyword>download</keyword>
|
||||
<keyword>newsreader</keyword>
|
||||
<keyword>binary</keyword>
|
||||
<keyword xml:lang="fr">usenet</keyword>
|
||||
<keyword xml:lang="fr">nzb</keyword>
|
||||
<keyword xml:lang="fr">téléchargement</keyword>
|
||||
<keyword xml:lang="fr">binaires</keyword>
|
||||
<keyword xml:lang="tr">usenet</keyword>
|
||||
<keyword xml:lang="tr">nzb</keyword>
|
||||
<keyword xml:lang="tr">indirme</keyword>
|
||||
</keywords>
|
||||
<categories>
|
||||
<category>Network</category>
|
||||
<category>FileTransfer</category>
|
||||
</categories>
|
||||
<url type="homepage">https://sabnzbd.org</url>
|
||||
<url type="bugtracker">https://github.com/sabnzbd/sabnzbd/issues</url>
|
||||
<url type="vcs-browser">https://github.com/sabnzbd/sabnzbd</url>
|
||||
<url type="contribute">https://github.com/sabnzbd/sabnzbd</url>
|
||||
<url type="translate">https://sabnzbd.org/wiki/translate</url>
|
||||
<url type="donation">https://sabnzbd.org/donate</url>
|
||||
<url type="help">https://sabnzbd.org/wiki/</url>
|
||||
<url type="faq">https://sabnzbd.org/wiki/faq</url>
|
||||
<url type="contact">https://sabnzbd.org/live-chat.html</url>
|
||||
<releases>
|
||||
<release version="5.1.0" date="2026-08-01" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.0</url>
|
||||
</release>
|
||||
<release version="5.0.4" date="2026-06-11" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.4</url>
|
||||
</release>
|
||||
<release version="5.0.3" date="2026-05-15" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.3</url>
|
||||
</release>
|
||||
<release version="5.0.2" date="2026-05-14" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.2</url>
|
||||
</release>
|
||||
<release version="5.0.1" date="2026-05-01" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.1</url>
|
||||
</release>
|
||||
<release version="5.0.0" date="2026-05-01" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.0</url>
|
||||
</release>
|
||||
<release version="4.5.5" date="2025-10-24" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.5</url>
|
||||
</release>
|
||||
<release version="4.5.4" date="2025-10-22" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.4</url>
|
||||
</release>
|
||||
<release version="4.5.3" date="2025-08-25" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.3</url>
|
||||
</release>
|
||||
<release version="4.5.2" date="2025-07-09" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.2</url>
|
||||
</release>
|
||||
<release version="4.5.1" date="2025-04-11" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.1</url>
|
||||
</release>
|
||||
<release version="4.5.0" date="2025-04-01" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.0</url>
|
||||
</release>
|
||||
<release version="4.4.1" date="2024-12-23" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.4.1</url>
|
||||
</release>
|
||||
<release version="4.4.0" date="2024-12-09" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.4.0</url>
|
||||
</release>
|
||||
<release version="4.3.3" date="2024-08-01" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.3</url>
|
||||
</release>
|
||||
<release version="4.3.2" date="2024-05-30" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.2</url>
|
||||
</release>
|
||||
<release version="4.3.1" date="2024-05-03" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.1</url>
|
||||
</release>
|
||||
</releases>
|
||||
<launchable type="desktop-id">sabnzbd.desktop</launchable>
|
||||
<provides>
|
||||
<mediatype>application/x-nzb</mediatype>
|
||||
<mediatype>application/x-compressed-nzb</mediatype>
|
||||
</provides>
|
||||
<supports>
|
||||
<control>pointing</control>
|
||||
<control>keyboard</control>
|
||||
<control>touch</control>
|
||||
</supports>
|
||||
<recommends>
|
||||
<display_length compare="ge">640</display_length>
|
||||
<internet>always</internet>
|
||||
</recommends>
|
||||
<project_license>GPL-2.0-or-later</project_license>
|
||||
<developer id="org.sabnzbd">
|
||||
<name>The SABnzbd-Team</name>
|
||||
</developer>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/interface.png</image>
|
||||
<caption>Intuitive interface</caption>
|
||||
<caption xml:lang="fr">Interface intuitive</caption>
|
||||
<caption xml:lang="tr">Sezgisel arayüz</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/night-mode.png</image>
|
||||
<caption>Also comes in Night-mode</caption>
|
||||
<caption xml:lang="fr">Fourni également avec le mode nuit</caption>
|
||||
<caption xml:lang="tr">Gece kipi ile de sunulur</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/add-nzb.png</image>
|
||||
<caption>Add NZB's or use drag-and-drop!</caption>
|
||||
<caption xml:lang="fr">Ajoutez des fichiers NZB ou utilisez le glisser - déposer !</caption>
|
||||
<caption xml:lang="tr">NZB dosyaları ekleyin veya kaydır - bırak kullanın!</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/phone-interface.png</image>
|
||||
<caption>Scales to any screen size</caption>
|
||||
<caption xml:lang="fr">S'adapte à toutes les tailles d'écran</caption>
|
||||
<caption xml:lang="tr">Her ekran boyutuna uyum sağlar</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/history-details.png</image>
|
||||
<caption>Easy overview of all history details</caption>
|
||||
<caption xml:lang="fr">Vue d'ensemble aisée de tous les détails de l'historique</caption>
|
||||
<caption xml:lang="tr">Tarihçenin tüm ayrıntıları için kolay genel bakış</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/phone-extra.png</image>
|
||||
<caption>Every option, on every screen size</caption>
|
||||
<caption xml:lang="fr">Toutes les options, quelle que soit la taille d'écran</caption>
|
||||
<caption xml:lang="tr">Ekran boyutu ne olursa olsun, tüm seçenekler elinizin altında</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/file-lists.png</image>
|
||||
<caption>Manage a job's individual files</caption>
|
||||
<caption xml:lang="fr">Gérez les fichiers individuels d'une tâche</caption>
|
||||
<caption xml:lang="tr">Herhangi bir görevin dosyalarını tek tek yönetin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/set-speedlimit.png</image>
|
||||
<caption>Easy speed limiting</caption>
|
||||
<caption xml:lang="fr">Limitation facile du débit</caption>
|
||||
<caption xml:lang="tr">Kolay hız sınırlaması</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/set-options.png</image>
|
||||
<caption>Quickly change settings</caption>
|
||||
<caption xml:lang="fr">Changement rapide des réglages</caption>
|
||||
<caption xml:lang="tr">Ayarları hızlıca değiştirin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/dashboard.png</image>
|
||||
<caption>Easy system check</caption>
|
||||
<caption xml:lang="fr">Vérification facile du système</caption>
|
||||
<caption xml:lang="tr">Sistemi kolayca kontrol edin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/connections-overview.png</image>
|
||||
<caption>See active connections</caption>
|
||||
<caption xml:lang="fr">Voyez les connexions actives</caption>
|
||||
<caption xml:lang="tr">Etkin bağlantıları görüntüleyin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/skin-settings.png</image>
|
||||
<caption>Customize the interface</caption>
|
||||
<caption xml:lang="fr">Personnalisez l'interface</caption>
|
||||
<caption xml:lang="tr">Arayüzü kişiselleştirin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/tabbed.png</image>
|
||||
<caption>Tabbed-mode</caption>
|
||||
<caption xml:lang="fr">Mode avec onglets</caption>
|
||||
<caption xml:lang="tr">Sekmeli kip</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/set-custom-pause.png</image>
|
||||
<caption>Specify any pause duration</caption>
|
||||
<caption xml:lang="fr">Spécifiez une durée de pause quelconque</caption>
|
||||
<caption xml:lang="tr">Herhangi bir duraklama süresi belirtin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/config.png</image>
|
||||
<caption>Easy configuration</caption>
|
||||
<caption xml:lang="fr">Configuration facile</caption>
|
||||
<caption xml:lang="tr">Kolay yapılandırma</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<content_rating type="oars-1.1"/>
|
||||
<id>org.sabnzbd.sabnzbd</id>
|
||||
<metadata_license>MIT</metadata_license>
|
||||
<name>SABnzbd</name>
|
||||
<summary>Free and easy binary newsreader</summary>
|
||||
<summary xml:lang="fr">Lecteur de news binaire libre et facile à utiliser</summary>
|
||||
<summary xml:lang="tr">Özgür ve kullanımı kolay Usenet ikili okuyucu</summary>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#e7e7e7</color>
|
||||
<color type="primary" scheme_preference="dark">#444444</color>
|
||||
</branding>
|
||||
<description>
|
||||
<p>
|
||||
SABnzbd is a free and Open Source web-based binary newsreader,
|
||||
with support for the popular nzb file format. It greatly simplifies
|
||||
the process of downloading from Usenet, thanks to a friendly
|
||||
web-based user interface and advanced built-in post-processing
|
||||
options including the ability to automatically verify, repair,
|
||||
extract and clean up downloaded posts. It runs anywhere, comes in
|
||||
over a dozen languages, and integrates with a host of tools, apps
|
||||
and services that help automate the download process.
|
||||
</p>
|
||||
<p xml:lang="fr">
|
||||
SABnzbd est un lecteur binaire de newsgroups libre et gratuit prenant
|
||||
en charge le format nzb très répandu. Il simplifie grandement le processus
|
||||
de téléchargement depuis Usenet grâce à une interface web conviviale
|
||||
et à des options avancées de post-traitement intégrées qui comprennent
|
||||
la capacité à vérifier, réparer, extraire et nettoyer automatiquement
|
||||
les messages téléchargés. Il fonctionne partout, est fourni avec une
|
||||
douzaine de traductions et s'intègre avec de nombreux outils, applications
|
||||
et services qui aident à automatiser le processus de téléchargement.
|
||||
</p>
|
||||
<p xml:lang="tr">
|
||||
SABnzbd, popüler nzb biçimini destekleyen özgür ve ücretsiz bir ikili
|
||||
haber grupları okuyucusudur. Kullanıcı dostu Web tabanlı arayüzü ve
|
||||
indirilen mesajları otomatik olarak doğrulama, tamir etme, çıkarma ve
|
||||
temizleme kabiliyetine sahip gelişmiş yerleşik son işlem seçenekleri
|
||||
sayesinde Usenet ağından indirmeyi büyük ölçüde kolaylaştırır. Her yerde
|
||||
çalışabilir, bir düzineden fazla dil desteği sunar ve indirme sürecini
|
||||
otomatik hâle getirmeye yardımcı olan birçok araç, uygulama ve hizmet ile
|
||||
birlikte çalışabilir.
|
||||
</p>
|
||||
</description>
|
||||
<keywords>
|
||||
<keyword>usenet</keyword>
|
||||
<keyword>nzb</keyword>
|
||||
<keyword>download</keyword>
|
||||
<keyword>newsreader</keyword>
|
||||
<keyword>binary</keyword>
|
||||
<keyword xml:lang="fr">usenet</keyword>
|
||||
<keyword xml:lang="fr">nzb</keyword>
|
||||
<keyword xml:lang="fr">téléchargement</keyword>
|
||||
<keyword xml:lang="fr">binaires</keyword>
|
||||
<keyword xml:lang="tr">usenet</keyword>
|
||||
<keyword xml:lang="tr">nzb</keyword>
|
||||
<keyword xml:lang="tr">indirme</keyword>
|
||||
</keywords>
|
||||
<categories>
|
||||
<category>Network</category>
|
||||
<category>FileTransfer</category>
|
||||
</categories>
|
||||
<url type="homepage">https://sabnzbd.org</url>
|
||||
<url type="bugtracker">https://github.com/sabnzbd/sabnzbd/issues</url>
|
||||
<url type="vcs-browser">https://github.com/sabnzbd/sabnzbd</url>
|
||||
<url type="contribute">https://github.com/sabnzbd/sabnzbd</url>
|
||||
<url type="translate">https://sabnzbd.org/wiki/translate</url>
|
||||
<url type="donation">https://sabnzbd.org/donate</url>
|
||||
<url type="help">https://sabnzbd.org/wiki/</url>
|
||||
<url type="faq">https://sabnzbd.org/wiki/faq</url>
|
||||
<url type="contact">https://sabnzbd.org/live-chat.html</url>
|
||||
<releases>
|
||||
<release version="5.1.3" date="2026-08-25" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.2</url>
|
||||
</release>
|
||||
<release version="5.1.2" date="2026-08-25" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.2</url>
|
||||
</release>
|
||||
<release version="5.1.1" date="2026-08-18" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.1</url>
|
||||
</release>
|
||||
<release version="5.1.0" date="2026-08-10" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.1.0</url>
|
||||
</release>
|
||||
<release version="5.0.4" date="2026-06-11" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.4</url>
|
||||
</release>
|
||||
<release version="5.0.3" date="2026-05-15" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.3</url>
|
||||
</release>
|
||||
<release version="5.0.2" date="2026-05-14" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.2</url>
|
||||
</release>
|
||||
<release version="5.0.1" date="2026-05-01" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.1</url>
|
||||
</release>
|
||||
<release version="5.0.0" date="2026-05-01" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/5.0.0</url>
|
||||
</release>
|
||||
<release version="4.5.5" date="2025-10-24" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.5</url>
|
||||
</release>
|
||||
<release version="4.5.4" date="2025-10-22" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.4</url>
|
||||
</release>
|
||||
<release version="4.5.3" date="2025-08-25" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.3</url>
|
||||
</release>
|
||||
<release version="4.5.2" date="2025-07-09" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.2</url>
|
||||
</release>
|
||||
<release version="4.5.1" date="2025-04-11" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.1</url>
|
||||
</release>
|
||||
<release version="4.5.0" date="2025-04-01" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.5.0</url>
|
||||
</release>
|
||||
<release version="4.4.1" date="2024-12-23" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.4.1</url>
|
||||
</release>
|
||||
<release version="4.4.0" date="2024-12-09" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.4.0</url>
|
||||
</release>
|
||||
<release version="4.3.3" date="2024-08-01" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.3</url>
|
||||
</release>
|
||||
<release version="4.3.2" date="2024-05-30" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.2</url>
|
||||
</release>
|
||||
<release version="4.3.1" date="2024-05-03" type="stable">
|
||||
<url type="details">https://github.com/sabnzbd/sabnzbd/releases/tag/4.3.1</url>
|
||||
</release>
|
||||
</releases>
|
||||
<launchable type="desktop-id">sabnzbd.desktop</launchable>
|
||||
<provides>
|
||||
<mediatype>application/x-nzb</mediatype>
|
||||
<mediatype>application/x-compressed-nzb</mediatype>
|
||||
</provides>
|
||||
<supports>
|
||||
<control>pointing</control>
|
||||
<control>keyboard</control>
|
||||
<control>touch</control>
|
||||
</supports>
|
||||
<recommends>
|
||||
<display_length compare="ge">640</display_length>
|
||||
<internet>always</internet>
|
||||
</recommends>
|
||||
<project_license>GPL-2.0-or-later</project_license>
|
||||
<developer id="org.sabnzbd">
|
||||
<name>The SABnzbd-Team</name>
|
||||
</developer>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/interface.png</image>
|
||||
<caption>Intuitive interface</caption>
|
||||
<caption xml:lang="fr">Interface intuitive</caption>
|
||||
<caption xml:lang="tr">Sezgisel arayüz</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/night-mode.png</image>
|
||||
<caption>Also comes in Night-mode</caption>
|
||||
<caption xml:lang="fr">Fourni également avec le mode nuit</caption>
|
||||
<caption xml:lang="tr">Gece kipi ile de sunulur</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/add-nzb.png</image>
|
||||
<caption>Add NZB's or use drag-and-drop!</caption>
|
||||
<caption xml:lang="fr">Ajoutez des fichiers NZB ou utilisez le glisser - déposer !</caption>
|
||||
<caption xml:lang="tr">NZB dosyaları ekleyin veya kaydır - bırak kullanın!</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/phone-interface.png</image>
|
||||
<caption>Scales to any screen size</caption>
|
||||
<caption xml:lang="fr">S'adapte à toutes les tailles d'écran</caption>
|
||||
<caption xml:lang="tr">Her ekran boyutuna uyum sağlar</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/history-details.png</image>
|
||||
<caption>Easy overview of all history details</caption>
|
||||
<caption xml:lang="fr">Vue d'ensemble aisée de tous les détails de l'historique</caption>
|
||||
<caption xml:lang="tr">Tarihçenin tüm ayrıntıları için kolay genel bakış</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/phone-extra.png</image>
|
||||
<caption>Every option, on every screen size</caption>
|
||||
<caption xml:lang="fr">Toutes les options, quelle que soit la taille d'écran</caption>
|
||||
<caption xml:lang="tr">Ekran boyutu ne olursa olsun, tüm seçenekler elinizin altında</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/file-lists.png</image>
|
||||
<caption>Manage a job's individual files</caption>
|
||||
<caption xml:lang="fr">Gérez les fichiers individuels d'une tâche</caption>
|
||||
<caption xml:lang="tr">Herhangi bir görevin dosyalarını tek tek yönetin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/set-speedlimit.png</image>
|
||||
<caption>Easy speed limiting</caption>
|
||||
<caption xml:lang="fr">Limitation facile du débit</caption>
|
||||
<caption xml:lang="tr">Kolay hız sınırlaması</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/set-options.png</image>
|
||||
<caption>Quickly change settings</caption>
|
||||
<caption xml:lang="fr">Changement rapide des réglages</caption>
|
||||
<caption xml:lang="tr">Ayarları hızlıca değiştirin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/dashboard.png</image>
|
||||
<caption>Easy system check</caption>
|
||||
<caption xml:lang="fr">Vérification facile du système</caption>
|
||||
<caption xml:lang="tr">Sistemi kolayca kontrol edin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/connections-overview.png</image>
|
||||
<caption>See active connections</caption>
|
||||
<caption xml:lang="fr">Voyez les connexions actives</caption>
|
||||
<caption xml:lang="tr">Etkin bağlantıları görüntüleyin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/skin-settings.png</image>
|
||||
<caption>Customize the interface</caption>
|
||||
<caption xml:lang="fr">Personnalisez l'interface</caption>
|
||||
<caption xml:lang="tr">Arayüzü kişiselleştirin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/tabbed.png</image>
|
||||
<caption>Tabbed-mode</caption>
|
||||
<caption xml:lang="fr">Mode avec onglets</caption>
|
||||
<caption xml:lang="tr">Sekmeli kip</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/set-custom-pause.png</image>
|
||||
<caption>Specify any pause duration</caption>
|
||||
<caption xml:lang="fr">Spécifiez une durée de pause quelconque</caption>
|
||||
<caption xml:lang="tr">Herhangi bir duraklama süresi belirtin</caption>
|
||||
</screenshot>
|
||||
<screenshot>
|
||||
<image>https://sabnzbd.org/images/landing/screenshots/config.png</image>
|
||||
<caption>Easy configuration</caption>
|
||||
<caption xml:lang="fr">Configuration facile</caption>
|
||||
<caption xml:lang="tr">Kolay yapılandırma</caption>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<content_rating type="oars-1.1"/>
|
||||
</component>
|
||||
Binary file not shown.
+2
-2
@@ -533,8 +533,8 @@ def delayed_startup_actions():
|
||||
# aren't on 24/7 and typically don't benefit from the daily scheduled call at midnight
|
||||
sabnzbd.database.scheduled_history_purge()
|
||||
|
||||
# Purge links older than 3 days
|
||||
sabnzbd.rss.expired_purge()
|
||||
# Drop leftover records of feeds that are no longer configured
|
||||
sabnzbd.rss.purge_removed_feeds()
|
||||
|
||||
# Start SSDP and Bonjour if SABnzbd isn't listening on localhost only
|
||||
if sabnzbd.cfg.enable_broadcast() and not misc.is_localhost(cfg.web_host()):
|
||||
|
||||
+3
-4
@@ -1512,10 +1512,9 @@ def test_nntp_server_dict(kwargs: ApiParams) -> tuple[bool, str]:
|
||||
elif nntp_code < 300 or nntp_code in (411, 423, 430):
|
||||
# If no username/password set and we requested fake-article, it will return 430 Not Found
|
||||
return_status = (True, T("Connection Successful!"))
|
||||
elif nntp_code == 502 or sabnzbd.downloader.clues_login(nntp_message):
|
||||
return_status = (False, T("Authentication failed, check username/password."))
|
||||
elif sabnzbd.downloader.clues_too_many(nntp_message):
|
||||
return_status = (False, T("Too many connections, please pause downloading or try again later"))
|
||||
else:
|
||||
# Pass the server message directly on to the user
|
||||
return_status = (False, nntp_message)
|
||||
|
||||
# Fallback in case no data was received or unknown status
|
||||
if not return_status:
|
||||
|
||||
@@ -72,7 +72,12 @@ def decode_par2(parfile: str) -> list[str]:
|
||||
new_path = os.path.join(dirname, md5of16k[file_md5of16k])
|
||||
# Make sure it's a unique name
|
||||
unique_filename = get_unique_filename(new_path)
|
||||
renamer(filepath, unique_filename)
|
||||
# Untrusted par2 name: contain it, skip on traversal
|
||||
try:
|
||||
renamer(filepath, unique_filename, create_local_directories=True)
|
||||
except OSError:
|
||||
logging.info("Skipping par2 rename of %s to %s", filepath, unique_filename)
|
||||
continue
|
||||
new_files.append(unique_filename)
|
||||
return new_files
|
||||
|
||||
|
||||
@@ -864,31 +864,27 @@ class Downloader(Thread):
|
||||
# Handle login problems
|
||||
block = False
|
||||
penalty = 0
|
||||
errormsg = None
|
||||
# Pass the server message directly on to the user
|
||||
errormsg = T("Server %s reported: %s") % (server.host, error.msg)
|
||||
logging.debug("Server login problem: %s", error.msg)
|
||||
if error.code in (502, 400, 481, 482) and clues_too_many(error.msg):
|
||||
# Too many connections: remove this thread and reduce thread-setting for server
|
||||
# Plan to go back to the full number after a penalty timeout
|
||||
errormsg = T("Too many connections to server %s [%s]") % (server.host, error.msg)
|
||||
if server.active:
|
||||
# Don't count this for the tries (max_art_tries) on this server
|
||||
self.reset_nw(nw)
|
||||
self.plan_server(server, _PENALTY_TOOMANY)
|
||||
elif error.code in (502, 481, 482) and clues_too_many_ip(error.msg):
|
||||
# Login from (too many) different IP addresses
|
||||
errormsg = T(
|
||||
"Login from too many different IP addresses to server %s [%s] - https://sabnzbd.org/multiple-adresses"
|
||||
) % (server.host, error.msg)
|
||||
errormsg += " - https://sabnzbd.org/multiple-adresses"
|
||||
penalty = _PENALTY_SHARE
|
||||
block = True
|
||||
elif error.code in (452, 481, 482, 381) or (error.code in (500, 502) and clues_login(error.msg)):
|
||||
# Cannot login, block this server
|
||||
errormsg = T("Failed login for server %s [%s]") % (server.host, error.msg)
|
||||
penalty = _PENALTY_PERM
|
||||
block = True
|
||||
elif error.code in (502, 482):
|
||||
# Cannot connect (other reasons), block this server
|
||||
errormsg = T("Cannot connect to server %s [%s]") % (server.host, error.msg)
|
||||
if clues_pay(error.msg):
|
||||
penalty = _PENALTY_PERM
|
||||
else:
|
||||
@@ -896,12 +892,12 @@ class Downloader(Thread):
|
||||
block = True
|
||||
elif error.code == 400:
|
||||
# Temp connection problem?
|
||||
errormsg = None
|
||||
logging.debug("Unspecified error 400 from server %s", server.host)
|
||||
penalty = _PENALTY_VERYSHORT
|
||||
block = True
|
||||
else:
|
||||
# Unknown error, just keep trying
|
||||
errormsg = T("Cannot connect to server %s [%s]") % (server.host, error.msg)
|
||||
penalty = _PENALTY_UNKNOWN
|
||||
block = True
|
||||
|
||||
|
||||
+111
-14
@@ -210,13 +210,39 @@ for i in range(1, 32):
|
||||
CH_ILLEGAL_WIN += chr(i)
|
||||
|
||||
|
||||
def sanitize_filename(filename: str) -> str:
|
||||
def sanitize_filename(filename: str, allow_subdirs: bool = False) -> str:
|
||||
"""Return filename with illegal chars converted to legal ones
|
||||
and with the par2 extension always in lowercase
|
||||
and with the par2 extension always in lowercase.
|
||||
With allow_subdirs the forward slashes that par2 uses to separate sub-directories are kept
|
||||
and every part is sanitized on its own. The result is always local to the current folder:
|
||||
empty parts, "." and ".." are dropped, so a leading slash or any amount of traversal can
|
||||
never produce a name that points outside of it. The admin folder is dropped as well, so
|
||||
it can never point into it either.
|
||||
"""
|
||||
if not filename:
|
||||
return filename
|
||||
|
||||
if allow_subdirs:
|
||||
# Par2 always uses a forward slash, no matter which platform created the set
|
||||
parts = []
|
||||
for part in filename.split("/"):
|
||||
# Sanitize first, the checks below run on the stripped name
|
||||
part = sanitize_filename(part)
|
||||
if part in ("", os.curdir):
|
||||
continue
|
||||
if part == os.pardir:
|
||||
logging.info("Dropping directory traversal from name %s", filename)
|
||||
continue
|
||||
if part.lower() == JOB_ADMIN.lower():
|
||||
# Never let a name point into the admin folder, its files are pickle-loaded
|
||||
logging.info("Dropping admin folder from name %s", filename)
|
||||
continue
|
||||
parts.append(part)
|
||||
# Nothing usable left, or no sub-directories after all
|
||||
if not parts:
|
||||
return "unknown"
|
||||
return os.path.join(*parts)
|
||||
|
||||
filename = unicode_nfc_normalize(filename)
|
||||
|
||||
illegal = CH_ILLEGAL
|
||||
@@ -455,6 +481,30 @@ def same_directory(a: str, b: str) -> int:
|
||||
return is_subfolder
|
||||
|
||||
|
||||
def points_into_admin_dir(path: str, base: str) -> bool:
|
||||
"""Return True if path is, or is inside, an admin folder somewhere below base.
|
||||
Both sides are resolved first, because the name alone cannot be trusted: on Windows
|
||||
an NTFS 8.3 alias ("__ADMI~1") and on any platform a link point at a directory that
|
||||
is named differently than the path says. Resolving also settles any case difference.
|
||||
"""
|
||||
# realpath() only keeps the \\?\ prefix if it was there to begin with, so clip both
|
||||
# sides: comparing a prefixed path to a plain one would put the admin folder out of
|
||||
# sight and let it pass
|
||||
try:
|
||||
relative = os.path.relpath(clip_path(os.path.realpath(path)), clip_path(os.path.realpath(base)))
|
||||
except ValueError:
|
||||
# Windows only: resolving ended up on another drive, so it left base altogether
|
||||
return True
|
||||
return JOB_ADMIN.lower() in relative.lower().split(os.sep)
|
||||
|
||||
|
||||
def points_outside(root: str, path: str) -> bool:
|
||||
"""Return True if the file at path does not end up inside root.
|
||||
Both sides are resolved, so a root that is itself a link is fine, a link inside it is not.
|
||||
"""
|
||||
return same_directory(os.path.realpath(root), os.path.dirname(os.path.realpath(path))) == 0
|
||||
|
||||
|
||||
def is_network_path(path: str) -> bool:
|
||||
"""Check weither a path is a network path.
|
||||
On Windows, use win32 functions to detect users that try to avoid this detection by using a mapped drive letter.
|
||||
@@ -784,7 +834,7 @@ def get_unique_dir(path: str, n: int = 0, create_dir: bool = True) -> str | bool
|
||||
if n:
|
||||
new_path = "%s.%s" % (path, n)
|
||||
|
||||
if not os.path.exists(new_path):
|
||||
if not os.path.lexists(new_path):
|
||||
if create_dir:
|
||||
return create_all_dirs(new_path, apply_permissions=True)
|
||||
else:
|
||||
@@ -801,7 +851,7 @@ def get_unique_filename(path: str) -> str:
|
||||
num = 1
|
||||
new_path, filename = os.path.split(path)
|
||||
name, ext = os.path.splitext(filename)
|
||||
while os.path.exists(path):
|
||||
while os.path.lexists(path):
|
||||
filename = "%s.%d%s" % (name, num, ext)
|
||||
num += 1
|
||||
path = os.path.join(new_path, filename)
|
||||
@@ -821,8 +871,9 @@ def listdir_full(input_dir: str, recursive: bool = True) -> list[str]:
|
||||
return filelist
|
||||
|
||||
|
||||
def move_to_path(path: str, new_path: str) -> tuple[bool, Optional[str]]:
|
||||
def move_to_path(path: str, new_path: str, root: Optional[str] = None) -> tuple[bool, Optional[str]]:
|
||||
"""Move a file to a new path, optionally give unique filename
|
||||
With root the destination has to resolve to a location inside it
|
||||
Return (ok, new_path)
|
||||
"""
|
||||
ok = True
|
||||
@@ -838,6 +889,11 @@ def move_to_path(path: str, new_path: str) -> tuple[bool, Optional[str]]:
|
||||
new_path = get_unique_filename(new_path)
|
||||
|
||||
if new_path:
|
||||
if root and points_outside(root, new_path):
|
||||
logging.error(T("Failed moving %s to %s"), clip_path(path), clip_path(new_path))
|
||||
logging.info("Refusing to move %s, it points outside %s", new_path, root)
|
||||
return False, None
|
||||
|
||||
logging.debug("Moving (overwrite: %s) %s => %s", overwrite, path, new_path)
|
||||
if not os.path.exists(new_path_dir):
|
||||
create_all_dirs(os.path.dirname(new_path), apply_permissions=True)
|
||||
@@ -922,13 +978,20 @@ def renamer(old: str, new: str, create_local_directories: bool = False) -> str:
|
||||
if create_local_directories:
|
||||
oldpath, _ = os.path.split(old)
|
||||
# Check not outside directory
|
||||
# In case of "same_file() == 1": same directory, so nothing to do
|
||||
if same_directory(oldpath, path) == 0:
|
||||
if points_outside(oldpath, new):
|
||||
# Outside current directory, this is most likely malicious
|
||||
logging.error(T("Blocked attempt to create directory %s"), path)
|
||||
raise OSError("Refusing to go outside directory")
|
||||
elif same_directory(oldpath, path) == 2:
|
||||
# Sub-directory, so create if does not yet exist:
|
||||
|
||||
# Refuse the admin folder, it is pickle-loaded. Check the whole new path: if the
|
||||
# last element is the admin folder itself, the move below would put the file
|
||||
# inside it, since shutil.move accepts a directory as its target.
|
||||
if points_into_admin_dir(new, oldpath):
|
||||
logging.error(T("Blocked attempt to create directory %s"), path)
|
||||
raise OSError("Refusing to go into admin directory")
|
||||
|
||||
if not os.path.isdir(path):
|
||||
# Sub-directory, create if does not yet exist:
|
||||
create_all_dirs(path)
|
||||
|
||||
logging.debug('Renaming "%s" to "%s"', old, new)
|
||||
@@ -1124,6 +1187,44 @@ def get_new_id(prefix: str, folder: str, check_list: Optional[list] = None) -> s
|
||||
raise IOError
|
||||
|
||||
|
||||
# Allowlist of every global our pickles may reference: safe data types and our persisted classes.
|
||||
# Explicit, not a "sabnzbd.*" wildcard, which would also admit gadget classes (e.g. a __del__
|
||||
# that runs os.kill). sabnzbd.nzbstuff is the pre-refactor module path (compat shim).
|
||||
_SAFE_GLOBALS = {
|
||||
("datetime", "datetime"),
|
||||
("datetime", "date"),
|
||||
("datetime", "time"),
|
||||
("datetime", "timedelta"),
|
||||
("datetime", "timezone"),
|
||||
("time", "struct_time"),
|
||||
("os", "stat_result"),
|
||||
("collections", "OrderedDict"),
|
||||
("collections", "defaultdict"),
|
||||
("collections", "deque"),
|
||||
("builtins", "set"),
|
||||
("builtins", "frozenset"),
|
||||
("builtins", "bytearray"),
|
||||
("builtins", "complex"),
|
||||
("copyreg", "_reconstructor"),
|
||||
("sabnzbd.nzb.object", "NzbObject"),
|
||||
("sabnzbd.nzb.file", "NzbFile"),
|
||||
("sabnzbd.nzb.article", "Article"),
|
||||
("sabnzbd.par2file", "FilePar2Info"),
|
||||
("sabnzbd.nzbstuff", "NzbObject"),
|
||||
("sabnzbd.nzbstuff", "NzbFile"),
|
||||
("sabnzbd.nzbstuff", "Article"),
|
||||
}
|
||||
|
||||
|
||||
class RestrictedUnpickler(pickle.Unpickler):
|
||||
"""Unpickler restricted to an allowlist, so a hostile pickle cannot run code"""
|
||||
|
||||
def find_class(self, module, name):
|
||||
if (module, name) in _SAFE_GLOBALS:
|
||||
return super().find_class(module, name)
|
||||
raise pickle.UnpicklingError("Refusing to unpickle %s.%s" % (module, name))
|
||||
|
||||
|
||||
def save_data(data: Any, _id: str, path: str, do_pickle: bool = True, silent: bool = False):
|
||||
"""Save data to a diskfile"""
|
||||
if not silent:
|
||||
@@ -1172,11 +1273,7 @@ def load_data(
|
||||
try:
|
||||
with open(path, "rb") as data_file:
|
||||
if do_pickle:
|
||||
try:
|
||||
data = pickle.load(data_file, encoding=sabnzbd.encoding.CODEPAGE)
|
||||
except UnicodeDecodeError:
|
||||
# Could be Python 2 data that we can load using old encoding
|
||||
data = pickle.load(data_file, encoding="latin1")
|
||||
data = RestrictedUnpickler(data_file, encoding=sabnzbd.encoding.CODEPAGE).load()
|
||||
elif mutable:
|
||||
data = bytearray(os.fstat(data_file.fileno()).st_size)
|
||||
data_file.readinto(data)
|
||||
|
||||
+70
-42
@@ -30,6 +30,7 @@ import socket
|
||||
import ssl
|
||||
import functools
|
||||
import copy
|
||||
import secrets
|
||||
from random import randint
|
||||
from xml.sax.saxutils import escape
|
||||
from Cheetah.Template import Template
|
||||
@@ -105,11 +106,23 @@ _MSG_APIKEY_REQUIRED = "API Key Required"
|
||||
_MSG_APIKEY_INCORRECT = "API Key Incorrect"
|
||||
|
||||
|
||||
class SecureDispatcher(cherrypy.dispatch.Dispatcher):
|
||||
"""Dispatcher that refuses paths traversing private attributes"""
|
||||
|
||||
def find_handler(self, path):
|
||||
handler, vpath = super().find_handler(path)
|
||||
# Punctuation is translated to underscores before the attribute lookup
|
||||
if any(segment.translate(self.translate).startswith("_") for segment in path.split("/")):
|
||||
return None, []
|
||||
return handler, vpath
|
||||
|
||||
|
||||
def secured_expose(
|
||||
wrap_func: Optional[Callable] = None,
|
||||
check_configlock: bool = False,
|
||||
check_for_login: bool = True,
|
||||
check_api_key: bool = False,
|
||||
api_route: bool = False,
|
||||
access_type: int = 4,
|
||||
) -> Callable | str:
|
||||
"""Wrapper for both cherrypy.expose and login/access check"""
|
||||
@@ -119,14 +132,15 @@ def secured_expose(
|
||||
check_configlock=check_configlock,
|
||||
check_for_login=check_for_login,
|
||||
check_api_key=check_api_key,
|
||||
api_route=api_route,
|
||||
access_type=access_type,
|
||||
)
|
||||
|
||||
# Expose to cherrypy
|
||||
wrap_func.exposed = True
|
||||
|
||||
@functools.wraps(wrap_func)
|
||||
def internal_wrap(*args, **kwargs):
|
||||
if len(args) > 1:
|
||||
raise cherrypy.NotFound()
|
||||
|
||||
# Label for logging in this and other functions, handling X-Forwarded-For
|
||||
# The cherrypy.request object allows adding custom attributes
|
||||
if cherrypy.request.headers.get("X-Forwarded-For"):
|
||||
@@ -182,7 +196,7 @@ def secured_expose(
|
||||
|
||||
# Some pages need correct API key
|
||||
if check_api_key:
|
||||
if msg := check_apikey(kwargs):
|
||||
if msg := check_apikey(kwargs, api_route=api_route):
|
||||
cherrypy.response.status = 403
|
||||
if cfg.api_warnings():
|
||||
return msg
|
||||
@@ -191,6 +205,9 @@ def secured_expose(
|
||||
# All good, cool!
|
||||
return wrap_func(*args, **kwargs)
|
||||
|
||||
# Expose only the wrapper to cherrypy
|
||||
del internal_wrap.__wrapped__
|
||||
internal_wrap.exposed = True
|
||||
return internal_wrap
|
||||
|
||||
|
||||
@@ -262,8 +279,10 @@ def check_hostname():
|
||||
return False
|
||||
|
||||
|
||||
# Create a more unique ID for each instance
|
||||
COOKIE_SECRET = str(randint(1000, 100000) * os.getpid())
|
||||
# Per-process secret used to sign login cookies. Regenerated on every start,
|
||||
# so sessions do not survive a restart (as before), but with enough entropy
|
||||
# that a captured cookie cannot be brute-forced back to the secret.
|
||||
COOKIE_SECRET = secrets.token_hex(32)
|
||||
|
||||
|
||||
def remote_ip_from_xff(xff_ips: list[str]) -> str:
|
||||
@@ -295,23 +314,25 @@ def set_login_cookie(remove=False, remember_me=False):
|
||||
else:
|
||||
remote_ip = cherrypy.request.remote.ip
|
||||
|
||||
cookie_str = utob(str(salt) + remote_ip + COOKIE_SECRET)
|
||||
cherrypy.response.cookie["login_cookie"] = hashlib.sha1(cookie_str).hexdigest()
|
||||
cherrypy.response.cookie["login_cookie"]["path"] = "/"
|
||||
cherrypy.response.cookie["login_cookie"]["httponly"] = 1
|
||||
cherrypy.response.cookie["login_salt"] = salt
|
||||
cherrypy.response.cookie["login_salt"]["path"] = "/"
|
||||
cherrypy.response.cookie["login_salt"]["httponly"] = 1
|
||||
|
||||
# If we want to be remembered
|
||||
if remember_me:
|
||||
cherrypy.response.cookie["login_cookie"]["max-age"] = 3600 * 24 * 14
|
||||
cherrypy.response.cookie["login_salt"]["max-age"] = 3600 * 24 * 14
|
||||
|
||||
# To remove
|
||||
if remove:
|
||||
# Never emit a valid cookie/salt pair on logout
|
||||
cherrypy.response.cookie["login_cookie"] = ""
|
||||
cherrypy.response.cookie["login_salt"] = ""
|
||||
cherrypy.response.cookie["login_cookie"]["expires"] = 0
|
||||
cherrypy.response.cookie["login_salt"]["expires"] = 0
|
||||
else:
|
||||
cookie_str = utob(str(salt) + remote_ip + COOKIE_SECRET)
|
||||
cherrypy.response.cookie["login_cookie"] = hashlib.sha1(cookie_str).hexdigest()
|
||||
cherrypy.response.cookie["login_salt"] = salt
|
||||
# If we want to be remembered
|
||||
if remember_me:
|
||||
cherrypy.response.cookie["login_cookie"]["max-age"] = 3600 * 24 * 14
|
||||
cherrypy.response.cookie["login_salt"]["max-age"] = 3600 * 24 * 14
|
||||
|
||||
cherrypy.response.cookie["login_cookie"]["path"] = "/"
|
||||
cherrypy.response.cookie["login_cookie"]["httponly"] = 1
|
||||
cherrypy.response.cookie["login_salt"]["path"] = "/"
|
||||
cherrypy.response.cookie["login_salt"]["httponly"] = 1
|
||||
|
||||
|
||||
def check_login_cookie():
|
||||
@@ -332,10 +353,14 @@ def check_login_cookie():
|
||||
|
||||
|
||||
def check_login():
|
||||
# Not when no authentication required or basic-auth is on
|
||||
if not cfg.html_login() or not cfg.username() or not cfg.password():
|
||||
# Not when no authentication required
|
||||
if not cfg.username() or not cfg.password():
|
||||
return True
|
||||
|
||||
# Basic-auth is checked by cherrypy, only on the routes where the tool is enabled
|
||||
if not cfg.html_login():
|
||||
return bool(cherrypy.request.login)
|
||||
|
||||
# If we show login for external IP, by using access_type=6 we can check if IP match
|
||||
if cfg.inet_exposure() == 5 and check_access(access_type=6):
|
||||
return True
|
||||
@@ -369,31 +394,34 @@ def set_auth(conf):
|
||||
conf.update({"tools.auth_basic.on": False})
|
||||
|
||||
|
||||
def check_apikey(kwargs):
|
||||
"""Check API-key or NZB-key
|
||||
Return None when OK, otherwise an error message
|
||||
"""
|
||||
mode = kwargs.get("mode", "")
|
||||
name = kwargs.get("name", "")
|
||||
|
||||
# Lookup required access level for the specific api-call
|
||||
req_access = sabnzbd.api.api_level(mode, name)
|
||||
if not check_access(req_access, warn_user=True):
|
||||
return _MSG_ACCESS_DENIED
|
||||
|
||||
# Skip for auth and version calls
|
||||
if mode in ("version", "auth"):
|
||||
return None
|
||||
|
||||
# First check API-key, if OK that's sufficient
|
||||
def check_apikey(kwargs, api_route: bool = False):
|
||||
"""Check API-key or NZB-key, return None when OK, else an error message.
|
||||
Only the real /api route trusts "mode"; elsewhere it's attacker-controlled,
|
||||
so a valid API-key is always required."""
|
||||
key = kwargs.get("apikey")
|
||||
|
||||
if api_route:
|
||||
mode = kwargs.get("mode", "")
|
||||
name = kwargs.get("name", "")
|
||||
|
||||
req_access = sabnzbd.api.api_level(mode, name)
|
||||
if not check_access(req_access, warn_user=True):
|
||||
return _MSG_ACCESS_DENIED
|
||||
|
||||
# Skip for auth and version calls
|
||||
if mode in ("version", "auth"):
|
||||
return None
|
||||
|
||||
# NZB-key suffices for nzb-level calls
|
||||
if req_access == 1 and key and key == cfg.nzb_key():
|
||||
return None
|
||||
|
||||
# A valid API-key is required for everything else
|
||||
if not key:
|
||||
log_warning_and_ip(
|
||||
T("API Key missing, please enter the api key from Config->General into your 3rd party program:")
|
||||
)
|
||||
return _MSG_APIKEY_REQUIRED
|
||||
elif req_access == 1 and key == cfg.nzb_key():
|
||||
return None
|
||||
elif key == cfg.api_key():
|
||||
return None
|
||||
else:
|
||||
@@ -492,7 +520,7 @@ class MainPage:
|
||||
sabnzbd.shutdown_program()
|
||||
return T("SABnzbd shutdown finished")
|
||||
|
||||
@secured_expose(check_api_key=True, access_type=1)
|
||||
@secured_expose(check_api_key=True, api_route=True, access_type=1)
|
||||
def api(self, **kwargs):
|
||||
"""Redirect to API-handler, we check the access_type in the API-handler"""
|
||||
return api_handler(kwargs)
|
||||
|
||||
@@ -20,6 +20,7 @@ sabnzbd.macosmenu - macOS Top Menu
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
@@ -506,7 +507,7 @@ class SABnzbdDelegate(NSObject):
|
||||
|
||||
def openFolderAction_(self, sender):
|
||||
folder2open = sender.representedObject()
|
||||
os.system('open "%s"' % folder2open)
|
||||
subprocess.run(["/usr/bin/open", folder2open])
|
||||
|
||||
def restartAction_(self, sender):
|
||||
self.setMenuTitle_("\n\n%s\n" % (T("Stopping...")))
|
||||
@@ -585,7 +586,7 @@ class SABnzbdDelegate(NSObject):
|
||||
elif notification.activationType() == 2:
|
||||
# User clicked on the action button
|
||||
if os.path.exists(folder2open := notification.userInfo()["value"]):
|
||||
os.system('open "%s"' % folder2open)
|
||||
subprocess.run(["/usr/bin/open", folder2open])
|
||||
|
||||
# Remove this notification after interaction
|
||||
DefaultUserNotificationCenter._removeDisplayedNotification_(notification)
|
||||
+7
-1
@@ -61,6 +61,7 @@ from sabnzbd.constants import (
|
||||
DEF_ARTICLE_CACHE_MAX,
|
||||
REPAIR_REQUEST,
|
||||
GUESSIT_SORT_TYPES,
|
||||
PP_LOOKUP,
|
||||
)
|
||||
import sabnzbd.config as config
|
||||
import sabnzbd.cfg as cfg
|
||||
@@ -244,7 +245,7 @@ def cat_pp_script_sanitizer(
|
||||
cat: Optional[str] = None,
|
||||
pp: Optional[int | str] = None,
|
||||
script: Optional[str] = None,
|
||||
) -> tuple[Optional[int | str], Optional[str], Optional[str]]:
|
||||
) -> tuple[Optional[str], Optional[int], Optional[str]]:
|
||||
"""Basic sanitizer from outside input to a bit more predictable values"""
|
||||
# * and Default are valid values
|
||||
if safe_lower(cat) in ("", "none"):
|
||||
@@ -253,6 +254,11 @@ def cat_pp_script_sanitizer(
|
||||
# Cannot use "not pp" because pp can also be 0
|
||||
if safe_lower(pp) in ("", "-1", "none"):
|
||||
pp = None
|
||||
else:
|
||||
# Only accept a valid pp value (key of PP_LOOKUP)
|
||||
pp = int_conv(pp)
|
||||
if pp not in PP_LOOKUP:
|
||||
pp = None
|
||||
|
||||
# Check for valid script is performed in NzbObject init
|
||||
if not script or safe_lower(script) == "default":
|
||||
|
||||
+21
-2
@@ -70,6 +70,7 @@ from sabnzbd.filesystem import (
|
||||
create_all_dirs,
|
||||
UNWANTED_FILE_PERMISSIONS,
|
||||
get_unique_filename,
|
||||
points_outside,
|
||||
)
|
||||
from sabnzbd.nzb import NzbObject
|
||||
import sabnzbd.cfg as cfg
|
||||
@@ -454,7 +455,7 @@ def file_join(nzo: NzbObject, workdir_complete: str, joinables: list[str]) -> tu
|
||||
joinable_sets[joinable_set].sort()
|
||||
|
||||
# If par2 already did the work, just remove the files
|
||||
if os.path.exists(joinable_set):
|
||||
if os.path.lexists(joinable_set):
|
||||
logging.debug("file_join(): Skipping %s, (probably) joined by par2", joinable_set)
|
||||
if nzo.delete:
|
||||
clean_up_joinables(current)
|
||||
@@ -472,6 +473,12 @@ def file_join(nzo: NzbObject, workdir_complete: str, joinables: list[str]) -> tu
|
||||
filename = filename.replace(nzo.download_path, workdir_complete)
|
||||
logging.debug("file_join(): Assembling %s", filename)
|
||||
|
||||
join_root = workdir_complete or nzo.download_path
|
||||
if points_outside(join_root, filename):
|
||||
raise OSError("Refusing to join into %s, it points outside %s" % (filename, join_root))
|
||||
if os.path.islink(filename):
|
||||
raise OSError("Refusing to join into %s, it is a link" % filename)
|
||||
|
||||
# Join the segments
|
||||
with open(filename, "ab") as joined_file:
|
||||
n = get_seq_number(current[0])
|
||||
@@ -1107,6 +1114,9 @@ def tar_extract(nzo: NzbObject, tar_path: str, extraction_path: str, one_folder:
|
||||
|
||||
def tar_filter(member: tarfile.TarInfo, path: str) -> Optional[tarfile.TarInfo]:
|
||||
"""Applies tarfile.data_filter, removes unwanted permissions and can prevent overwrites"""
|
||||
if not member.isreg() and not member.isdir():
|
||||
logging.info("Skipping %s from tar file, it is not a file or folder", member.name)
|
||||
return None
|
||||
member = tarfile.data_filter(member, path)
|
||||
if member is not None and member.isreg():
|
||||
member = member.replace(mode=member.mode & ~UNWANTED_FILE_PERMISSIONS)
|
||||
@@ -1116,6 +1126,9 @@ def tar_extract(nzo: NzbObject, tar_path: str, extraction_path: str, one_folder:
|
||||
member = member.replace(
|
||||
name=os.path.relpath(get_unique_filename(os.path.join(path, member.name)), path)
|
||||
)
|
||||
if points_outside(path, os.path.join(path, member.name)):
|
||||
logging.info("Skipping %s from tar file, it points outside %s", member.name, path)
|
||||
return None
|
||||
new_files.append(os.path.join(extraction_path, member.name))
|
||||
return member
|
||||
|
||||
@@ -1867,7 +1880,13 @@ def sfv_check(sfvs: list[str], nzo: NzbObject) -> bool:
|
||||
if calculated_crc32.get(nzf.filename, "") == sfv_parse_results[file]:
|
||||
try:
|
||||
logging.debug("SFV-check will rename %s to %s", nzf.filename, file)
|
||||
renamer(os.path.join(nzo.download_path, nzf.filename), os.path.join(nzo.download_path, file))
|
||||
# Untrusted sfv name: normalize separators and contain
|
||||
file = os.path.normpath(file)
|
||||
renamer(
|
||||
os.path.join(nzo.download_path, nzf.filename),
|
||||
os.path.join(nzo.download_path, file),
|
||||
create_local_directories=True,
|
||||
)
|
||||
renames[file] = nzf.filename
|
||||
nzf.filename = file
|
||||
result &= True
|
||||
|
||||
+12
-1
@@ -89,6 +89,8 @@ from sabnzbd.filesystem import (
|
||||
remove_data,
|
||||
get_ext,
|
||||
create_work_name,
|
||||
points_outside,
|
||||
points_into_admin_dir,
|
||||
RAR_RE,
|
||||
)
|
||||
from sabnzbd.par2file import FilePar2Info, has_par2_in_filename, analyse_par2, parse_par2_file, is_par2_file
|
||||
@@ -1450,10 +1452,19 @@ class NzbObject(TryList):
|
||||
candidate = filename
|
||||
path = os.path.join(directory, candidate)
|
||||
num = 1
|
||||
while candidate in self.filenames or os.path.exists(path):
|
||||
while candidate in self.filenames or os.path.lexists(path):
|
||||
candidate = f"{base_name}.{num}{ext}"
|
||||
path = os.path.join(directory, candidate)
|
||||
num += 1
|
||||
|
||||
if subdir := os.path.dirname(candidate):
|
||||
# sanitize_filename() keeps the name local, so this should never trigger
|
||||
if points_outside(directory, path):
|
||||
raise ValueError("Refusing to write %s outside of %s" % (candidate, directory))
|
||||
if points_into_admin_dir(path, directory):
|
||||
raise ValueError("Refusing to write %s into the admin folder" % candidate)
|
||||
create_all_dirs(os.path.join(directory, subdir))
|
||||
|
||||
self.filenames.add(candidate)
|
||||
return path
|
||||
|
||||
|
||||
+1
-1
@@ -515,7 +515,7 @@ def process_job(nzo: NzbObject) -> bool:
|
||||
if JOB_ADMIN not in path:
|
||||
new_path = path.replace(nzo.download_path, tmp_workdir_complete)
|
||||
nzo.set_action_line(T("Moving"), get_filename(path))
|
||||
ok, new_path = move_to_path(path, new_path)
|
||||
ok, new_path = move_to_path(path, new_path, root=tmp_workdir_complete)
|
||||
if new_path:
|
||||
newfiles.append(new_path)
|
||||
if not ok:
|
||||
|
||||
+40
-15
@@ -621,10 +621,10 @@ class RSSRepository:
|
||||
"""
|
||||
Expire G/B links that are not in new_jobs (mark them 'X')
|
||||
|
||||
Expired links older than 3 days are removed
|
||||
Expired links older than 7 days are removed
|
||||
"""
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
limit = int((now - datetime.timedelta(days=3)).timestamp())
|
||||
limit = int((now - datetime.timedelta(days=7)).timestamp())
|
||||
|
||||
if new_urls:
|
||||
# Create temporary table for all new URLs
|
||||
@@ -635,6 +635,22 @@ class RSSRepository:
|
||||
placeholders = ",".join(["(?)"] * len(batch))
|
||||
self.db.execute(f"INSERT INTO temp_urls(url) VALUES {placeholders}", batch)
|
||||
|
||||
# Refresh seen_at for everything still listed in the feed. Entries in a terminal
|
||||
# state are skipped during evaluation, so this is the only place they are touched
|
||||
# and without it they would be purged while still present in the feed.
|
||||
self.db.execute(
|
||||
"""
|
||||
UPDATE rss
|
||||
SET seen_at = ?
|
||||
WHERE feed = ?
|
||||
AND url IN (SELECT url FROM temp_urls)
|
||||
""",
|
||||
(
|
||||
int(datetime.datetime.now(datetime.timezone.utc).timestamp()),
|
||||
feed,
|
||||
),
|
||||
)
|
||||
|
||||
# Update rss to mark G/B not in temp_urls as X
|
||||
self.db.execute(
|
||||
"""
|
||||
@@ -686,6 +702,7 @@ class RSSRepository:
|
||||
def get_feed_jobs(
|
||||
self,
|
||||
feed: Optional[str] = None,
|
||||
archive: Optional[bool] = None,
|
||||
search: Optional[str] = None,
|
||||
states: Optional[list[RSSState]] = None,
|
||||
) -> Generator[ResolvedEntry, Any, None]:
|
||||
@@ -693,6 +710,11 @@ class RSSRepository:
|
||||
command_args = []
|
||||
where_clauses = []
|
||||
|
||||
if archive:
|
||||
where_clauses.append("archived_at IS NOT NULL")
|
||||
else:
|
||||
where_clauses.append("archived_at IS NULL")
|
||||
|
||||
if search is not None:
|
||||
where_clauses.append("title LIKE ?")
|
||||
command_args.append(convert_search(search))
|
||||
@@ -847,10 +869,13 @@ class RSSRepository:
|
||||
)
|
||||
return bool(self.db.cursor.fetchone()["found"])
|
||||
|
||||
def expired_purge(self):
|
||||
"""Removed expired links from all feeds"""
|
||||
def purge_removed_feeds(self):
|
||||
"""Remove all records of feeds that are no longer configured"""
|
||||
configured = set(config.get_rss())
|
||||
for feed in self.get_feeds():
|
||||
self.remove_obsolete(feed)
|
||||
if feed not in configured:
|
||||
logging.debug("Purging records of removed feed %s", feed)
|
||||
self.clear_feed(feed)
|
||||
|
||||
def import_rss_records(self):
|
||||
"""Migrate old RSS database"""
|
||||
@@ -964,7 +989,8 @@ class RSSReader:
|
||||
if readout:
|
||||
gen = self.fetch_rss(feed, uris)
|
||||
else:
|
||||
gen = repo.get_feed_jobs(feed=feed)
|
||||
# Materialize before evaluating: each write needs the read cursor closed
|
||||
gen = list(repo.get_feed_jobs(feed=feed))
|
||||
|
||||
# Evaluate rules and apply side effects
|
||||
try:
|
||||
@@ -981,7 +1007,6 @@ class RSSReader:
|
||||
new_links.add(entry.link)
|
||||
|
||||
downloaded = self._process_entry(
|
||||
repo,
|
||||
feed_entry=entry,
|
||||
filters=filters,
|
||||
first=first,
|
||||
@@ -998,7 +1023,8 @@ class RSSReader:
|
||||
if new_downloads and cfg.email_rss() and not force:
|
||||
emailer.rss_mail(feed, new_downloads)
|
||||
|
||||
repo.remove_obsolete(feed, new_links, purge_downloaded=True)
|
||||
if readout:
|
||||
repo.remove_obsolete(feed, new_links, purge_downloaded=True)
|
||||
|
||||
return ""
|
||||
|
||||
@@ -1119,8 +1145,6 @@ class RSSReader:
|
||||
|
||||
def _process_entry(
|
||||
self,
|
||||
repo: RSSRepository,
|
||||
*,
|
||||
feed_entry: ResolvedEntry,
|
||||
filters: FeedConfig,
|
||||
first: bool,
|
||||
@@ -1178,8 +1202,9 @@ class RSSReader:
|
||||
initial_scan=initial_scan,
|
||||
)
|
||||
|
||||
repo.upsert(resolved_entry)
|
||||
self.enqueue_download(repo, resolved_entry)
|
||||
with sabnzbd.rss.rss_repository() as repo:
|
||||
repo.upsert(resolved_entry)
|
||||
self.enqueue_download(repo, resolved_entry)
|
||||
|
||||
return bool(evaluation.matched and should_download)
|
||||
|
||||
@@ -1266,10 +1291,10 @@ def special_rss_site(url: str) -> bool:
|
||||
return bool(cfg.rss_filenames() or match_str(url, cfg.rss_odd_titles()))
|
||||
|
||||
|
||||
def expired_purge():
|
||||
"""Purge links older than 3 days"""
|
||||
def purge_removed_feeds():
|
||||
"""Purge records of feeds that are no longer configured"""
|
||||
with rss_repository() as repo:
|
||||
repo.expired_purge()
|
||||
repo.purge_removed_feeds()
|
||||
|
||||
|
||||
@contextmanager
|
||||
|
||||
@@ -822,6 +822,9 @@ SKIN_TEXT = {
|
||||
"Glitter-freeTemp": TT("Temp Folder"),
|
||||
"Glitter-search": TT("Search"),
|
||||
"Glitter-clearSearch": TT("Clear search"),
|
||||
"Glitter-searchExplain": TT(
|
||||
"Search by name, or refine with filters <code>cat:</code>, <code>status:</code> and <code>priority:</code> (Queue). For example: <code>my show cat:tv</code>"
|
||||
), #: Explains the queue/history search box, shown as a tooltip
|
||||
"Glitter-multiOperations": TT("Multi-Operations"),
|
||||
"Glitter-multiSelect": TT("Hold shift key to select a range"),
|
||||
"Glitter-selectJob": TT("Select job"),
|
||||
|
||||
+1
-1
@@ -648,7 +648,7 @@ def move_to_parent_directory(workdir: str, files: list[str]) -> tuple[str, bool,
|
||||
# Not inside the directory we move, keep as-is
|
||||
moved_files.append(path)
|
||||
continue
|
||||
ok, new_path = move_to_path(path, path.replace(workdir, dest))
|
||||
ok, new_path = move_to_path(path, path.replace(workdir, dest), root=dest)
|
||||
if not ok:
|
||||
return dest, False, moved_files + files[nr:]
|
||||
if new_path:
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
# You MUST use double quotes (so " and not ')
|
||||
# Do not forget to update the appdata file for every major release!
|
||||
|
||||
__version__ = "5.1.0RC2"
|
||||
__version__ = "5.1.3"
|
||||
__baseline__ = "unknown"
|
||||
File renamed without changes.
Binary file not shown.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+323
-1
@@ -19,10 +19,14 @@
|
||||
tests.test_filesystem - Testing functions in filesystem.py
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import io
|
||||
import pickle
|
||||
import stat
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
@@ -38,7 +42,7 @@ import sabnzbd
|
||||
import sabnzbd.cfg
|
||||
from sabnzbd import cfg
|
||||
import sabnzbd.filesystem as filesystem
|
||||
from sabnzbd.constants import DEF_FOLDER_MAX, DEF_FILE_MAX
|
||||
from sabnzbd.constants import DEF_FOLDER_MAX, DEF_FILE_MAX, JOB_ADMIN
|
||||
|
||||
# Set the global uid for fake filesystems to a non-root user;
|
||||
# by default this depends on the user running pytest.
|
||||
@@ -123,6 +127,88 @@ class TestFileFolderNameSanitizer:
|
||||
assert filesystem.sanitize_filename("../") == ".._"
|
||||
assert filesystem.sanitize_filename("../test") == ".._test"
|
||||
|
||||
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
|
||||
@pytest.mark.platform()
|
||||
def test_file_allow_subdirs(self, platform):
|
||||
"""Par2 uses "/" to separate sub-directories, no matter which platform created the set"""
|
||||
assert filesystem.sanitize_filename("sub/test.rar", allow_subdirs=True) == os.path.join("sub", "test.rar")
|
||||
assert filesystem.sanitize_filename("sub/deeper/test.rar", allow_subdirs=True) == os.path.join(
|
||||
"sub", "deeper", "test.rar"
|
||||
)
|
||||
# No sub-directory at all, or nothing but separators
|
||||
assert filesystem.sanitize_filename("test.rar", allow_subdirs=True) == "test.rar"
|
||||
assert filesystem.sanitize_filename("a//b.rar", allow_subdirs=True) == os.path.join("a", "b.rar")
|
||||
assert filesystem.sanitize_filename("sub/./test.rar", allow_subdirs=True) == os.path.join("sub", "test.rar")
|
||||
# Every part is sanitized on its own, chr(0) is illegal on all platforms
|
||||
assert filesystem.sanitize_filename("sub" + chr(0) + "1/test" + chr(0) + "2.rar", allow_subdirs=True) == (
|
||||
os.path.join("sub_1", "test_2.rar")
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostile_name",
|
||||
[
|
||||
"/test.rar",
|
||||
"//test.rar",
|
||||
"../test.rar",
|
||||
"../../../../../../etc/shadow",
|
||||
"sub/../../test.rar",
|
||||
"sub/../../../sub/test.rar",
|
||||
"./../test.rar",
|
||||
"../..",
|
||||
"../",
|
||||
"/",
|
||||
"//",
|
||||
"/../",
|
||||
"...",
|
||||
"....",
|
||||
# Whitespace must not hide a part from the checks, it is stripped while sanitizing
|
||||
" .. /test.rar",
|
||||
" .. / .. /etc/shadow",
|
||||
"sub/ .. / .. /test.rar",
|
||||
"\t..\t/test.rar",
|
||||
"\xa0../test.rar",
|
||||
" .. ",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
|
||||
@pytest.mark.platform()
|
||||
def test_file_allow_subdirs_cannot_escape(self, platform, hostile_name):
|
||||
"""Whatever the par2 claims, the result has to stay inside the folder it is used in.
|
||||
Joining it onto any base directory must never point above that base."""
|
||||
result = filesystem.sanitize_filename(hostile_name, allow_subdirs=True)
|
||||
|
||||
assert result, "an empty result would resolve to the base directory itself"
|
||||
assert not os.path.isabs(result)
|
||||
assert os.pardir not in result.split(os.sep)
|
||||
|
||||
# The real test: it cannot climb out of whatever it gets joined to
|
||||
base = os.path.join(os.sep + "downloads", "incomplete", "job")
|
||||
resolved = os.path.normpath(os.path.join(base, result))
|
||||
assert resolved.startswith(base + os.sep), "%s escaped to %s" % (hostile_name, resolved)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostile_name",
|
||||
[
|
||||
JOB_ADMIN,
|
||||
JOB_ADMIN + "/__verified__",
|
||||
JOB_ADMIN.lower() + "/__verified__",
|
||||
"sub/" + JOB_ADMIN + "/__verified__",
|
||||
JOB_ADMIN + "/deeper/__verified__",
|
||||
# Whitespace must not hide a part from the checks, it is stripped while sanitizing
|
||||
" " + JOB_ADMIN + " /__verified__",
|
||||
"\t" + JOB_ADMIN + "\t/__verified__",
|
||||
"sub/ " + JOB_ADMIN.lower() + " /__verified__",
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("platform", ["win32", "macos", "linux"])
|
||||
@pytest.mark.platform()
|
||||
def test_file_allow_subdirs_cannot_enter_admin(self, platform, hostile_name):
|
||||
"""The admin folder is pickle-loaded, so a par2 name must never point into it"""
|
||||
result = filesystem.sanitize_filename(hostile_name, allow_subdirs=True)
|
||||
|
||||
assert result, "an empty result would resolve to the base directory itself"
|
||||
assert JOB_ADMIN.lower() not in result.lower().split(os.sep)
|
||||
|
||||
@pytest.mark.platform("linux")
|
||||
def test_folder_illegal_chars_linux(self):
|
||||
assert filesystem.sanitize_foldername('test"aftertest') == "test_aftertest"
|
||||
@@ -443,6 +529,131 @@ class TestSameDirectory:
|
||||
assert 0 == filesystem.same_directory("/test/../Home", "/home")
|
||||
|
||||
|
||||
class TestPointsIntoAdminDir:
|
||||
def test_by_name(self, tmp_path):
|
||||
base = str(tmp_path)
|
||||
assert filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN), base)
|
||||
assert filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN, "__verified__"), base)
|
||||
assert filesystem.points_into_admin_dir(os.path.join(base, "sub", JOB_ADMIN, "__verified__"), base)
|
||||
|
||||
def test_regular_names_are_left_alone(self, tmp_path):
|
||||
base = str(tmp_path)
|
||||
assert not filesystem.points_into_admin_dir(os.path.join(base, "testfile.rar"), base)
|
||||
assert not filesystem.points_into_admin_dir(os.path.join(base, "sub", "testfile.rar"), base)
|
||||
# Only a full part counts, not a name that merely starts with it
|
||||
assert not filesystem.points_into_admin_dir(os.path.join(base, JOB_ADMIN + "-data", "testfile.rar"), base)
|
||||
|
||||
def test_link_cannot_hide_it(self, tmp_path):
|
||||
"""On Windows an NTFS 8.3 alias ("__ADMI~1") points at the admin folder under a
|
||||
different name, exactly like a link does here, so the name cannot be trusted"""
|
||||
base = str(tmp_path)
|
||||
admin_dir = os.path.join(base, JOB_ADMIN)
|
||||
os.mkdir(admin_dir)
|
||||
linkname = os.path.join(base, "notadmin")
|
||||
os.symlink(admin_dir, linkname)
|
||||
|
||||
assert filesystem.points_into_admin_dir(linkname, base)
|
||||
assert filesystem.points_into_admin_dir(os.path.join(linkname, "__verified__"), base)
|
||||
|
||||
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="NTFS 8.3 aliases only exist on Windows")
|
||||
def test_ntfs_8dot3_alias_cannot_hide_it(self, tmp_path):
|
||||
"""The real thing the link above stands in for: NTFS keeps an 8.3 alias for every
|
||||
long name, so "__ADMI~1" reaches the admin folder without ever spelling it out"""
|
||||
import win32api
|
||||
|
||||
base = str(tmp_path)
|
||||
admin_dir = os.path.join(base, JOB_ADMIN)
|
||||
os.mkdir(admin_dir)
|
||||
|
||||
# Ask the filesystem for the alias instead of assuming what it generated
|
||||
alias = os.path.basename(win32api.GetShortPathName(admin_dir))
|
||||
if alias.lower() == JOB_ADMIN.lower():
|
||||
pytest.skip("8.3 name creation is disabled on this volume")
|
||||
|
||||
assert filesystem.points_into_admin_dir(os.path.join(base, alias), base)
|
||||
assert filesystem.points_into_admin_dir(os.path.join(base, alias, "__verified__"), base)
|
||||
|
||||
# And the rename that the alias was meant to sneak through has to fail
|
||||
filename = os.path.join(base, "myfile.txt")
|
||||
Path(filename).touch()
|
||||
with pytest.raises(OSError):
|
||||
filesystem.renamer(filename, os.path.join(base, alias, "__verified__"), create_local_directories=True)
|
||||
assert os.path.isfile(filename)
|
||||
assert not os.listdir(admin_dir)
|
||||
|
||||
|
||||
class TestPointsOutside:
|
||||
def test_inside(self, tmp_path):
|
||||
base = str(tmp_path)
|
||||
assert not filesystem.points_outside(base, os.path.join(base, "file.bin"))
|
||||
assert not filesystem.points_outside(base, os.path.join(base, "sub", "file.bin"))
|
||||
|
||||
def test_outside(self, tmp_path):
|
||||
base = str(tmp_path)
|
||||
assert filesystem.points_outside(base, os.path.join(base, os.pardir, "file.bin"))
|
||||
assert filesystem.points_outside(base, os.path.join(base, "sub", os.pardir, os.pardir, "file.bin"))
|
||||
|
||||
def test_root_reached_through_a_link_is_fine(self, tmp_path):
|
||||
"""The download and complete folder are allowed to be a link"""
|
||||
base = str(tmp_path)
|
||||
real = os.path.join(base, "real")
|
||||
os.mkdir(real)
|
||||
link = os.path.join(base, "link")
|
||||
os.symlink(real, link)
|
||||
|
||||
assert not filesystem.points_outside(link, os.path.join(link, "file.bin"))
|
||||
assert not filesystem.points_outside(link, os.path.join(real, "file.bin"))
|
||||
assert not filesystem.points_outside(real, os.path.join(link, "file.bin"))
|
||||
assert filesystem.points_outside(link, os.path.join(link, os.pardir, "file.bin"))
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform.startswith("win"), reason="Windows collapses '..' before the filesystem resolves links"
|
||||
)
|
||||
def test_link_inside_the_root_cannot_redirect(self, tmp_path):
|
||||
base = str(tmp_path)
|
||||
root = os.path.join(base, "root")
|
||||
os.mkdir(root)
|
||||
os.symlink(base, os.path.join(root, "up"))
|
||||
os.symlink(".", os.path.join(root, "pivot"))
|
||||
|
||||
assert filesystem.points_outside(root, os.path.join(root, "up", "file.bin"))
|
||||
assert filesystem.points_outside(root, os.path.join(root, "pivot", os.pardir, "file.bin"))
|
||||
assert not filesystem.points_outside(root, os.path.join(root, "pivot", "file.bin"))
|
||||
|
||||
|
||||
class TestMoveToPath:
|
||||
def test_link_in_a_parent_cannot_redirect(self, tmp_path):
|
||||
"""A linked directory in the path redirects the move just like a linked leaf"""
|
||||
base = str(tmp_path)
|
||||
root = os.path.join(base, "complete")
|
||||
outside = os.path.join(base, "outside")
|
||||
os.makedirs(root)
|
||||
os.makedirs(outside)
|
||||
os.symlink(outside, os.path.join(root, "sub"))
|
||||
|
||||
source = os.path.join(base, "source.bin")
|
||||
Path(source).touch()
|
||||
ok, new_path = filesystem.move_to_path(source, os.path.join(root, "sub", "moved.bin"), root=root)
|
||||
|
||||
assert not ok
|
||||
assert not new_path
|
||||
assert os.path.isfile(source)
|
||||
assert not os.listdir(outside)
|
||||
|
||||
def test_move_inside_the_root_still_works(self, tmp_path):
|
||||
base = str(tmp_path)
|
||||
root = os.path.join(base, "complete")
|
||||
os.makedirs(root)
|
||||
source = os.path.join(base, "source.bin")
|
||||
Path(source).touch()
|
||||
|
||||
ok, new_path = filesystem.move_to_path(source, os.path.join(root, "sub", "moved.bin"), root=root)
|
||||
|
||||
assert ok
|
||||
assert os.path.isfile(new_path)
|
||||
assert not os.path.isfile(source)
|
||||
|
||||
|
||||
class TestFirstExistingPath:
|
||||
def test_existing_path(self, tmp_path):
|
||||
assert filesystem.first_existing_path(str(tmp_path)) == str(tmp_path)
|
||||
@@ -874,6 +1085,16 @@ class TestGetUniqueDirFilename:
|
||||
fake_fs.create_file(first_filename)
|
||||
assert filesystem.get_unique_filename(test_file) == "/some/filename.2"
|
||||
|
||||
def test_dangling_link_is_taken(self, tmp_path):
|
||||
"""A link whose target is missing still occupies the name, and handing it out would
|
||||
write through it to wherever it points"""
|
||||
base = str(tmp_path)
|
||||
test_file = os.path.join(base, "file.name")
|
||||
Path(test_file).touch()
|
||||
os.symlink(os.path.join(base, "does_not_exist"), os.path.join(base, "file.1.name"))
|
||||
|
||||
assert filesystem.get_unique_filename(test_file) == os.path.join(base, "file.2.name")
|
||||
|
||||
|
||||
@pytest.mark.skipif(not sys.platform.startswith("win"), reason="Windows specific tests")
|
||||
class TestGetUniqueDirFilenameWin:
|
||||
@@ -1203,9 +1424,110 @@ class TestRenamer:
|
||||
assert os.path.isfile(filename)
|
||||
assert not os.path.isfile(newfilename)
|
||||
|
||||
# ... renaming into the admin folder is not allowed either
|
||||
admin_dir = os.path.join(dirname, JOB_ADMIN)
|
||||
os.mkdir(admin_dir)
|
||||
Path(filename).touch()
|
||||
newfilename = os.path.join(admin_dir, "__verified__")
|
||||
try:
|
||||
filesystem.renamer(filename, newfilename, create_local_directories=True)
|
||||
except Exception:
|
||||
pass
|
||||
assert os.path.isfile(filename)
|
||||
assert not os.path.isfile(newfilename)
|
||||
|
||||
# ... nor is naming the admin folder itself: a move into an existing directory
|
||||
# keeps the old basename, so this would end up inside the admin folder as well
|
||||
Path(filename).touch()
|
||||
try:
|
||||
filesystem.renamer(filename, admin_dir, create_local_directories=True)
|
||||
except Exception:
|
||||
pass
|
||||
assert os.path.isfile(filename)
|
||||
assert not os.listdir(admin_dir)
|
||||
|
||||
# ... and not under another name that resolves to it, such as a link. On Windows
|
||||
# an NTFS 8.3 alias ("__ADMI~1") reaches the admin folder the very same way.
|
||||
linkname = os.path.join(dirname, "notadmin")
|
||||
os.symlink(admin_dir, linkname)
|
||||
Path(filename).touch()
|
||||
try:
|
||||
filesystem.renamer(filename, os.path.join(linkname, "__verified__"), create_local_directories=True)
|
||||
except Exception:
|
||||
pass
|
||||
assert os.path.isfile(filename)
|
||||
assert not os.listdir(admin_dir)
|
||||
os.remove(linkname)
|
||||
|
||||
# Cleanup working directory
|
||||
shutil.rmtree(dirname)
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform.startswith("win"), reason="Windows collapses '..' before the filesystem resolves links"
|
||||
)
|
||||
def test_link_cannot_redirect_rename(self, tmp_path):
|
||||
"""The filesystem resolves a link before it handles "..", so "pivot/.." lands one
|
||||
level higher than normalizing the path on its own suggests"""
|
||||
base = str(tmp_path)
|
||||
dirname = os.path.join(base, "job")
|
||||
os.mkdir(dirname)
|
||||
os.symlink(".", os.path.join(dirname, "pivot"))
|
||||
|
||||
filename = os.path.join(dirname, "myfile.txt")
|
||||
Path(filename).touch()
|
||||
escaped = os.path.join(base, "escaped.bin")
|
||||
with pytest.raises(OSError):
|
||||
filesystem.renamer(
|
||||
filename, os.path.join(dirname, "pivot", "..", "escaped.bin"), create_local_directories=True
|
||||
)
|
||||
assert os.path.isfile(filename)
|
||||
assert not os.path.exists(escaped)
|
||||
|
||||
# A link that leaves the directory outright is no stepping stone either
|
||||
os.symlink(base, os.path.join(dirname, "outside"))
|
||||
with pytest.raises(OSError):
|
||||
filesystem.renamer(filename, os.path.join(dirname, "outside", "escaped.bin"), create_local_directories=True)
|
||||
assert os.path.isfile(filename)
|
||||
assert not os.path.exists(escaped)
|
||||
|
||||
|
||||
class TestRestrictedUnpickler:
|
||||
def test_round_trip(self, tmp_path):
|
||||
data = {"a": 1, "s": {1, 2}, "when": datetime.datetime(2024, 1, 1), "t": time.gmtime(0), "st": os.stat(".")}
|
||||
filesystem.save_data(data, "d", str(tmp_path))
|
||||
assert filesystem.load_data("d", str(tmp_path), remove=False) == data
|
||||
|
||||
def test_rejects_code_execution_gadget(self):
|
||||
class Evil:
|
||||
def __reduce__(self):
|
||||
return (os.system, ("echo pwned",))
|
||||
|
||||
with pytest.raises(pickle.UnpicklingError):
|
||||
filesystem.RestrictedUnpickler(io.BytesIO(pickle.dumps(Evil()))).load()
|
||||
|
||||
def test_rejects_non_allowlisted_sabnzbd_class(self):
|
||||
# kronos.ForkedScheduler has a __del__ that runs os.kill; referenced by name, rejected pre-import
|
||||
def named_global(module, name):
|
||||
return (
|
||||
b"\x80\x04\x8c"
|
||||
+ bytes([len(module)])
|
||||
+ module.encode()
|
||||
+ b"\x8c"
|
||||
+ bytes([len(name)])
|
||||
+ name.encode()
|
||||
+ b"\x93."
|
||||
)
|
||||
|
||||
with pytest.raises(pickle.UnpicklingError):
|
||||
filesystem.RestrictedUnpickler(io.BytesIO(named_global("sabnzbd.utils.kronos", "ForkedScheduler"))).load()
|
||||
|
||||
def test_loads_legacy_3_0_rss_pickle(self):
|
||||
path = os.path.join(SAB_DATA_DIR, "test_3_0_0_data_format")
|
||||
data = filesystem.load_data("rss_data.sab", path, remove=False)
|
||||
assert isinstance(data, dict) and data
|
||||
feed_jobs = next(iter(data.values()))
|
||||
assert isinstance(feed_jobs, dict) and feed_jobs
|
||||
|
||||
|
||||
class TestUnwantedExtensions:
|
||||
# Only test lowercase extensions without a leading dot: the unwanted_extensions
|
||||
|
||||
@@ -29,6 +29,7 @@ from random import choice, randint, sample
|
||||
from warnings import warn
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from tavern.core import run
|
||||
|
||||
import sabnzbd
|
||||
@@ -327,6 +328,41 @@ class TestOtherApi(ApiTestFunctions):
|
||||
# Restore language setting to default
|
||||
assert self._get_api_json("set_config_default", extra_args={"keyword": "language"})["status"] is True
|
||||
|
||||
@pytest.mark.parametrize("path", ["__wrapped__", "__wrapped__/x"])
|
||||
def test_api_wrapped_not_dispatchable(self, path):
|
||||
"""The unprotected handler below secured_expose must not be reachable via the URL"""
|
||||
response = requests.get(
|
||||
"http://%s:%s/api/%s" % (SAB_HOST, SAB_PORT, path),
|
||||
params={
|
||||
"self": "x",
|
||||
"mode": "get_config",
|
||||
"section": "misc",
|
||||
"keyword": "download_dir",
|
||||
"output": "json",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert "download_dir" not in response.text
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path",
|
||||
[
|
||||
# The handler is a bound method, so __self__ leads back into the page tree
|
||||
"__self__/",
|
||||
"__self__/config/",
|
||||
"__self__/config/general/",
|
||||
"__self__/scriptlog",
|
||||
# Punctuation is translated to underscores before the attribute lookup
|
||||
"--self--/config/",
|
||||
"..self../config/",
|
||||
],
|
||||
)
|
||||
def test_api_page_tree_not_dispatchable(self, path):
|
||||
"""The pages behind the handler must not be reachable through the /api route"""
|
||||
response = requests.get("http://%s:%s/api/%s" % (SAB_HOST, SAB_PORT, path))
|
||||
assert response.status_code == 404
|
||||
assert SAB_APIKEY not in response.text
|
||||
|
||||
def test_api_get_clear_warnings(self):
|
||||
# Trigger warnings by sending requests with a truncated apikey
|
||||
for _ in range(0, 2):
|
||||
|
||||
@@ -228,3 +228,129 @@ class TestInterfaceFunctions:
|
||||
assert interface.remote_ip_from_xff(xff_ips) is expected_result
|
||||
|
||||
_func()
|
||||
|
||||
@pytest.mark.config({"api_key": "the_real_api_key", "nzb_key": "the_real_nzb_key"})
|
||||
@pytest.mark.parametrize(
|
||||
"api_route, kwargs, expected",
|
||||
[
|
||||
# /api route: version/auth public, NZB-key valid for nzb-level calls
|
||||
(True, {"mode": "version"}, None),
|
||||
(True, {"mode": "auth"}, None),
|
||||
(True, {"mode": "addfile", "apikey": "the_real_nzb_key"}, None),
|
||||
(True, {"mode": "queue", "apikey": "the_real_api_key"}, None),
|
||||
(True, {"mode": "queue"}, interface._MSG_APIKEY_REQUIRED),
|
||||
(True, {"mode": "queue", "apikey": "wrong"}, interface._MSG_APIKEY_INCORRECT),
|
||||
# Web-ui routes must ignore 'mode': no version/auth or NZB-key bypass
|
||||
(False, {"mode": "version"}, interface._MSG_APIKEY_REQUIRED),
|
||||
(False, {"mode": "auth"}, interface._MSG_APIKEY_REQUIRED),
|
||||
(False, {"mode": "addfile", "apikey": "the_real_nzb_key"}, interface._MSG_APIKEY_INCORRECT),
|
||||
(False, {"apikey": "the_real_api_key"}, None),
|
||||
(False, {"mode": "version", "apikey": "the_real_api_key"}, None),
|
||||
(False, {"apikey": "wrong"}, interface._MSG_APIKEY_INCORRECT),
|
||||
(False, {}, interface._MSG_APIKEY_REQUIRED),
|
||||
],
|
||||
)
|
||||
def test_check_apikey_ignores_mode_off_api_route(self, api_route, kwargs, expected):
|
||||
"""'mode' is only trusted on the real /api route, not on web-ui handlers."""
|
||||
cherrypy.request.remote.ip = "127.0.0.1"
|
||||
cherrypy.request.headers.update({"X-Forwarded-For": None})
|
||||
cherrypy.request.remote_label = "127.0.0.1 [test]"
|
||||
assert interface.check_apikey(kwargs, api_route=api_route) == expected
|
||||
|
||||
def test_secured_expose_hides_wrapped_function(self):
|
||||
"""The unprotected function must not be reachable below the wrapper"""
|
||||
|
||||
class DummyPage:
|
||||
@interface.secured_expose(check_api_key=True, api_route=True, access_type=1)
|
||||
def api(self, **kwargs):
|
||||
return "protected handler executed"
|
||||
|
||||
assert not hasattr(DummyPage.api, "__wrapped__")
|
||||
assert not any(getattr(value, "exposed", False) for value in vars(DummyPage.api).values())
|
||||
assert not hasattr(interface.MainPage.api, "__wrapped__")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path, should_resolve",
|
||||
[
|
||||
("/", True),
|
||||
("/api", True),
|
||||
("/config/", True),
|
||||
("/config/general/", True),
|
||||
# Handlers are bound methods, so __self__ leads back into the page tree
|
||||
("/api/__self__/", False),
|
||||
("/api/__self__/config/", False),
|
||||
("/api/__self__/config/general/", False),
|
||||
("/config/general/__self__/", False),
|
||||
# Punctuation is translated to underscores before the attribute lookup
|
||||
("/api/--self--/", False),
|
||||
("/api/..self../", False),
|
||||
("/api/__func__/", False),
|
||||
("/api/__wrapped__", False),
|
||||
],
|
||||
)
|
||||
def test_dispatcher_refuses_private_attributes(self, path, should_resolve):
|
||||
"""A private attribute is no route, it reaches a handler while the access rules
|
||||
of another route are applied to the request"""
|
||||
|
||||
class GeneralPage:
|
||||
@interface.secured_expose
|
||||
def index(self, **kwargs):
|
||||
return "general config page"
|
||||
|
||||
class ConfigPage:
|
||||
def __init__(self):
|
||||
self.general = GeneralPage()
|
||||
|
||||
@interface.secured_expose
|
||||
def index(self, **kwargs):
|
||||
return "config page"
|
||||
|
||||
class RootPage:
|
||||
def __init__(self):
|
||||
self.config = ConfigPage()
|
||||
|
||||
@interface.secured_expose
|
||||
def index(self, **kwargs):
|
||||
return "main page"
|
||||
|
||||
@interface.secured_expose(check_api_key=True, api_route=True, access_type=1)
|
||||
def api(self, **kwargs):
|
||||
return "api handler executed"
|
||||
|
||||
cherrypy.serving.request.app = cherrypy.Application(RootPage(), "/")
|
||||
handler, _vpath = interface.SecureDispatcher().find_handler(path)
|
||||
assert bool(handler) is should_resolve
|
||||
|
||||
@pytest.mark.config({"username": "sabuser", "password": "sabpass", "html_login": 0})
|
||||
@pytest.mark.parametrize("login, expected", [(None, False), ("", False), ("sabuser", True)])
|
||||
def test_check_login_verifies_basic_auth(self, login, expected):
|
||||
"""Basic-auth is checked by cherrypy, which does not run on every route, so the
|
||||
login it sets on the request has to be there"""
|
||||
cherrypy.serving.request.login = login
|
||||
assert interface.check_login() is expected
|
||||
|
||||
@pytest.mark.config({"verify_xff_header": False})
|
||||
def test_logout_does_not_leak_valid_cookie(self):
|
||||
"""A logout must never emit a cookie/salt pair that passes check_login_cookie.
|
||||
The Set-Cookie header is readable regardless of its expiry, so leaking valid
|
||||
values there is an authentication bypass (harvest via logout, then replay)."""
|
||||
cherrypy.request.remote.ip = "10.11.12.13"
|
||||
cherrypy.request.headers.update({"X-Forwarded-For": None})
|
||||
|
||||
# Sanity check: a real login produces a cookie that validates
|
||||
cherrypy.response.cookie.clear()
|
||||
interface.set_login_cookie()
|
||||
cherrypy.request.cookie["login_cookie"] = cherrypy.response.cookie["login_cookie"].value
|
||||
cherrypy.request.cookie["login_salt"] = cherrypy.response.cookie["login_salt"].value
|
||||
assert interface.check_login_cookie() is True
|
||||
|
||||
# Logout must blank out the values, not emit a working hash/salt
|
||||
cherrypy.response.cookie.clear()
|
||||
interface.set_login_cookie(remove=True)
|
||||
assert cherrypy.response.cookie["login_cookie"].value == ""
|
||||
assert cherrypy.response.cookie["login_salt"].value == ""
|
||||
|
||||
# Replaying whatever the logout response carried must fail authentication
|
||||
cherrypy.request.cookie["login_cookie"] = cherrypy.response.cookie["login_cookie"].value
|
||||
cherrypy.request.cookie["login_salt"] = cherrypy.response.cookie["login_salt"].value
|
||||
assert interface.check_login_cookie() is False
|
||||
+13
-3
@@ -109,16 +109,26 @@ class TestMisc:
|
||||
(None, None, None, (None, None, None)),
|
||||
("", "", "", (None, None, None)),
|
||||
("none", "-1", "default", (None, None, None)),
|
||||
("SomeCategory", "5", "SomeScript", ("SomeCategory", "5", "SomeScript")),
|
||||
("SomeCategory", "1", "SomeScript", ("SomeCategory", 1, "SomeScript")),
|
||||
("none", 0, "default", (None, 0, None)),
|
||||
("Movies", "", "default", ("Movies", None, None)),
|
||||
("", "10", "default", (None, "10", None)),
|
||||
("none", "15", "", (None, "15", None)),
|
||||
("none", 0, "Default", (None, 0, None)),
|
||||
("other", "-1", "Default", ("other", None, None)),
|
||||
("none", "None", "default", (None, None, None)),
|
||||
("some", "none", "script", ("some", None, "script")),
|
||||
("none", "NONE", "Default", (None, None, None)),
|
||||
# pp must be a PP_LOOKUP key or None
|
||||
("none", "2", "default", (None, 2, None)),
|
||||
("none", 3, "default", (None, 3, None)),
|
||||
# Out-of-range ints are invalid
|
||||
("", "10", "default", (None, None, None)),
|
||||
("none", "15", "", (None, None, None)),
|
||||
("none", 4, "default", (None, None, None)),
|
||||
# Non-numeric never passes as a string
|
||||
("none", "-c", "default", (None, 0, None)),
|
||||
("none", "echo pwned", "default", (None, 0, None)),
|
||||
("none", "2; rm -rf /", "default", (None, 0, None)),
|
||||
("none", "1.5", "default", (None, 0, None)),
|
||||
],
|
||||
)
|
||||
def test_cat_pp_script_sanitizer(self, cat, pp, script, expected):
|
||||
|
||||
+180
-2
@@ -51,8 +51,94 @@ class TestNewsUnpackFunctions:
|
||||
assert not newsunpack.is_sfv_file("tests/data/only_comments.sfv")
|
||||
assert not newsunpack.is_sfv_file("tests/data/random.bin")
|
||||
|
||||
def test_is_sevenfile(self):
|
||||
# False, because the command is not set
|
||||
def test_file_join_refuses_a_link_as_target(self, tmp_path):
|
||||
"""A dangling link left behind by another extractor is not a free name, so joining
|
||||
must not write the result through it"""
|
||||
base = str(tmp_path)
|
||||
download_path = os.path.join(base, "job")
|
||||
workdir_complete = os.path.join(base, "complete")
|
||||
os.mkdir(download_path)
|
||||
os.mkdir(workdir_complete)
|
||||
outside = os.path.join(base, "outside.bin")
|
||||
|
||||
joinables = []
|
||||
for num in (1, 2):
|
||||
joinable = os.path.join(download_path, "victim.%03d" % num)
|
||||
with open(joinable, "wb") as part:
|
||||
part.write(b"part%d" % num)
|
||||
joinables.append(joinable)
|
||||
# The link sits where the joined file is written, not next to the parts
|
||||
os.symlink(os.path.join("..", "outside.bin"), os.path.join(workdir_complete, "victim"))
|
||||
|
||||
nzo = mock.Mock()
|
||||
nzo.download_path = download_path
|
||||
nzo.final_name = "test"
|
||||
nzo.delete = False
|
||||
nzo.set_action_line = mock.Mock()
|
||||
nzo.set_unpack_info = mock.Mock()
|
||||
|
||||
failed, newfiles = newsunpack.file_join(nzo, workdir_complete, joinables)
|
||||
|
||||
assert not os.path.exists(outside), "written through the link to %s" % outside
|
||||
assert failed
|
||||
assert not newfiles
|
||||
|
||||
def test_file_join_refuses_a_link_in_a_parent(self, tmp_path):
|
||||
"""A linked directory on the way to the joined name redirects the append"""
|
||||
base = str(tmp_path)
|
||||
download_path = os.path.join(base, "job")
|
||||
workdir_complete = os.path.join(base, "complete")
|
||||
outside = os.path.join(base, "outside")
|
||||
os.makedirs(os.path.join(download_path, "sub"))
|
||||
os.makedirs(workdir_complete)
|
||||
os.makedirs(outside)
|
||||
os.symlink(outside, os.path.join(workdir_complete, "sub"))
|
||||
|
||||
joinables = []
|
||||
for num in (1, 2):
|
||||
joinable = os.path.join(download_path, "sub", "victim.%03d" % num)
|
||||
with open(joinable, "wb") as part:
|
||||
part.write(b"part%d" % num)
|
||||
joinables.append(joinable)
|
||||
|
||||
nzo = mock.Mock()
|
||||
nzo.download_path = download_path
|
||||
nzo.final_name = "test"
|
||||
nzo.delete = False
|
||||
nzo.set_action_line = mock.Mock()
|
||||
nzo.set_unpack_info = mock.Mock()
|
||||
|
||||
failed, newfiles = newsunpack.file_join(nzo, workdir_complete, joinables)
|
||||
|
||||
assert not os.listdir(outside), "joined through the link into %s" % outside
|
||||
assert failed
|
||||
assert not newfiles
|
||||
|
||||
def test_sfv_check_blocks_path_traversal(self, tmp_path):
|
||||
"""A traversing SFV filename must not move a file out of the job directory"""
|
||||
download_path = str(tmp_path)
|
||||
obfuscated_name = "6f1ed002ab5595859014ebf0951522d9"
|
||||
obfuscated_path = os.path.join(download_path, obfuscated_name)
|
||||
with open(obfuscated_path, "wb") as test_file:
|
||||
test_file.write(b"payload")
|
||||
|
||||
# SFV entry with matching crc32 but a traversing target name
|
||||
sfv_path = os.path.join(download_path, "check.sfv")
|
||||
with open(sfv_path, "w") as sfv_file:
|
||||
sfv_file.write("../escaped.bin deadbeef\n")
|
||||
|
||||
nzf = mock.Mock(filename=obfuscated_name, filepath=obfuscated_path, crc32=0xDEADBEEF)
|
||||
nzo = mock.Mock(download_path=download_path, finished_files=[nzf])
|
||||
|
||||
assert newsunpack.sfv_check([sfv_path], nzo) is False
|
||||
assert not os.path.exists(os.path.join(download_path, os.pardir, "escaped.bin"))
|
||||
assert os.path.exists(obfuscated_path)
|
||||
|
||||
def test_is_sevenfile(self, monkeypatch):
|
||||
# False, because the command is not set. Force it explicitly: SEVENZIP_COMMAND
|
||||
# is a module global that another test in this class may have populated via
|
||||
# find_programs(), and under pytest-xdist tests share no ordering guarantee.
|
||||
monkeypatch.setattr(newsunpack, "SEVENZIP_COMMAND", None)
|
||||
assert not newsunpack.SEVENZIP_COMMAND
|
||||
assert not newsunpack.is_sevenfile("tests/data/test_7zip/testfile.7z")
|
||||
|
||||
@@ -65,6 +151,7 @@ class TestNewsUnpackFunctions:
|
||||
assert newsunpack.is_sevenfile("tests/data/test_7zip/testfile.7z")
|
||||
|
||||
def test_sevenzip(self):
|
||||
newsunpack.find_programs(".")
|
||||
testzip = newsunpack.SevenZip("tests/data/test_7zip/testfile.7z")
|
||||
assert testzip.namelist() == ["My_Test_Download.bin"]
|
||||
# Basic check that we can get data from the 7zip
|
||||
@@ -844,6 +931,97 @@ class TestTarUnpack:
|
||||
assert error_code == 1, "TAR extraction should fail"
|
||||
assert not extracted_files
|
||||
|
||||
def test_link_members_skipped_tar_unpack(self, tmp_path):
|
||||
"""A download never needs links, and even one that stays inside the folder is enough
|
||||
to redirect a later rename out of it, so they are dropped instead of extracted"""
|
||||
tar_path = tmp_path / "links.tar"
|
||||
|
||||
with tarfile.open(tar_path, "w") as tar:
|
||||
info = tarfile.TarInfo("file.txt")
|
||||
info.size = 4
|
||||
tar.addfile(info, io.BytesIO(b"test"))
|
||||
# tarfile.data_filter() allows this one: the target stays inside the folder
|
||||
pivot = tarfile.TarInfo("pivot")
|
||||
pivot.type = tarfile.SYMTYPE
|
||||
pivot.linkname = "."
|
||||
tar.addfile(pivot)
|
||||
hardlink = tarfile.TarInfo("hardlink.txt")
|
||||
hardlink.type = tarfile.LNKTYPE
|
||||
hardlink.linkname = "file.txt"
|
||||
tar.addfile(hardlink)
|
||||
|
||||
tar_files = ["links.tar"]
|
||||
expected_files = {"file.txt"}
|
||||
|
||||
error_code, extracted_files, complete_contents, download_contents, _nzo, temp_complete_dir = (
|
||||
self._run_tar_unpack(str(tmp_path), tar_files)
|
||||
)
|
||||
|
||||
self._assert_successful_extraction(
|
||||
error_code,
|
||||
extracted_files,
|
||||
complete_contents,
|
||||
download_contents,
|
||||
temp_complete_dir,
|
||||
expected_files,
|
||||
should_delete_original=True,
|
||||
original_files=tar_files,
|
||||
)
|
||||
|
||||
dropped = {"pivot", "hardlink.txt"}
|
||||
assert not [f for f in extracted_files if os.path.basename(f) in dropped]
|
||||
assert not [f for f in complete_contents if os.path.basename(f) in dropped]
|
||||
|
||||
def test_pre_existing_link_in_destination(self, tmp_path):
|
||||
"""A link left in the folder by another unpacker cannot be used to write a member
|
||||
through it, because tarfile.data_filter() resolves the destination"""
|
||||
base = str(tmp_path)
|
||||
extraction_path = os.path.join(base, "dest")
|
||||
outside = os.path.join(base, "outside")
|
||||
os.mkdir(extraction_path)
|
||||
os.mkdir(outside)
|
||||
os.symlink(outside, os.path.join(extraction_path, "sub"))
|
||||
|
||||
tar_path = os.path.join(base, "prelink.tar")
|
||||
with tarfile.open(tar_path, "w") as tar:
|
||||
info = tarfile.TarInfo("sub/evil.txt")
|
||||
info.size = 4
|
||||
tar.addfile(info, io.BytesIO(b"evil"))
|
||||
|
||||
nzo = TestRarUnpack._create_test_nzo(extraction_path)
|
||||
error_code, extracted_files = newsunpack.tar_extract(nzo, tar_path, extraction_path, False)
|
||||
|
||||
assert error_code == 1, "TAR extraction should fail"
|
||||
assert not extracted_files
|
||||
assert not os.listdir(outside)
|
||||
|
||||
def test_dangling_link_not_used_as_unique_name(self, tmp_path):
|
||||
"""get_unique_filename() picks the name after data_filter() approved the original, so
|
||||
a dangling link is neither a free name nor a way out of the folder"""
|
||||
base = str(tmp_path)
|
||||
extraction_path = os.path.join(base, "dest")
|
||||
os.mkdir(extraction_path)
|
||||
outside = os.path.join(base, "outside.txt")
|
||||
|
||||
# Occupy the plain name, so a unique one has to be picked
|
||||
with open(os.path.join(extraction_path, "victim.txt"), "wb") as victim:
|
||||
victim.write(b"keep")
|
||||
# The name that gets picked next is a link that points out of the folder
|
||||
os.symlink(os.path.join("..", "outside.txt"), os.path.join(extraction_path, "victim.1.txt"))
|
||||
|
||||
tar_path = os.path.join(base, "unique.tar")
|
||||
with tarfile.open(tar_path, "w") as tar:
|
||||
info = tarfile.TarInfo("victim.txt")
|
||||
info.size = 4
|
||||
tar.addfile(info, io.BytesIO(b"evil"))
|
||||
|
||||
nzo = TestRarUnpack._create_test_nzo(extraction_path)
|
||||
newsunpack.tar_extract(nzo, tar_path, extraction_path, False)
|
||||
|
||||
assert not os.path.exists(outside), "written through the link to %s" % outside
|
||||
with open(os.path.join(extraction_path, "victim.txt"), "rb") as victim:
|
||||
assert victim.read() == b"keep", "the existing file was overwritten"
|
||||
|
||||
def test_owner_permissions_sanitized_tar_unpack(self, tmp_path):
|
||||
tar_path = tmp_path / "owner.tar"
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ class TestNzbQueue:
|
||||
|
||||
@pytest.mark.skipif(not sabnzbd.WINDOWS, reason="Legacy 3.0.0 queue fixture contains Windows-specific paths")
|
||||
def test_restore_legacy_queue_format_3_0_0(self, tmp_path, monkeypatch):
|
||||
fixture_path = Path(SAB_DATA_DIR) / "test_3_0_0_queue_format"
|
||||
fixture_path = Path(SAB_DATA_DIR) / "test_3_0_0_data_format"
|
||||
shutil.copytree(fixture_path, tmp_path, dirs_exist_ok=True)
|
||||
|
||||
nzbqueue = NzbQueue()
|
||||
|
||||
+171
-3
@@ -734,9 +734,15 @@ class TestRSS:
|
||||
assert job_after_clear.archived_at is not None
|
||||
assert job_after_clear.is_downloaded
|
||||
|
||||
# get_jobs should return all jobs for a feed
|
||||
# get_jobs should return all non-archived jobs for a feed
|
||||
jobs_from_get_jobs = list(repo.get_feed_jobs(feed=feed))
|
||||
assert {j.link for j in jobs_from_get_jobs} == set(links_by_feed[feed])
|
||||
assert {j.link for j in jobs_from_get_jobs} == {
|
||||
job_link for job_link in links_by_feed[feed] if job_link is not link
|
||||
}
|
||||
|
||||
# get_jobs archive should return only archived jobs for a feed
|
||||
jobs_from_get_jobs = list(repo.get_feed_jobs(feed=feed, archive=True))
|
||||
assert {j.link for j in jobs_from_get_jobs} == {link}
|
||||
|
||||
# is_duplicate should detect similar jobs in other feeds
|
||||
duplicate_candidate = ResolvedEntry(
|
||||
@@ -775,7 +781,7 @@ class TestRSS:
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
age = now - datetime.timedelta(weeks=52)
|
||||
old_seen_at = now - datetime.timedelta(days=4)
|
||||
old_seen_at = now - datetime.timedelta(days=8)
|
||||
new_seen_at = now - datetime.timedelta(days=1)
|
||||
|
||||
# Old good item that should be kept because it is part of the new_urls set
|
||||
@@ -1039,3 +1045,165 @@ class TestRSS:
|
||||
|
||||
# Shared link must only appear once
|
||||
assert links == {shared_link, a_only_link, b_only_link}
|
||||
|
||||
def test_purge_removed_feeds_only_drops_unconfigured_feeds(self, tmp_rss):
|
||||
"""Records should only be dropped for feeds that are no longer configured."""
|
||||
repo, _reader = tmp_rss
|
||||
configured_feed = "ConfiguredFeed"
|
||||
removed_feed = "RemovedFeed"
|
||||
|
||||
self.setup_rss(configured_feed, "http://example.test/rss.xml")
|
||||
|
||||
age = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(weeks=52)
|
||||
old_seen_at = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=30)
|
||||
for feed in (configured_feed, removed_feed):
|
||||
repo.upsert(
|
||||
ResolvedEntry(
|
||||
feed=feed,
|
||||
link=f"http://example.test/{feed}/job",
|
||||
title=f"{feed} job",
|
||||
infourl=None,
|
||||
size=10,
|
||||
age=age,
|
||||
seen_at=old_seen_at,
|
||||
season=1,
|
||||
episode=1,
|
||||
category=None,
|
||||
state=RSSState.GOOD,
|
||||
)
|
||||
)
|
||||
|
||||
repo.purge_removed_feeds()
|
||||
|
||||
assert set(repo.get_feeds()) == {configured_feed}
|
||||
|
||||
def test_process_feed_without_readout_keeps_stored_jobs(self, httpserver: HTTPServer, tmp_rss):
|
||||
"""Replaying stored jobs (readout=False) must not expire or purge anything."""
|
||||
repo, reader = tmp_rss
|
||||
feed_name = "NoReadoutFeed"
|
||||
feed_xml = """<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>NoReadout</title>
|
||||
<item>
|
||||
<title>New.Show.S01E01.720p</title>
|
||||
<link>http://example.test/no-readout/current</link>
|
||||
<guid>http://example.test/info/no-readout-current</guid>
|
||||
<category>tv</category>
|
||||
<pubDate>Wed, 01 Jan 2025 00:00:00 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
httpserver.expect_request("/rss_no_readout.xml").respond_with_data(feed_xml, content_type="application/rss+xml")
|
||||
self.setup_rss(feed_name, httpserver.url_for("/rss_no_readout.xml"))
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
old_url = "http://example.test/no-readout/expired"
|
||||
repo.upsert(
|
||||
ResolvedEntry(
|
||||
feed=feed_name,
|
||||
link=old_url,
|
||||
title="Old.Show.S01E01.720p",
|
||||
infourl=None,
|
||||
size=10,
|
||||
age=now - datetime.timedelta(weeks=52),
|
||||
seen_at=now - datetime.timedelta(days=8),
|
||||
season=1,
|
||||
episode=1,
|
||||
category=None,
|
||||
state=RSSState.EXPIRED,
|
||||
)
|
||||
)
|
||||
|
||||
assert reader.process_feed(feed_name, readout=False) == ""
|
||||
assert repo.find_job_by_url(feed_name, old_url) is not None
|
||||
|
||||
# A real readout does not find the link anymore, so it gets purged
|
||||
assert reader.process_feed(feed_name, readout=True) == ""
|
||||
assert repo.find_job_by_url(feed_name, old_url) is None
|
||||
|
||||
def test_process_feed_without_readout_updates_all_stored_jobs(self, httpserver: HTTPServer, tmp_rss):
|
||||
"""Re-evaluating stored jobs (readout=False) must persist the new state for every job.
|
||||
|
||||
The stored jobs are read through one database connection while each
|
||||
re-evaluated job is written through another connection to the same
|
||||
file. If the read cursor is still open while writing, SQLite reports
|
||||
"database is locked" and the write is silently dropped.
|
||||
"""
|
||||
repo, reader = tmp_rss
|
||||
feed_name = "ReEvaluateFeed"
|
||||
# Start without any filter: every stored job is BAD
|
||||
self.setup_rss(feed_name, httpserver.url_for("/unused.xml"))
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
urls = [f"http://example.test/re-evaluate/{n}" for n in range(5)]
|
||||
for n, url in enumerate(urls):
|
||||
repo.upsert(
|
||||
ResolvedEntry(
|
||||
feed=feed_name,
|
||||
link=url,
|
||||
title=f"Some.Show.S01E0{n}.720p",
|
||||
infourl=None,
|
||||
size=1000,
|
||||
age=now - datetime.timedelta(hours=n + 1),
|
||||
seen_at=now,
|
||||
season=1,
|
||||
episode=n,
|
||||
category=None,
|
||||
state=RSSState.BAD,
|
||||
)
|
||||
)
|
||||
assert all(repo.find_job_by_url(feed_name, url).state is RSSState.BAD for url in urls)
|
||||
|
||||
# Add an accept-all filter and replay the stored jobs, like the filter editor does
|
||||
self.setup_rss(feed_name, httpserver.url_for("/unused.xml"), filters=[("", "", "", "A", "*", "", "1")])
|
||||
assert reader.process_feed(feed_name, readout=False) == ""
|
||||
|
||||
for url in urls:
|
||||
job = repo.find_job_by_url(feed_name, url)
|
||||
assert job is not None
|
||||
assert job.state is RSSState.GOOD, f"{url} was not updated"
|
||||
|
||||
def test_downloaded_item_still_in_feed_is_not_redownloaded(self, httpserver: HTTPServer, tmp_rss, mocker):
|
||||
"""An item that stays in the feed must survive the retention period and not be grabbed twice."""
|
||||
repo, reader = tmp_rss
|
||||
feed_name = "LongLivedFeed"
|
||||
link = "http://example.test/long-lived"
|
||||
feed_xml = f"""<?xml version="1.0" encoding="utf-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>LongLived</title>
|
||||
<item>
|
||||
<title>Show.S01E01.720p</title>
|
||||
<link>{link}</link>
|
||||
<guid>http://example.test/info/long-lived</guid>
|
||||
<category>tv</category>
|
||||
<pubDate>Wed, 01 Jan 2025 00:00:00 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
httpserver.expect_request("/rss_long_lived.xml").respond_with_data(feed_xml, content_type="application/rss+xml")
|
||||
self.setup_rss(feed_name, httpserver.url_for("/rss_long_lived.xml"))
|
||||
|
||||
add_url = mocker.patch("sabnzbd.urlgrabber.add_url")
|
||||
assert reader.process_feed(feed_name, download=True, force=True) == ""
|
||||
assert add_url.call_count == 1
|
||||
assert repo.find_job_by_url(feed_name, link).state is RSSState.DOWNLOADED
|
||||
|
||||
# Pretend the item was downloaded longer ago than the retention period, while
|
||||
# it is still being served by the feed
|
||||
stale = int((datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=30)).timestamp())
|
||||
repo.db.execute("UPDATE rss SET seen_at = ? WHERE feed = ?", (stale, feed_name))
|
||||
|
||||
# Still being listed should refresh seen_at instead of purging the job
|
||||
assert reader.process_feed(feed_name, download=True) == ""
|
||||
job = repo.find_job_by_url(feed_name, link)
|
||||
assert job is not None
|
||||
assert job.state is RSSState.DOWNLOADED
|
||||
assert job.seen_at.timestamp() > stale
|
||||
|
||||
# And it must not be picked up as a new job on the next scans
|
||||
assert reader.process_feed(feed_name, download=True) == ""
|
||||
assert add_url.call_count == 1
|
||||
Binary file not shown.
Reference in new issue
Block a user