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.

@@ -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 _sign_cookie(user, expiry):
37
- """Create HMAC signature for cookie."""
38
- msg = f"{user}:{expiry}".encode()
39
- return hmac.new(AUTH_PASS.encode(), msg, hashlib.sha256).hexdigest()
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
- def _create_session_cookie(user):
43
- """Create a signed session cookie value."""
44
- expiry = int(time.time()) + COOKIE_MAX_AGE
45
- signature = _sign_cookie(user, expiry)
46
- return f"{user}:{expiry}:{signature}"
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 _verify_session_cookie(cookie_value):
50
- """Verify a session cookie. Returns True if valid."""
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
- parts = cookie_value.split(':')
53
- if len(parts) != 3:
54
- return False
55
- user, expiry, signature = parts
56
- expiry = int(expiry)
57
- if time.time() > expiry:
58
- return False
59
- expected_sig = _sign_cookie(user, expiry)
60
- return hmac.compare_digest(signature, expected_sig)
61
- except:
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
- cookie_value = _create_session_cookie(username)
180
- response.set_cookie(COOKIE_NAME, cookie_value, max_age=COOKIE_MAX_AGE, path='/', httponly=True)
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
- return _render_login_page(error="Invalid username or password")
241
+ _invalidate_cookie()
242
+ return _render_login_page("Invalid username or password")
184
243
 
185
244
 
186
245
  def _handle_logout():
187
- """Handle logout - clear cookie and redirect to login."""
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: 20px;
99
+ border-radius: 0.5rem;
96
100
  font-size: 0.9rem;
97
101
  font-weight: 500;
98
102
  margin: 8px 0;