quasarr 2.4.8__py3-none-any.whl → 2.4.9__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.
- quasarr/__init__.py +134 -70
- quasarr/api/__init__.py +40 -31
- quasarr/api/arr/__init__.py +116 -108
- quasarr/api/captcha/__init__.py +262 -137
- quasarr/api/config/__init__.py +76 -46
- quasarr/api/packages/__init__.py +138 -102
- quasarr/api/sponsors_helper/__init__.py +29 -16
- quasarr/api/statistics/__init__.py +19 -19
- quasarr/downloads/__init__.py +165 -72
- quasarr/downloads/linkcrypters/al.py +35 -18
- quasarr/downloads/linkcrypters/filecrypt.py +107 -52
- quasarr/downloads/linkcrypters/hide.py +5 -6
- quasarr/downloads/packages/__init__.py +342 -177
- quasarr/downloads/sources/al.py +191 -100
- quasarr/downloads/sources/by.py +31 -13
- quasarr/downloads/sources/dd.py +27 -14
- quasarr/downloads/sources/dj.py +1 -3
- quasarr/downloads/sources/dl.py +126 -71
- quasarr/downloads/sources/dt.py +11 -5
- quasarr/downloads/sources/dw.py +28 -14
- quasarr/downloads/sources/he.py +32 -24
- quasarr/downloads/sources/mb.py +19 -9
- quasarr/downloads/sources/nk.py +14 -10
- quasarr/downloads/sources/nx.py +8 -18
- quasarr/downloads/sources/sf.py +45 -20
- quasarr/downloads/sources/sj.py +1 -3
- quasarr/downloads/sources/sl.py +9 -5
- quasarr/downloads/sources/wd.py +32 -12
- quasarr/downloads/sources/wx.py +35 -21
- quasarr/providers/auth.py +42 -37
- quasarr/providers/cloudflare.py +28 -30
- quasarr/providers/hostname_issues.py +2 -1
- quasarr/providers/html_images.py +2 -2
- quasarr/providers/html_templates.py +22 -14
- quasarr/providers/imdb_metadata.py +149 -80
- quasarr/providers/jd_cache.py +131 -39
- quasarr/providers/log.py +1 -1
- quasarr/providers/myjd_api.py +260 -196
- quasarr/providers/notifications.py +53 -41
- quasarr/providers/obfuscated.py +9 -4
- quasarr/providers/sessions/al.py +71 -55
- quasarr/providers/sessions/dd.py +21 -14
- quasarr/providers/sessions/dl.py +30 -19
- quasarr/providers/sessions/nx.py +23 -14
- quasarr/providers/shared_state.py +292 -141
- quasarr/providers/statistics.py +75 -43
- quasarr/providers/utils.py +33 -27
- quasarr/providers/version.py +45 -14
- quasarr/providers/web_server.py +10 -5
- quasarr/search/__init__.py +30 -18
- quasarr/search/sources/al.py +124 -73
- quasarr/search/sources/by.py +110 -59
- quasarr/search/sources/dd.py +57 -35
- quasarr/search/sources/dj.py +69 -48
- quasarr/search/sources/dl.py +159 -100
- quasarr/search/sources/dt.py +110 -74
- quasarr/search/sources/dw.py +121 -61
- quasarr/search/sources/fx.py +108 -62
- quasarr/search/sources/he.py +78 -49
- quasarr/search/sources/mb.py +96 -48
- quasarr/search/sources/nk.py +80 -50
- quasarr/search/sources/nx.py +91 -62
- quasarr/search/sources/sf.py +171 -106
- quasarr/search/sources/sj.py +69 -48
- quasarr/search/sources/sl.py +115 -71
- quasarr/search/sources/wd.py +67 -44
- quasarr/search/sources/wx.py +188 -123
- quasarr/storage/config.py +65 -52
- quasarr/storage/setup.py +238 -140
- quasarr/storage/sqlite_database.py +10 -4
- {quasarr-2.4.8.dist-info → quasarr-2.4.9.dist-info}/METADATA +2 -2
- quasarr-2.4.9.dist-info/RECORD +81 -0
- quasarr-2.4.8.dist-info/RECORD +0 -81
- {quasarr-2.4.8.dist-info → quasarr-2.4.9.dist-info}/WHEEL +0 -0
- {quasarr-2.4.8.dist-info → quasarr-2.4.9.dist-info}/entry_points.txt +0 -0
- {quasarr-2.4.8.dist-info → quasarr-2.4.9.dist-info}/licenses/LICENSE +0 -0
quasarr/downloads/sources/wd.py
CHANGED
|
@@ -10,7 +10,7 @@ from bs4 import BeautifulSoup
|
|
|
10
10
|
|
|
11
11
|
from quasarr.providers.cloudflare import flaresolverr_get, is_cloudflare_challenge
|
|
12
12
|
from quasarr.providers.hostname_issues import mark_hostname_issue
|
|
13
|
-
from quasarr.providers.log import
|
|
13
|
+
from quasarr.providers.log import debug, info
|
|
14
14
|
from quasarr.providers.utils import is_flaresolverr_available
|
|
15
15
|
|
|
16
16
|
hostname = "wd"
|
|
@@ -33,10 +33,14 @@ def resolve_wd_redirect(url, user_agent):
|
|
|
33
33
|
debug(f"Redirected from {resp.url} to {r.url}")
|
|
34
34
|
return r.url
|
|
35
35
|
else:
|
|
36
|
-
info(
|
|
36
|
+
info(
|
|
37
|
+
f"WD blocked attempt to resolve {url}. Your IP may be banned. Try again later."
|
|
38
|
+
)
|
|
37
39
|
except Exception as e:
|
|
38
40
|
info(f"Error fetching redirected URL for {url}: {e}")
|
|
39
|
-
mark_hostname_issue(
|
|
41
|
+
mark_hostname_issue(
|
|
42
|
+
hostname, "download", str(e) if "e" in dir() else "Download error"
|
|
43
|
+
)
|
|
40
44
|
return None
|
|
41
45
|
|
|
42
46
|
|
|
@@ -54,16 +58,24 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
|
|
|
54
58
|
r = requests.get(url)
|
|
55
59
|
if r.status_code >= 400 or is_cloudflare_challenge(r.text):
|
|
56
60
|
if is_flaresolverr_available(shared_state):
|
|
57
|
-
info(
|
|
61
|
+
info(
|
|
62
|
+
"WD is protected by Cloudflare. Using FlareSolverr to bypass protection."
|
|
63
|
+
)
|
|
58
64
|
r = flaresolverr_get(shared_state, url)
|
|
59
65
|
else:
|
|
60
|
-
info(
|
|
61
|
-
|
|
62
|
-
|
|
66
|
+
info(
|
|
67
|
+
"WD is protected by Cloudflare but FlareSolverr is not configured. "
|
|
68
|
+
"Please configure FlareSolverr in the web UI to access this site."
|
|
69
|
+
)
|
|
70
|
+
mark_hostname_issue(
|
|
71
|
+
hostname, "download", "FlareSolverr required but missing."
|
|
72
|
+
)
|
|
63
73
|
return {"links": [], "imdb_id": None}
|
|
64
74
|
|
|
65
75
|
if r.status_code >= 400:
|
|
66
|
-
mark_hostname_issue(
|
|
76
|
+
mark_hostname_issue(
|
|
77
|
+
hostname, "download", f"Download error: {str(r.status_code)}"
|
|
78
|
+
)
|
|
67
79
|
|
|
68
80
|
soup = BeautifulSoup(r.text, "html.parser")
|
|
69
81
|
|
|
@@ -83,7 +95,9 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
|
|
|
83
95
|
string=re.compile(r"^\s*Downloads\s*$", re.IGNORECASE),
|
|
84
96
|
)
|
|
85
97
|
if not header:
|
|
86
|
-
info(
|
|
98
|
+
info(
|
|
99
|
+
f"WD Downloads section not found. Grabbing download links for {title} not possible!"
|
|
100
|
+
)
|
|
87
101
|
return {"links": [], "imdb_id": None}
|
|
88
102
|
|
|
89
103
|
card = header.find_parent("div", class_="card")
|
|
@@ -96,7 +110,9 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
|
|
|
96
110
|
info(f"WD access failed: {e}")
|
|
97
111
|
return {"links": [], "imdb_id": None}
|
|
98
112
|
except Exception:
|
|
99
|
-
info(
|
|
113
|
+
info(
|
|
114
|
+
f"WD site has been updated. Grabbing download links for {title} not possible!"
|
|
115
|
+
)
|
|
100
116
|
return {"links": [], "imdb_id": None}
|
|
101
117
|
|
|
102
118
|
results = []
|
|
@@ -122,12 +138,16 @@ def get_wd_download_links(shared_state, url, mirror, title, password):
|
|
|
122
138
|
break
|
|
123
139
|
|
|
124
140
|
if mirror and mirror.lower() not in hoster.lower():
|
|
125
|
-
debug(
|
|
141
|
+
debug(
|
|
142
|
+
f'Skipping link from "{hoster}" (not the desired mirror "{mirror}")!'
|
|
143
|
+
)
|
|
126
144
|
continue
|
|
127
145
|
|
|
128
146
|
results.append([resolved, hoster])
|
|
129
147
|
except Exception:
|
|
130
|
-
info(
|
|
148
|
+
info(
|
|
149
|
+
f"WD site has been updated. Parsing download links for {title} not possible!"
|
|
150
|
+
)
|
|
131
151
|
|
|
132
152
|
return {
|
|
133
153
|
"links": results,
|
quasarr/downloads/sources/wx.py
CHANGED
|
@@ -7,7 +7,7 @@ import re
|
|
|
7
7
|
import requests
|
|
8
8
|
|
|
9
9
|
from quasarr.providers.hostname_issues import mark_hostname_issue
|
|
10
|
-
from quasarr.providers.log import
|
|
10
|
+
from quasarr.providers.log import debug, info
|
|
11
11
|
from quasarr.providers.utils import check_links_online_status
|
|
12
12
|
|
|
13
13
|
hostname = "wx"
|
|
@@ -25,8 +25,8 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
|
|
|
25
25
|
host = shared_state.values["config"]("Hostnames").get(hostname)
|
|
26
26
|
|
|
27
27
|
headers = {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
"User-Agent": shared_state.values["user_agent"],
|
|
29
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
try:
|
|
@@ -37,17 +37,17 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
|
|
|
37
37
|
r.raise_for_status()
|
|
38
38
|
|
|
39
39
|
# Extract slug from URL
|
|
40
|
-
slug_match = re.search(r
|
|
40
|
+
slug_match = re.search(r"/detail/([^/?]+)", url)
|
|
41
41
|
if not slug_match:
|
|
42
42
|
info(f"{hostname.upper()}: Could not extract slug from URL: {url}")
|
|
43
43
|
return {"links": []}
|
|
44
44
|
|
|
45
|
-
api_url = f
|
|
45
|
+
api_url = f"https://api.{host}/start/d/{slug_match.group(1)}"
|
|
46
46
|
|
|
47
47
|
# Update headers for API request
|
|
48
48
|
api_headers = {
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
"User-Agent": shared_state.values["user_agent"],
|
|
50
|
+
"Accept": "application/json",
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
debug(f"{hostname.upper()}: Fetching API data from: {api_url}")
|
|
@@ -57,28 +57,30 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
|
|
|
57
57
|
data = api_r.json()
|
|
58
58
|
|
|
59
59
|
# Navigate to releases in the API response
|
|
60
|
-
if
|
|
60
|
+
if "item" not in data or "releases" not in data["item"]:
|
|
61
61
|
info(f"{hostname.upper()}: No releases found in API response")
|
|
62
62
|
return {"links": []}
|
|
63
63
|
|
|
64
|
-
releases = data[
|
|
64
|
+
releases = data["item"]["releases"]
|
|
65
65
|
|
|
66
66
|
# Find ALL releases matching the title (these are different mirrors: M1, M2, M3...)
|
|
67
|
-
matching_releases = [r for r in releases if r.get(
|
|
67
|
+
matching_releases = [r for r in releases if r.get("fulltitle") == title]
|
|
68
68
|
|
|
69
69
|
if not matching_releases:
|
|
70
70
|
info(f"{hostname.upper()}: No release found matching title: {title}")
|
|
71
71
|
return {"links": []}
|
|
72
72
|
|
|
73
|
-
debug(
|
|
73
|
+
debug(
|
|
74
|
+
f"{hostname.upper()}: Found {len(matching_releases)} mirror(s) for: {title}"
|
|
75
|
+
)
|
|
74
76
|
|
|
75
77
|
# Evaluate each mirror and find the best one
|
|
76
78
|
# Track: (online_count, is_hide, online_links)
|
|
77
79
|
best_mirror = None # (online_count, is_hide, online_links)
|
|
78
80
|
|
|
79
81
|
for idx, release in enumerate(matching_releases):
|
|
80
|
-
crypted_links = release.get(
|
|
81
|
-
check_urls = release.get(
|
|
82
|
+
crypted_links = release.get("crypted_links", {})
|
|
83
|
+
check_urls = release.get("options", {}).get("check", {})
|
|
82
84
|
|
|
83
85
|
if not crypted_links:
|
|
84
86
|
continue
|
|
@@ -89,9 +91,9 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
|
|
|
89
91
|
|
|
90
92
|
for hoster, container_url in crypted_links.items():
|
|
91
93
|
state_url = check_urls.get(hoster)
|
|
92
|
-
if re.search(r
|
|
94
|
+
if re.search(r"hide\.", container_url, re.IGNORECASE):
|
|
93
95
|
hide_links.append([container_url, hoster, state_url])
|
|
94
|
-
elif re.search(r
|
|
96
|
+
elif re.search(r"filecrypt\.", container_url, re.IGNORECASE):
|
|
95
97
|
other_links.append([container_url, hoster, state_url])
|
|
96
98
|
# Skip other crypters we don't support
|
|
97
99
|
|
|
@@ -103,12 +105,15 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
|
|
|
103
105
|
hide_total = len(hide_links)
|
|
104
106
|
hide_online = len(online_hide)
|
|
105
107
|
|
|
106
|
-
debug(
|
|
108
|
+
debug(
|
|
109
|
+
f"{hostname.upper()}: M{idx + 1} hide.cx: {hide_online}/{hide_total} online"
|
|
110
|
+
)
|
|
107
111
|
|
|
108
112
|
# If all hide.cx links are online, use this mirror immediately
|
|
109
113
|
if hide_online == hide_total and hide_online > 0:
|
|
110
114
|
debug(
|
|
111
|
-
f"{hostname.upper()}: M{idx + 1} is complete (all {hide_online} hide.cx links online), using this mirror"
|
|
115
|
+
f"{hostname.upper()}: M{idx + 1} is complete (all {hide_online} hide.cx links online), using this mirror"
|
|
116
|
+
)
|
|
112
117
|
return {"links": online_hide}
|
|
113
118
|
|
|
114
119
|
# Check other crypters (filecrypt, etc.) - no early return, always check all mirrors for hide.cx first
|
|
@@ -119,7 +124,9 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
|
|
|
119
124
|
other_total = len(other_links)
|
|
120
125
|
other_online = len(online_other)
|
|
121
126
|
|
|
122
|
-
debug(
|
|
127
|
+
debug(
|
|
128
|
+
f"{hostname.upper()}: M{idx + 1} other crypters: {other_online}/{other_total} online"
|
|
129
|
+
)
|
|
123
130
|
|
|
124
131
|
# Determine best option for this mirror (prefer hide.cx on ties)
|
|
125
132
|
mirror_links = None
|
|
@@ -144,7 +151,11 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
|
|
|
144
151
|
best_mirror = (mirror_count, mirror_is_hide, mirror_links)
|
|
145
152
|
elif mirror_count > best_mirror[0]:
|
|
146
153
|
best_mirror = (mirror_count, mirror_is_hide, mirror_links)
|
|
147
|
-
elif
|
|
154
|
+
elif (
|
|
155
|
+
mirror_count == best_mirror[0]
|
|
156
|
+
and mirror_is_hide
|
|
157
|
+
and not best_mirror[1]
|
|
158
|
+
):
|
|
148
159
|
# Same count but this is hide.cx and current best is not
|
|
149
160
|
best_mirror = (mirror_count, mirror_is_hide, mirror_links)
|
|
150
161
|
|
|
@@ -152,7 +163,8 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
|
|
|
152
163
|
if best_mirror and best_mirror[2]:
|
|
153
164
|
crypter_type = "hide.cx" if best_mirror[1] else "other crypter"
|
|
154
165
|
debug(
|
|
155
|
-
f"{hostname.upper()}: No complete mirror, using best partial with {best_mirror[0]} online {crypter_type} link(s)"
|
|
166
|
+
f"{hostname.upper()}: No complete mirror, using best partial with {best_mirror[0]} online {crypter_type} link(s)"
|
|
167
|
+
)
|
|
156
168
|
return {"links": best_mirror[2]}
|
|
157
169
|
|
|
158
170
|
info(f"{hostname.upper()}: No online links found for: {title}")
|
|
@@ -160,5 +172,7 @@ def get_wx_download_links(shared_state, url, mirror, title, password):
|
|
|
160
172
|
|
|
161
173
|
except Exception as e:
|
|
162
174
|
info(f"{hostname.upper()}: Error extracting download links from {url}: {e}")
|
|
163
|
-
mark_hostname_issue(
|
|
175
|
+
mark_hostname_issue(
|
|
176
|
+
hostname, "download", str(e) if "e" in dir() else "Download error"
|
|
177
|
+
)
|
|
164
178
|
return {"links": []}
|
quasarr/providers/auth.py
CHANGED
|
@@ -10,23 +10,23 @@ import os
|
|
|
10
10
|
import time
|
|
11
11
|
from functools import wraps
|
|
12
12
|
|
|
13
|
-
from bottle import
|
|
13
|
+
from bottle import abort, redirect, request, response
|
|
14
14
|
|
|
15
15
|
import quasarr.providers.html_images as images
|
|
16
16
|
from quasarr.providers.version import get_version
|
|
17
17
|
from quasarr.storage.config import Config
|
|
18
18
|
|
|
19
19
|
# Auth configuration from environment
|
|
20
|
-
AUTH_USER = os.environ.get(
|
|
21
|
-
AUTH_PASS = os.environ.get(
|
|
22
|
-
AUTH_TYPE = os.environ.get(
|
|
20
|
+
AUTH_USER = os.environ.get("USER", "")
|
|
21
|
+
AUTH_PASS = os.environ.get("PASS", "")
|
|
22
|
+
AUTH_TYPE = os.environ.get("AUTH", "").lower()
|
|
23
23
|
|
|
24
24
|
# Cookie settings
|
|
25
|
-
COOKIE_NAME =
|
|
25
|
+
COOKIE_NAME = "quasarr_session"
|
|
26
26
|
COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
|
|
27
27
|
|
|
28
28
|
# Stable secret derived from PASS (restart-safe)
|
|
29
|
-
_SECRET_KEY = hashlib.sha256(AUTH_PASS.encode(
|
|
29
|
+
_SECRET_KEY = hashlib.sha256(AUTH_PASS.encode("utf-8")).digest()
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
def is_auth_enabled():
|
|
@@ -36,15 +36,15 @@ def is_auth_enabled():
|
|
|
36
36
|
|
|
37
37
|
def is_form_auth():
|
|
38
38
|
"""Check if form-based auth is enabled."""
|
|
39
|
-
return AUTH_TYPE ==
|
|
39
|
+
return AUTH_TYPE == "form"
|
|
40
40
|
|
|
41
41
|
|
|
42
42
|
def _b64encode(data: bytes) -> str:
|
|
43
|
-
return base64.urlsafe_b64encode(data).decode(
|
|
43
|
+
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def _b64decode(data: str) -> bytes:
|
|
47
|
-
padding =
|
|
47
|
+
padding = "=" * (-len(data) % 4)
|
|
48
48
|
return base64.urlsafe_b64decode(data + padding)
|
|
49
49
|
|
|
50
50
|
|
|
@@ -57,7 +57,7 @@ def _mask_user(user: str) -> str:
|
|
|
57
57
|
One-way masked user identifier.
|
|
58
58
|
Stable across restarts, not reversible.
|
|
59
59
|
"""
|
|
60
|
-
return hashlib.sha256(f"user:{user}".encode(
|
|
60
|
+
return hashlib.sha256(f"user:{user}".encode("utf-8")).hexdigest()
|
|
61
61
|
|
|
62
62
|
|
|
63
63
|
def _create_session_cookie(user: str) -> str:
|
|
@@ -69,14 +69,14 @@ def _create_session_cookie(user: str) -> str:
|
|
|
69
69
|
"u": _mask_user(user),
|
|
70
70
|
"exp": int(time.time()) + COOKIE_MAX_AGE,
|
|
71
71
|
}
|
|
72
|
-
raw = json.dumps(payload, separators=(
|
|
72
|
+
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
73
73
|
sig = _sign(raw)
|
|
74
74
|
return f"{_b64encode(raw)}.{_b64encode(sig)}"
|
|
75
75
|
|
|
76
76
|
|
|
77
77
|
def _invalidate_cookie():
|
|
78
78
|
try:
|
|
79
|
-
response.delete_cookie(COOKIE_NAME, path=
|
|
79
|
+
response.delete_cookie(COOKIE_NAME, path="/")
|
|
80
80
|
except Exception:
|
|
81
81
|
pass
|
|
82
82
|
|
|
@@ -87,17 +87,17 @@ def _verify_session_cookie(value: str) -> bool:
|
|
|
87
87
|
On ANY failure → force logout (cookie deletion).
|
|
88
88
|
"""
|
|
89
89
|
try:
|
|
90
|
-
if not value or
|
|
90
|
+
if not value or "." not in value:
|
|
91
91
|
raise ValueError
|
|
92
92
|
|
|
93
|
-
raw_b64, sig_b64 = value.split(
|
|
93
|
+
raw_b64, sig_b64 = value.split(".", 1)
|
|
94
94
|
raw = _b64decode(raw_b64)
|
|
95
95
|
sig = _b64decode(sig_b64)
|
|
96
96
|
|
|
97
97
|
if not hmac.compare_digest(sig, _sign(raw)):
|
|
98
98
|
raise ValueError
|
|
99
99
|
|
|
100
|
-
payload = json.loads(raw.decode(
|
|
100
|
+
payload = json.loads(raw.decode("utf-8"))
|
|
101
101
|
|
|
102
102
|
if payload.get("u") != _mask_user(AUTH_USER):
|
|
103
103
|
raise ValueError
|
|
@@ -113,12 +113,12 @@ def _verify_session_cookie(value: str) -> bool:
|
|
|
113
113
|
|
|
114
114
|
def check_basic_auth():
|
|
115
115
|
"""Check HTTP Basic Auth header. Returns True if valid."""
|
|
116
|
-
auth = request.headers.get(
|
|
117
|
-
if not auth.startswith(
|
|
116
|
+
auth = request.headers.get("Authorization", "")
|
|
117
|
+
if not auth.startswith("Basic "):
|
|
118
118
|
return False
|
|
119
119
|
try:
|
|
120
|
-
decoded = base64.b64decode(auth[6:]).decode(
|
|
121
|
-
user, passwd = decoded.split(
|
|
120
|
+
decoded = base64.b64decode(auth[6:]).decode("utf-8")
|
|
121
|
+
user, passwd = decoded.split(":", 1)
|
|
122
122
|
return user == AUTH_USER and passwd == AUTH_PASS
|
|
123
123
|
except:
|
|
124
124
|
return False
|
|
@@ -133,14 +133,18 @@ def check_form_auth():
|
|
|
133
133
|
def require_basic_auth():
|
|
134
134
|
"""Send 401 response for Basic Auth."""
|
|
135
135
|
response.status = 401
|
|
136
|
-
response.set_header(
|
|
136
|
+
response.set_header("WWW-Authenticate", 'Basic realm="Quasarr"')
|
|
137
137
|
return "Authentication required"
|
|
138
138
|
|
|
139
139
|
|
|
140
140
|
def _render_login_page(error=None):
|
|
141
141
|
"""Render login form page using Quasarr styling."""
|
|
142
|
-
error_html =
|
|
143
|
-
|
|
142
|
+
error_html = (
|
|
143
|
+
f'<p style="color: #dc3545; margin-bottom: 1rem;"><b>{error}</b></p>'
|
|
144
|
+
if error
|
|
145
|
+
else ""
|
|
146
|
+
)
|
|
147
|
+
next_url = request.query.get("next", "/")
|
|
144
148
|
|
|
145
149
|
# Inline the centered HTML to avoid circular import
|
|
146
150
|
return f'''<html>
|
|
@@ -220,21 +224,21 @@ def _render_login_page(error=None):
|
|
|
220
224
|
|
|
221
225
|
def _handle_login_post():
|
|
222
226
|
"""Handle login form submission."""
|
|
223
|
-
username = request.forms.get(
|
|
224
|
-
password = request.forms.get(
|
|
225
|
-
next_url = request.forms.get(
|
|
227
|
+
username = request.forms.get("username", "")
|
|
228
|
+
password = request.forms.get("password", "")
|
|
229
|
+
next_url = request.forms.get("next", "/")
|
|
226
230
|
|
|
227
231
|
if username == AUTH_USER and password == AUTH_PASS:
|
|
228
232
|
cookie = _create_session_cookie(username)
|
|
229
|
-
secure_flag = request.url.startswith(
|
|
233
|
+
secure_flag = request.url.startswith("https://")
|
|
230
234
|
response.set_cookie(
|
|
231
235
|
COOKIE_NAME,
|
|
232
236
|
cookie,
|
|
233
237
|
max_age=COOKIE_MAX_AGE,
|
|
234
|
-
path=
|
|
238
|
+
path="/",
|
|
235
239
|
httponly=True,
|
|
236
240
|
secure=secure_flag,
|
|
237
|
-
samesite=
|
|
241
|
+
samesite="Lax",
|
|
238
242
|
)
|
|
239
243
|
redirect(next_url)
|
|
240
244
|
else:
|
|
@@ -244,7 +248,7 @@ def _handle_login_post():
|
|
|
244
248
|
|
|
245
249
|
def _handle_logout():
|
|
246
250
|
_invalidate_cookie()
|
|
247
|
-
redirect(
|
|
251
|
+
redirect("/login")
|
|
248
252
|
|
|
249
253
|
|
|
250
254
|
def show_logout_link():
|
|
@@ -258,17 +262,18 @@ def add_auth_routes(app):
|
|
|
258
262
|
return
|
|
259
263
|
|
|
260
264
|
if is_form_auth():
|
|
261
|
-
|
|
265
|
+
|
|
266
|
+
@app.get("/login")
|
|
262
267
|
def login_get():
|
|
263
268
|
if check_form_auth():
|
|
264
|
-
redirect(
|
|
269
|
+
redirect("/")
|
|
265
270
|
return _render_login_page()
|
|
266
271
|
|
|
267
|
-
@app.post(
|
|
272
|
+
@app.post("/login")
|
|
268
273
|
def login_post():
|
|
269
274
|
return _handle_login_post()
|
|
270
275
|
|
|
271
|
-
@app.get(
|
|
276
|
+
@app.get("/logout")
|
|
272
277
|
def logout():
|
|
273
278
|
return _handle_logout()
|
|
274
279
|
|
|
@@ -283,7 +288,7 @@ def add_auth_hook(app, whitelist_prefixes=[], whitelist_suffixes=[]):
|
|
|
283
288
|
if whitelist_prefixes is None:
|
|
284
289
|
whitelist_prefixes = []
|
|
285
290
|
|
|
286
|
-
@app.hook(
|
|
291
|
+
@app.hook("before_request")
|
|
287
292
|
def auth_hook():
|
|
288
293
|
if not is_auth_enabled():
|
|
289
294
|
return
|
|
@@ -291,7 +296,7 @@ def add_auth_hook(app, whitelist_prefixes=[], whitelist_suffixes=[]):
|
|
|
291
296
|
path = request.path
|
|
292
297
|
|
|
293
298
|
# Always allow login/logout
|
|
294
|
-
if path in [
|
|
299
|
+
if path in ["/login", "/logout"]:
|
|
295
300
|
return
|
|
296
301
|
|
|
297
302
|
# Check whitelist prefixes
|
|
@@ -308,7 +313,7 @@ def add_auth_hook(app, whitelist_prefixes=[], whitelist_suffixes=[]):
|
|
|
308
313
|
if is_form_auth():
|
|
309
314
|
if not check_form_auth():
|
|
310
315
|
_invalidate_cookie()
|
|
311
|
-
redirect(f
|
|
316
|
+
redirect(f"/login?next={path}")
|
|
312
317
|
else:
|
|
313
318
|
if not check_basic_auth():
|
|
314
319
|
return require_basic_auth()
|
|
@@ -317,7 +322,7 @@ def add_auth_hook(app, whitelist_prefixes=[], whitelist_suffixes=[]):
|
|
|
317
322
|
def require_api_key(func):
|
|
318
323
|
@wraps(func)
|
|
319
324
|
def decorated(*args, **kwargs):
|
|
320
|
-
api_key = Config(
|
|
325
|
+
api_key = Config("API").get("key")
|
|
321
326
|
if not request.query.apikey:
|
|
322
327
|
return abort(401, "Missing API Key")
|
|
323
328
|
if request.query.apikey != api_key:
|
quasarr/providers/cloudflare.py
CHANGED
|
@@ -36,17 +36,15 @@ def is_cloudflare_challenge(html: str) -> bool:
|
|
|
36
36
|
return False
|
|
37
37
|
|
|
38
38
|
|
|
39
|
-
def update_session_via_flaresolverr(
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
target_url: str,
|
|
43
|
-
timeout: int = 60):
|
|
39
|
+
def update_session_via_flaresolverr(
|
|
40
|
+
info, shared_state, sess, target_url: str, timeout: int = 60
|
|
41
|
+
):
|
|
44
42
|
# Check if FlareSolverr is available
|
|
45
43
|
if not is_flaresolverr_available(shared_state):
|
|
46
44
|
info("FlareSolverr is not configured. Cannot bypass Cloudflare protection.")
|
|
47
45
|
return False
|
|
48
46
|
|
|
49
|
-
flaresolverr_url = shared_state.values["config"](
|
|
47
|
+
flaresolverr_url = shared_state.values["config"]("FlareSolverr").get("url")
|
|
50
48
|
if not flaresolverr_url:
|
|
51
49
|
info("Cannot proceed without FlareSolverr. Please configure it in the web UI!")
|
|
52
50
|
return False
|
|
@@ -61,10 +59,7 @@ def update_session_via_flaresolverr(info,
|
|
|
61
59
|
fs_headers = {"Content-Type": "application/json"}
|
|
62
60
|
try:
|
|
63
61
|
resp = requests.post(
|
|
64
|
-
flaresolverr_url,
|
|
65
|
-
headers=fs_headers,
|
|
66
|
-
json=fs_payload,
|
|
67
|
-
timeout=timeout + 10
|
|
62
|
+
flaresolverr_url, headers=fs_headers, json=fs_payload, timeout=timeout + 10
|
|
68
63
|
)
|
|
69
64
|
resp.raise_for_status()
|
|
70
65
|
except requests.exceptions.RequestException as e:
|
|
@@ -75,14 +70,16 @@ def update_session_via_flaresolverr(info,
|
|
|
75
70
|
"json": None,
|
|
76
71
|
"text": "",
|
|
77
72
|
"cookies": [],
|
|
78
|
-
"error": f"FlareSolverr request failed: {e}"
|
|
73
|
+
"error": f"FlareSolverr request failed: {e}",
|
|
79
74
|
}
|
|
80
75
|
except Exception as e:
|
|
81
76
|
raise RuntimeError(f"Could not reach FlareSolverr: {e}")
|
|
82
77
|
|
|
83
78
|
fs_json = resp.json()
|
|
84
79
|
if fs_json.get("status") != "ok" or "solution" not in fs_json:
|
|
85
|
-
raise RuntimeError(
|
|
80
|
+
raise RuntimeError(
|
|
81
|
+
f"FlareSolverr did not return a valid solution: {fs_json.get('message', '<no message>')}"
|
|
82
|
+
)
|
|
86
83
|
|
|
87
84
|
solution = fs_json["solution"]
|
|
88
85
|
|
|
@@ -93,7 +90,7 @@ def update_session_via_flaresolverr(info,
|
|
|
93
90
|
ck.get("name"),
|
|
94
91
|
ck.get("value"),
|
|
95
92
|
domain=ck.get("domain"),
|
|
96
|
-
path=ck.get("path", "/")
|
|
93
|
+
path=ck.get("path", "/"),
|
|
97
94
|
)
|
|
98
95
|
return {"session": sess, "user_agent": solution.get("userAgent", None)}
|
|
99
96
|
|
|
@@ -113,12 +110,18 @@ def ensure_session_cf_bypassed(info, shared_state, session, url, headers):
|
|
|
113
110
|
if resp.status_code == 403 or is_cloudflare_challenge(resp.text):
|
|
114
111
|
# Check if FlareSolverr is available before attempting bypass
|
|
115
112
|
if not is_flaresolverr_available(shared_state):
|
|
116
|
-
info(
|
|
117
|
-
|
|
113
|
+
info(
|
|
114
|
+
"Cloudflare protection detected but FlareSolverr is not configured. "
|
|
115
|
+
"Please configure FlareSolverr in the web UI to access this site."
|
|
116
|
+
)
|
|
118
117
|
return None, None, None
|
|
119
118
|
|
|
120
|
-
info(
|
|
121
|
-
|
|
119
|
+
info(
|
|
120
|
+
"Encountered Cloudflare protection. Solving challenge with FlareSolverr..."
|
|
121
|
+
)
|
|
122
|
+
flaresolverr_result = update_session_via_flaresolverr(
|
|
123
|
+
info, shared_state, session, url
|
|
124
|
+
)
|
|
122
125
|
if not flaresolverr_result:
|
|
123
126
|
info("FlareSolverr did not return a result.")
|
|
124
127
|
return None, None, None
|
|
@@ -129,7 +132,7 @@ def ensure_session_cf_bypassed(info, shared_state, session, url, headers):
|
|
|
129
132
|
if user_agent and user_agent != shared_state.values.get("user_agent"):
|
|
130
133
|
info("Updating User-Agent from FlareSolverr solution: " + user_agent)
|
|
131
134
|
shared_state.update("user_agent", user_agent)
|
|
132
|
-
headers = {
|
|
135
|
+
headers = {"User-Agent": shared_state.values["user_agent"]}
|
|
133
136
|
|
|
134
137
|
# re-fetch using the new session/headers
|
|
135
138
|
try:
|
|
@@ -174,24 +177,22 @@ def flaresolverr_get(shared_state, url, timeout=60):
|
|
|
174
177
|
"""
|
|
175
178
|
# Check if FlareSolverr is available
|
|
176
179
|
if not is_flaresolverr_available(shared_state):
|
|
177
|
-
raise RuntimeError(
|
|
180
|
+
raise RuntimeError(
|
|
181
|
+
"FlareSolverr is not configured. Please configure it in the web UI."
|
|
182
|
+
)
|
|
178
183
|
|
|
179
|
-
flaresolverr_url = shared_state.values["config"](
|
|
184
|
+
flaresolverr_url = shared_state.values["config"]("FlareSolverr").get("url")
|
|
180
185
|
if not flaresolverr_url:
|
|
181
186
|
raise RuntimeError("FlareSolverr URL not configured in shared_state.")
|
|
182
187
|
|
|
183
|
-
payload = {
|
|
184
|
-
"cmd": "request.get",
|
|
185
|
-
"url": url,
|
|
186
|
-
"maxTimeout": timeout * 1000
|
|
187
|
-
}
|
|
188
|
+
payload = {"cmd": "request.get", "url": url, "maxTimeout": timeout * 1000}
|
|
188
189
|
|
|
189
190
|
try:
|
|
190
191
|
resp = requests.post(
|
|
191
192
|
flaresolverr_url,
|
|
192
193
|
json=payload,
|
|
193
194
|
headers={"Content-Type": "application/json"},
|
|
194
|
-
timeout=timeout + 10
|
|
195
|
+
timeout=timeout + 10,
|
|
195
196
|
)
|
|
196
197
|
resp.raise_for_status()
|
|
197
198
|
except Exception as e:
|
|
@@ -216,8 +217,5 @@ def flaresolverr_get(shared_state, url, timeout=60):
|
|
|
216
217
|
shared_state.update("user_agent", user_agent)
|
|
217
218
|
|
|
218
219
|
return FlareSolverrResponse(
|
|
219
|
-
url=url,
|
|
220
|
-
status_code=status_code,
|
|
221
|
-
headers=fs_headers,
|
|
222
|
-
text=html
|
|
220
|
+
url=url, status_code=status_code, headers=fs_headers, text=html
|
|
223
221
|
)
|
|
@@ -13,6 +13,7 @@ from datetime import datetime
|
|
|
13
13
|
def _get_db(table_name):
|
|
14
14
|
"""Lazy import to avoid circular dependency."""
|
|
15
15
|
from quasarr.storage.sqlite_database import DataBase
|
|
16
|
+
|
|
16
17
|
return DataBase(table_name)
|
|
17
18
|
|
|
18
19
|
|
|
@@ -23,7 +24,7 @@ def mark_hostname_issue(shorthand, operation, error_message):
|
|
|
23
24
|
issue_data = {
|
|
24
25
|
"operation": operation,
|
|
25
26
|
"error": str(error_message)[:500],
|
|
26
|
-
"timestamp": datetime.now().isoformat()
|
|
27
|
+
"timestamp": datetime.now().isoformat(),
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
db.update_store(shorthand, json.dumps(issue_data))
|