quasarr 2.1.0__py3-none-any.whl → 2.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of quasarr might be problematic. Click here for more details.
- quasarr/api/__init__.py +1 -1
- quasarr/api/arr/__init__.py +1 -15
- quasarr/api/packages/__init__.py +283 -233
- quasarr/api/sponsors_helper/__init__.py +13 -5
- quasarr/downloads/__init__.py +23 -0
- quasarr/providers/auth.py +100 -28
- quasarr/providers/html_templates.py +5 -1
- quasarr/providers/obfuscated.py +10 -10
- quasarr/providers/version.py +1 -1
- {quasarr-2.1.0.dist-info → quasarr-2.1.2.dist-info}/METADATA +38 -22
- {quasarr-2.1.0.dist-info → quasarr-2.1.2.dist-info}/RECORD +15 -15
- {quasarr-2.1.0.dist-info → quasarr-2.1.2.dist-info}/WHEEL +0 -0
- {quasarr-2.1.0.dist-info → quasarr-2.1.2.dist-info}/entry_points.txt +0 -0
- {quasarr-2.1.0.dist-info → quasarr-2.1.2.dist-info}/licenses/LICENSE +0 -0
- {quasarr-2.1.0.dist-info → quasarr-2.1.2.dist-info}/top_level.txt +0 -0
quasarr/downloads/__init__.py
CHANGED
|
@@ -7,6 +7,7 @@ import json
|
|
|
7
7
|
import re
|
|
8
8
|
|
|
9
9
|
from quasarr.downloads.linkcrypters.hide import decrypt_links_if_hide
|
|
10
|
+
from quasarr.downloads.packages import get_packages
|
|
10
11
|
from quasarr.downloads.sources.al import get_al_download_links
|
|
11
12
|
from quasarr.downloads.sources.by import get_by_download_links
|
|
12
13
|
from quasarr.downloads.sources.dd import get_dd_download_links
|
|
@@ -295,6 +296,23 @@ def process_links(shared_state, source_result, title, password, package_id, imdb
|
|
|
295
296
|
# MAIN ENTRY POINT
|
|
296
297
|
# =============================================================================
|
|
297
298
|
|
|
299
|
+
def package_id_exists(shared_state, package_id):
|
|
300
|
+
# DB checks
|
|
301
|
+
if shared_state.get_db("protected").retrieve(package_id):
|
|
302
|
+
return True
|
|
303
|
+
if shared_state.get_db("failed").retrieve(package_id):
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
data = get_packages(shared_state) or {}
|
|
307
|
+
|
|
308
|
+
for section in ("queue", "history"):
|
|
309
|
+
for pkg in data.get(section, []) or []:
|
|
310
|
+
if pkg.get("nzo_id") == package_id:
|
|
311
|
+
return True
|
|
312
|
+
|
|
313
|
+
return False
|
|
314
|
+
|
|
315
|
+
|
|
298
316
|
def download(shared_state, request_from, title, url, mirror, size_mb, password, imdb_id=None, source_key=None):
|
|
299
317
|
"""
|
|
300
318
|
Main download entry point.
|
|
@@ -348,6 +366,11 @@ def download(shared_state, request_from, title, url, mirror, size_mb, password,
|
|
|
348
366
|
# Generate DETERMINISTIC package_id
|
|
349
367
|
package_id = generate_deterministic_package_id(title, final_source_key, client_type)
|
|
350
368
|
|
|
369
|
+
# Skip Download if package_id already exists
|
|
370
|
+
if package_id_exists(shared_state, package_id):
|
|
371
|
+
info(f"Package {package_id} already exists. Skipping download!")
|
|
372
|
+
return {"success": True, "package_id": package_id, "title": title}
|
|
373
|
+
|
|
351
374
|
if source_result is None:
|
|
352
375
|
info(f'Could not find matching source for "{title}" - "{url}"')
|
|
353
376
|
StatsHelper(shared_state).increment_failed_downloads()
|
quasarr/providers/auth.py
CHANGED
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
import base64
|
|
6
6
|
import hashlib
|
|
7
7
|
import hmac
|
|
8
|
+
import json
|
|
8
9
|
import os
|
|
9
10
|
import time
|
|
11
|
+
from functools import wraps
|
|
10
12
|
|
|
11
|
-
from bottle import request, response, redirect
|
|
13
|
+
from bottle import request, response, redirect, abort
|
|
12
14
|
|
|
13
15
|
import quasarr.providers.html_images as images
|
|
14
16
|
from quasarr.providers.version import get_version
|
|
17
|
+
from quasarr.storage.config import Config
|
|
15
18
|
|
|
16
19
|
# Auth configuration from environment
|
|
17
20
|
AUTH_USER = os.environ.get('USER', '')
|
|
@@ -22,6 +25,9 @@ AUTH_TYPE = os.environ.get('AUTH', '').lower()
|
|
|
22
25
|
COOKIE_NAME = 'quasarr_session'
|
|
23
26
|
COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
|
|
24
27
|
|
|
28
|
+
# Stable secret derived from PASS (restart-safe)
|
|
29
|
+
_SECRET_KEY = hashlib.sha256(AUTH_PASS.encode('utf-8')).digest()
|
|
30
|
+
|
|
25
31
|
|
|
26
32
|
def is_auth_enabled():
|
|
27
33
|
"""Check if authentication is enabled (both USER and PASS set)."""
|
|
@@ -33,32 +39,75 @@ def is_form_auth():
|
|
|
33
39
|
return AUTH_TYPE == 'form'
|
|
34
40
|
|
|
35
41
|
|
|
36
|
-
def
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
def _b64encode(data: bytes) -> str:
|
|
43
|
+
return base64.urlsafe_b64encode(data).decode('ascii').rstrip('=')
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _b64decode(data: str) -> bytes:
|
|
47
|
+
padding = '=' * (-len(data) % 4)
|
|
48
|
+
return base64.urlsafe_b64decode(data + padding)
|
|
49
|
+
|
|
40
50
|
|
|
51
|
+
def _sign(data: bytes) -> bytes:
|
|
52
|
+
return hmac.new(_SECRET_KEY, data, hashlib.sha256).digest()
|
|
41
53
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
54
|
+
|
|
55
|
+
def _mask_user(user: str) -> str:
|
|
56
|
+
"""
|
|
57
|
+
One-way masked user identifier.
|
|
58
|
+
Stable across restarts, not reversible.
|
|
59
|
+
"""
|
|
60
|
+
return hashlib.sha256(f"user:{user}".encode('utf-8')).hexdigest()
|
|
47
61
|
|
|
48
62
|
|
|
49
|
-
def
|
|
50
|
-
"""
|
|
63
|
+
def _create_session_cookie(user: str) -> str:
|
|
64
|
+
"""
|
|
65
|
+
Stateless, signed cookie.
|
|
66
|
+
Stores only masked user + expiry.
|
|
67
|
+
"""
|
|
68
|
+
payload = {
|
|
69
|
+
"u": _mask_user(user),
|
|
70
|
+
"exp": int(time.time()) + COOKIE_MAX_AGE,
|
|
71
|
+
}
|
|
72
|
+
raw = json.dumps(payload, separators=(',', ':')).encode('utf-8')
|
|
73
|
+
sig = _sign(raw)
|
|
74
|
+
return f"{_b64encode(raw)}.{_b64encode(sig)}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _invalidate_cookie():
|
|
51
78
|
try:
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
79
|
+
response.delete_cookie(COOKIE_NAME, path='/')
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _verify_session_cookie(value: str) -> bool:
|
|
85
|
+
"""
|
|
86
|
+
Verify signature, expiry, and masked user.
|
|
87
|
+
On ANY failure → force logout (cookie deletion).
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
if not value or '.' not in value:
|
|
91
|
+
raise ValueError
|
|
92
|
+
|
|
93
|
+
raw_b64, sig_b64 = value.split('.', 1)
|
|
94
|
+
raw = _b64decode(raw_b64)
|
|
95
|
+
sig = _b64decode(sig_b64)
|
|
96
|
+
|
|
97
|
+
if not hmac.compare_digest(sig, _sign(raw)):
|
|
98
|
+
raise ValueError
|
|
99
|
+
|
|
100
|
+
payload = json.loads(raw.decode('utf-8'))
|
|
101
|
+
|
|
102
|
+
if payload.get("u") != _mask_user(AUTH_USER):
|
|
103
|
+
raise ValueError
|
|
104
|
+
|
|
105
|
+
if int(time.time()) > int(payload.get("exp", 0)):
|
|
106
|
+
raise ValueError
|
|
107
|
+
|
|
108
|
+
return True
|
|
109
|
+
except Exception:
|
|
110
|
+
_invalidate_cookie()
|
|
62
111
|
return False
|
|
63
112
|
|
|
64
113
|
|
|
@@ -78,7 +127,7 @@ def check_basic_auth():
|
|
|
78
127
|
def check_form_auth():
|
|
79
128
|
"""Check session cookie. Returns True if valid."""
|
|
80
129
|
cookie = request.get_cookie(COOKIE_NAME)
|
|
81
|
-
return cookie and _verify_session_cookie(cookie)
|
|
130
|
+
return bool(cookie and _verify_session_cookie(cookie))
|
|
82
131
|
|
|
83
132
|
|
|
84
133
|
def require_basic_auth():
|
|
@@ -176,16 +225,25 @@ def _handle_login_post():
|
|
|
176
225
|
next_url = request.forms.get('next', '/')
|
|
177
226
|
|
|
178
227
|
if username == AUTH_USER and password == AUTH_PASS:
|
|
179
|
-
|
|
180
|
-
|
|
228
|
+
cookie = _create_session_cookie(username)
|
|
229
|
+
secure_flag = request.url.startswith('https://')
|
|
230
|
+
response.set_cookie(
|
|
231
|
+
COOKIE_NAME,
|
|
232
|
+
cookie,
|
|
233
|
+
max_age=COOKIE_MAX_AGE,
|
|
234
|
+
path='/',
|
|
235
|
+
httponly=True,
|
|
236
|
+
secure=secure_flag,
|
|
237
|
+
samesite='Lax'
|
|
238
|
+
)
|
|
181
239
|
redirect(next_url)
|
|
182
240
|
else:
|
|
183
|
-
|
|
241
|
+
_invalidate_cookie()
|
|
242
|
+
return _render_login_page("Invalid username or password")
|
|
184
243
|
|
|
185
244
|
|
|
186
245
|
def _handle_logout():
|
|
187
|
-
|
|
188
|
-
response.delete_cookie(COOKIE_NAME, path='/')
|
|
246
|
+
_invalidate_cookie()
|
|
189
247
|
redirect('/login')
|
|
190
248
|
|
|
191
249
|
|
|
@@ -244,7 +302,21 @@ def add_auth_hook(app, whitelist_prefixes=None):
|
|
|
244
302
|
# Check authentication
|
|
245
303
|
if is_form_auth():
|
|
246
304
|
if not check_form_auth():
|
|
305
|
+
_invalidate_cookie()
|
|
247
306
|
redirect(f'/login?next={path}')
|
|
248
307
|
else:
|
|
249
308
|
if not check_basic_auth():
|
|
250
309
|
return require_basic_auth()
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def require_api_key(func):
|
|
313
|
+
@wraps(func)
|
|
314
|
+
def decorated(*args, **kwargs):
|
|
315
|
+
api_key = Config('API').get('key')
|
|
316
|
+
if not request.query.apikey:
|
|
317
|
+
return abort(401, "Missing API key")
|
|
318
|
+
if request.query.apikey != api_key:
|
|
319
|
+
return abort(403, "Invalid API key")
|
|
320
|
+
return func(*args, **kwargs)
|
|
321
|
+
|
|
322
|
+
return decorated
|
|
@@ -86,13 +86,17 @@ def render_centered_html(inner_content, footer_content=""):
|
|
|
86
86
|
margin-top: 0;
|
|
87
87
|
color: var(--setup-border);
|
|
88
88
|
}
|
|
89
|
+
a.action-card,
|
|
90
|
+
a.action-card {
|
|
91
|
+
text-decoration: none !important;
|
|
92
|
+
}
|
|
89
93
|
/* Status pill styling */
|
|
90
94
|
.status-pill {
|
|
91
95
|
display: inline-flex;
|
|
92
96
|
align-items: center;
|
|
93
97
|
gap: 6px;
|
|
94
98
|
padding: 6px 12px;
|
|
95
|
-
border-radius:
|
|
99
|
+
border-radius: 0.5rem;
|
|
96
100
|
font-size: 0.9rem;
|
|
97
101
|
font-weight: 500;
|
|
98
102
|
margin: 8px 0;
|