redfetch 1.3.0__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.
- redfetch/__about__.py +24 -0
- redfetch/__init__.py +0 -0
- redfetch/api.py +134 -0
- redfetch/auth.py +573 -0
- redfetch/config.py +483 -0
- redfetch/config_firstrun.py +455 -0
- redfetch/desktop_shortcut.py +81 -0
- redfetch/detecteq.py +116 -0
- redfetch/download.py +312 -0
- redfetch/listener.py +216 -0
- redfetch/main.py +744 -0
- redfetch/meta.py +561 -0
- redfetch/navmesh.py +371 -0
- redfetch/net.py +109 -0
- redfetch/processes.py +118 -0
- redfetch/push.py +246 -0
- redfetch/redfetch.ico +0 -0
- redfetch/runtime_errors.py +96 -0
- redfetch/settings.toml +303 -0
- redfetch/special.py +81 -0
- redfetch/store.py +505 -0
- redfetch/sync.py +261 -0
- redfetch/sync_discovery.py +352 -0
- redfetch/sync_executor.py +164 -0
- redfetch/sync_planner.py +196 -0
- redfetch/sync_remote.py +182 -0
- redfetch/sync_types.py +348 -0
- redfetch/terminal_ui.py +2318 -0
- redfetch/terminal_ui.tcss +569 -0
- redfetch/unloadmq.py +148 -0
- redfetch/update_status.py +62 -0
- redfetch/utils.py +256 -0
- redfetch-1.3.0.dist-info/METADATA +257 -0
- redfetch-1.3.0.dist-info/RECORD +37 -0
- redfetch-1.3.0.dist-info/WHEEL +4 -0
- redfetch-1.3.0.dist-info/entry_points.txt +2 -0
- redfetch-1.3.0.dist-info/licenses/LICENSE +674 -0
redfetch/auth.py
ADDED
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""Can be used as a standalone script to authorize with RedGuides.
|
|
2
|
+
|
|
3
|
+
redfetch supports two auth modes:
|
|
4
|
+
- API key (via REDGUIDES_API_KEY env var)
|
|
5
|
+
- XenForo 2.3 native OAuth2
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# standard
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
import webbrowser
|
|
15
|
+
from datetime import datetime, timedelta
|
|
16
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
17
|
+
from typing import Optional
|
|
18
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
19
|
+
|
|
20
|
+
# third-party
|
|
21
|
+
import httpx
|
|
22
|
+
import keyring # for storing tokens (secrets only)
|
|
23
|
+
from diskcache import Cache
|
|
24
|
+
from keyring.errors import NoKeyringError
|
|
25
|
+
|
|
26
|
+
# Local
|
|
27
|
+
from redfetch import config
|
|
28
|
+
from redfetch import net
|
|
29
|
+
|
|
30
|
+
# Constants
|
|
31
|
+
KEYRING_SERVICE_NAME = "redfetch"
|
|
32
|
+
BASE_URL = net.BASE_URL
|
|
33
|
+
|
|
34
|
+
AUTHORIZATION_ENDPOINT = f"{BASE_URL}/oauth2/authorize"
|
|
35
|
+
TOKEN_ENDPOINT = f"{BASE_URL}/api/oauth2/token"
|
|
36
|
+
|
|
37
|
+
# Loopback redirect default (must match the OAuth client redirect URI exactly)
|
|
38
|
+
DEFAULT_REDIRECT_URI = "http://127.0.0.1:62897/"
|
|
39
|
+
DEFAULT_LOOPBACK_PORT = 62897
|
|
40
|
+
_REFRESH_CODE_VERIFIER = "refresh" # XF 2.3 requires a non-empty code_verifier even for refresh (public clients)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _get_setting(key: str, default=None):
|
|
44
|
+
"""Get a setting from env first, then initialized Dynaconf settings."""
|
|
45
|
+
env_key = f"REDFETCH_{key}"
|
|
46
|
+
env_val = os.environ.get(env_key)
|
|
47
|
+
if env_val not in (None, ""):
|
|
48
|
+
return env_val
|
|
49
|
+
|
|
50
|
+
if config.settings is not None:
|
|
51
|
+
val = config.settings.get(key, default)
|
|
52
|
+
return default if val in ("", None) else val
|
|
53
|
+
|
|
54
|
+
return default
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Disk cache for non-secret identity data (user_id, username, token_expiry)
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
_disk_cache = None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_disk_cache_instance():
|
|
65
|
+
"""Create a diskcache.Cache in the config directory."""
|
|
66
|
+
cache_dir = getattr(config, 'config_dir', None) or os.getenv('REDFETCH_CONFIG_DIR')
|
|
67
|
+
if not cache_dir:
|
|
68
|
+
cache_dir = os.getcwd()
|
|
69
|
+
return Cache(os.path.join(cache_dir, '.cache'))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ensure_cache():
|
|
73
|
+
global _disk_cache
|
|
74
|
+
if _disk_cache is None:
|
|
75
|
+
_disk_cache = _get_disk_cache_instance()
|
|
76
|
+
return _disk_cache
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_disk_cache():
|
|
80
|
+
"""Return the shared disk cache (for non-identity caching by other modules)."""
|
|
81
|
+
return _ensure_cache()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def set_user_id(user_id: str) -> None:
|
|
85
|
+
"""Store user_id in disk cache (non-sensitive public identifier)."""
|
|
86
|
+
_ensure_cache().set('user_id', str(user_id))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def get_user_id() -> Optional[str]:
|
|
90
|
+
"""Retrieve user_id from disk cache."""
|
|
91
|
+
return _ensure_cache().get('user_id')
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def set_username(username: str) -> None:
|
|
95
|
+
"""Store username in disk cache (non-sensitive public display name)."""
|
|
96
|
+
_ensure_cache().set('username', username)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_username_from_cache() -> Optional[str]:
|
|
100
|
+
"""Retrieve username from disk cache."""
|
|
101
|
+
return _ensure_cache().get('username')
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def set_token_expiry(expires_at: str) -> None:
|
|
105
|
+
"""Store OAuth token expiry timestamp in disk cache."""
|
|
106
|
+
_ensure_cache().set('expires_at', expires_at)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def get_token_expiry() -> Optional[str]:
|
|
110
|
+
"""Retrieve OAuth token expiry timestamp from disk cache."""
|
|
111
|
+
return _ensure_cache().get('expires_at')
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def clear_disk_cache():
|
|
115
|
+
"""Clear all cached non-secret data."""
|
|
116
|
+
global _disk_cache
|
|
117
|
+
cache = _ensure_cache()
|
|
118
|
+
try:
|
|
119
|
+
cache.clear()
|
|
120
|
+
finally:
|
|
121
|
+
try:
|
|
122
|
+
cache.close()
|
|
123
|
+
except Exception:
|
|
124
|
+
pass
|
|
125
|
+
_disk_cache = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
129
|
+
"""Capture XF OAuth2 redirect responses for loopback redirects."""
|
|
130
|
+
|
|
131
|
+
def log_message(self, format, *args): # noqa: A002 (shadowing built-in 'format')
|
|
132
|
+
# Silence noisy default logging.
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
def do_GET(self):
|
|
136
|
+
query = parse_qs(urlparse(self.path).query)
|
|
137
|
+
|
|
138
|
+
error = (query.get("error") or [None])[0]
|
|
139
|
+
error_description = (query.get("error_description") or [None])[0]
|
|
140
|
+
code = (query.get("code") or [None])[0]
|
|
141
|
+
state = (query.get("state") or [None])[0]
|
|
142
|
+
|
|
143
|
+
# Some browsers will request /favicon.ico or similar first; ignore those.
|
|
144
|
+
if not error and (not code or not state):
|
|
145
|
+
self.send_response(404)
|
|
146
|
+
self.send_header("Content-type", "text/plain; charset=utf-8")
|
|
147
|
+
self.end_headers()
|
|
148
|
+
self.wfile.write(b"Waiting for OAuth response...")
|
|
149
|
+
return
|
|
150
|
+
|
|
151
|
+
if error:
|
|
152
|
+
self.server.error = f"{error} {error_description or ''}".strip()
|
|
153
|
+
self.send_response(200)
|
|
154
|
+
self.send_header("Content-type", "text/plain; charset=utf-8")
|
|
155
|
+
self.end_headers()
|
|
156
|
+
self.wfile.write(b"Authorization failed. You can close this tab.")
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
self.server.code = code
|
|
160
|
+
self.server.state = state
|
|
161
|
+
self.send_response(200)
|
|
162
|
+
self.send_header("Content-type", "text/plain; charset=utf-8")
|
|
163
|
+
self.end_headers()
|
|
164
|
+
self.wfile.write(b"Authorization successful. You can close this tab.")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def first_authorization(client_id: str, client_secret: str | None, *, scope: str, redirect_uri: str) -> None:
|
|
168
|
+
"""Perform auth via browser and cache tokens.
|
|
169
|
+
|
|
170
|
+
Uses Authorization Code + PKCE (S256) as required by XF for public clients.
|
|
171
|
+
"""
|
|
172
|
+
# Step 1: Generate PKCE + state, then build the authorize URL
|
|
173
|
+
state = base64.urlsafe_b64encode(os.urandom(32)).decode("ascii").rstrip("=")
|
|
174
|
+
code_verifier = base64.urlsafe_b64encode(os.urandom(32)).decode("ascii").rstrip("=") # 43 chars base64url
|
|
175
|
+
code_challenge = (
|
|
176
|
+
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode("ascii")).digest())
|
|
177
|
+
.decode("ascii")
|
|
178
|
+
.rstrip("=")
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
params = {
|
|
182
|
+
"response_type": "code",
|
|
183
|
+
"client_id": client_id,
|
|
184
|
+
"redirect_uri": redirect_uri,
|
|
185
|
+
"scope": scope or "",
|
|
186
|
+
"state": state,
|
|
187
|
+
"code_challenge": code_challenge,
|
|
188
|
+
"code_challenge_method": "S256",
|
|
189
|
+
}
|
|
190
|
+
auth_url = f"{AUTHORIZATION_ENDPOINT}?{urlencode(params)}"
|
|
191
|
+
|
|
192
|
+
# Step 2: Open the authorize URL in the user's browser
|
|
193
|
+
try:
|
|
194
|
+
success = webbrowser.open(auth_url)
|
|
195
|
+
if success:
|
|
196
|
+
print("Please login and authorize the app in your web browser.")
|
|
197
|
+
else:
|
|
198
|
+
raise RuntimeError("Browser could not be opened.")
|
|
199
|
+
except Exception:
|
|
200
|
+
print("Unable to open the web browser automatically.")
|
|
201
|
+
print("Please open the following URL manually in your browser to authorize the app:")
|
|
202
|
+
print(auth_url)
|
|
203
|
+
|
|
204
|
+
# Step 3: Wait for the authorization code via the loopback redirect
|
|
205
|
+
authorization_code = run_server(expected_state=state, redirect_uri=redirect_uri)
|
|
206
|
+
|
|
207
|
+
# Step 4: Exchange the authorization code for tokens
|
|
208
|
+
payload = {
|
|
209
|
+
"client_id": client_id,
|
|
210
|
+
"grant_type": "authorization_code",
|
|
211
|
+
"redirect_uri": redirect_uri,
|
|
212
|
+
"code": authorization_code,
|
|
213
|
+
"code_verifier": code_verifier,
|
|
214
|
+
}
|
|
215
|
+
if client_secret:
|
|
216
|
+
payload["client_secret"] = client_secret
|
|
217
|
+
|
|
218
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
|
|
219
|
+
response = httpx.post(TOKEN_ENDPOINT, headers=headers, data=payload, timeout=10.0)
|
|
220
|
+
if not response.is_success:
|
|
221
|
+
details = response.text.strip()
|
|
222
|
+
if details:
|
|
223
|
+
raise RuntimeError(f"Failed to retrieve tokens.\n{details}")
|
|
224
|
+
raise RuntimeError("Failed to retrieve tokens.")
|
|
225
|
+
|
|
226
|
+
token_data = response.json()
|
|
227
|
+
if token_data.get("error"):
|
|
228
|
+
raise RuntimeError(
|
|
229
|
+
f"OAuth token error: {token_data.get('error')} {token_data.get('error_description', '')}".strip()
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Step 5: Cache tokens and basic user info
|
|
233
|
+
store_tokens_in_keyring(token_data)
|
|
234
|
+
print("Authorization successful and tokens cached.")
|
|
235
|
+
|
|
236
|
+
# Cache basic user info (best-effort; not required for API auth).
|
|
237
|
+
try:
|
|
238
|
+
_cache_user_info(token_data.get("access_token"))
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _cache_user_info(access_token: str | None) -> None:
|
|
244
|
+
"""Fetch /api/me and cache username/user_id (best-effort)."""
|
|
245
|
+
if not access_token:
|
|
246
|
+
return
|
|
247
|
+
headers = {"Authorization": f"Bearer {access_token}"}
|
|
248
|
+
resp = httpx.get(f"{BASE_URL}/api/me", headers=headers, timeout=10.0)
|
|
249
|
+
resp.raise_for_status()
|
|
250
|
+
data = resp.json()
|
|
251
|
+
me = data.get("me") or {}
|
|
252
|
+
if me.get("username"):
|
|
253
|
+
set_username(str(me["username"]))
|
|
254
|
+
if me.get("user_id"):
|
|
255
|
+
set_user_id(str(me["user_id"]))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def authorize():
|
|
259
|
+
# If using env var (mainly for CI), skip OAuth entirely
|
|
260
|
+
if os.environ.get('REDGUIDES_API_KEY'):
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
client_id = _get_setting("OAUTH_CLIENT_ID")
|
|
264
|
+
client_secret = _get_setting("OAUTH_CLIENT_SECRET", "") # optional (confidential clients only)
|
|
265
|
+
scope = _get_setting("OAUTH_SCOPE", "user:read resource:read resource:write attachment:write")
|
|
266
|
+
redirect_uri = _get_setting("OAUTH_REDIRECT_URI", DEFAULT_REDIRECT_URI)
|
|
267
|
+
|
|
268
|
+
if not client_id:
|
|
269
|
+
raise RuntimeError("OAuth client is not configured.")
|
|
270
|
+
|
|
271
|
+
data = get_cached_tokens()
|
|
272
|
+
|
|
273
|
+
# Fast path: valid access token already cached
|
|
274
|
+
if data.get("access_token") and token_is_valid():
|
|
275
|
+
return
|
|
276
|
+
|
|
277
|
+
# Try refresh if we have a refresh token
|
|
278
|
+
if data.get("refresh_token"):
|
|
279
|
+
print("Attempting to refresh access token...")
|
|
280
|
+
if refresh_token(client_id, client_secret, redirect_uri=redirect_uri):
|
|
281
|
+
print("Token refreshed successfully.")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
# Fall back to interactive authorization
|
|
285
|
+
print("Performing full authorization...")
|
|
286
|
+
first_authorization(client_id, client_secret, scope=scope, redirect_uri=redirect_uri)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _port_from_redirect_uri(redirect_uri: str) -> int:
|
|
290
|
+
try:
|
|
291
|
+
parsed = urlparse(redirect_uri)
|
|
292
|
+
if parsed.port:
|
|
293
|
+
return int(parsed.port)
|
|
294
|
+
except Exception:
|
|
295
|
+
pass
|
|
296
|
+
return DEFAULT_LOOPBACK_PORT
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def run_server(*, expected_state: str, redirect_uri: str, timeout_seconds: int = 300) -> str:
|
|
300
|
+
"""Start a loopback HTTP server and wait for XF's OAuth redirect."""
|
|
301
|
+
port = _port_from_redirect_uri(redirect_uri)
|
|
302
|
+
server_address = ("127.0.0.1", port)
|
|
303
|
+
httpd = HTTPServer(server_address, OAuthCallbackHandler)
|
|
304
|
+
httpd.timeout = 5 # allow periodic timeout checks
|
|
305
|
+
httpd.code = None
|
|
306
|
+
httpd.state = None
|
|
307
|
+
httpd.error = None
|
|
308
|
+
|
|
309
|
+
start = time.time()
|
|
310
|
+
while True:
|
|
311
|
+
if httpd.error:
|
|
312
|
+
raise RuntimeError(f"OAuth authorization error: {httpd.error}")
|
|
313
|
+
if httpd.code:
|
|
314
|
+
break
|
|
315
|
+
if time.time() - start > timeout_seconds:
|
|
316
|
+
raise TimeoutError("Timed out waiting for OAuth authorization response.")
|
|
317
|
+
httpd.handle_request()
|
|
318
|
+
|
|
319
|
+
if httpd.state != expected_state:
|
|
320
|
+
raise RuntimeError("Received OAuth response with invalid state.")
|
|
321
|
+
|
|
322
|
+
return httpd.code
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def store_tokens_in_keyring(data):
|
|
326
|
+
"""Store OAuth tokens securely in keyring; store non-secrets in disk cache."""
|
|
327
|
+
keyring.set_password(KEYRING_SERVICE_NAME, "access_token", data["access_token"])
|
|
328
|
+
keyring.set_password(KEYRING_SERVICE_NAME, "refresh_token", data["refresh_token"])
|
|
329
|
+
|
|
330
|
+
expires_at = datetime.now().timestamp() + int(data.get("expires_in", 0) or 0)
|
|
331
|
+
set_token_expiry(str(expires_at))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def refresh_token(client_id: str, client_secret: str | None, *, redirect_uri: str) -> bool:
|
|
335
|
+
refresh_token_value = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
|
|
336
|
+
if not refresh_token_value:
|
|
337
|
+
return False
|
|
338
|
+
|
|
339
|
+
payload = {
|
|
340
|
+
"client_id": client_id,
|
|
341
|
+
"grant_type": "refresh_token",
|
|
342
|
+
"redirect_uri": redirect_uri,
|
|
343
|
+
"refresh_token": refresh_token_value,
|
|
344
|
+
# XF 2.3 requires code_verifier for public clients even on refresh. A static non-empty value works.
|
|
345
|
+
"code_verifier": _REFRESH_CODE_VERIFIER,
|
|
346
|
+
}
|
|
347
|
+
if client_secret:
|
|
348
|
+
payload["client_secret"] = client_secret
|
|
349
|
+
|
|
350
|
+
headers = {"Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json"}
|
|
351
|
+
response = httpx.post(TOKEN_ENDPOINT, headers=headers, data=payload, timeout=10.0)
|
|
352
|
+
if not response.is_success:
|
|
353
|
+
print("Failed to refresh access token.")
|
|
354
|
+
print(response.text)
|
|
355
|
+
return False
|
|
356
|
+
|
|
357
|
+
new_token_data = response.json()
|
|
358
|
+
if new_token_data.get("error"):
|
|
359
|
+
print(f"OAuth token error: {new_token_data.get('error')} {new_token_data.get('error_description', '')}".strip())
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
store_tokens_in_keyring(new_token_data)
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def token_is_valid():
|
|
367
|
+
"""Check if the access token is still valid."""
|
|
368
|
+
expires_at_str = get_token_expiry()
|
|
369
|
+
if expires_at_str:
|
|
370
|
+
expires_at = datetime.fromtimestamp(float(expires_at_str))
|
|
371
|
+
now = datetime.now()
|
|
372
|
+
is_valid = now < expires_at - timedelta(minutes=5) # Buffer of 5 minutes
|
|
373
|
+
return is_valid
|
|
374
|
+
else:
|
|
375
|
+
return False
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def get_cached_tokens():
|
|
379
|
+
"""Retrieve cached OAuth tokens from keyring and non-secrets from disk cache."""
|
|
380
|
+
data = {}
|
|
381
|
+
data["access_token"] = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
|
|
382
|
+
data["refresh_token"] = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
|
|
383
|
+
data["username"] = get_username_from_cache()
|
|
384
|
+
data["user_id"] = get_user_id()
|
|
385
|
+
return data
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def logout():
|
|
389
|
+
"""Clear stored credentials from keyring and all disk caches."""
|
|
390
|
+
from redfetch import meta
|
|
391
|
+
|
|
392
|
+
keyring_credentials = ["access_token", "refresh_token", "api_key"]
|
|
393
|
+
legacy_credentials = ["user_id", "username", "expires_at"]
|
|
394
|
+
|
|
395
|
+
credentials_deleted = False
|
|
396
|
+
|
|
397
|
+
for credential in keyring_credentials + legacy_credentials:
|
|
398
|
+
try:
|
|
399
|
+
keyring.delete_password(KEYRING_SERVICE_NAME, credential)
|
|
400
|
+
credentials_deleted = True
|
|
401
|
+
except keyring.errors.PasswordDeleteError:
|
|
402
|
+
pass
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
clear_disk_cache()
|
|
406
|
+
meta.clear_pypi_cache()
|
|
407
|
+
net.clear_manifest_cache()
|
|
408
|
+
credentials_deleted = True
|
|
409
|
+
except Exception:
|
|
410
|
+
pass
|
|
411
|
+
|
|
412
|
+
if credentials_deleted:
|
|
413
|
+
print("You have been logged out successfully.")
|
|
414
|
+
else:
|
|
415
|
+
print("No active session found. You were not logged in.")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# ---------------------------------------------------------------------------
|
|
419
|
+
# API identity resolution
|
|
420
|
+
# ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
async def fetch_me(client: httpx.AsyncClient) -> Optional[dict]:
|
|
423
|
+
"""Fetch current user info from /api/me."""
|
|
424
|
+
url = f'{BASE_URL}/api/me'
|
|
425
|
+
try:
|
|
426
|
+
data = await net.get_json(client, url)
|
|
427
|
+
return {
|
|
428
|
+
'user_id': str(data['me']['user_id']),
|
|
429
|
+
'username': data['me']['username']
|
|
430
|
+
}
|
|
431
|
+
except Exception as e:
|
|
432
|
+
print(f"Failed to retrieve user info: {e}")
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
async def fetch_user_id_from_api(api_key):
|
|
437
|
+
"""Fetch user_id using the API key; caches it."""
|
|
438
|
+
async with httpx.AsyncClient(headers={'XF-Api-Key': api_key}, http2=True) as client:
|
|
439
|
+
me = await fetch_me(client)
|
|
440
|
+
if me:
|
|
441
|
+
set_user_id(me['user_id'])
|
|
442
|
+
return me['user_id']
|
|
443
|
+
return None
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
async def fetch_username(api_key, cache=True):
|
|
447
|
+
"""Fetch username via API key; caches username and user_id."""
|
|
448
|
+
async with httpx.AsyncClient(headers={'XF-Api-Key': api_key}, http2=True) as client:
|
|
449
|
+
me = await fetch_me(client)
|
|
450
|
+
if me:
|
|
451
|
+
if cache:
|
|
452
|
+
set_username(me['username'])
|
|
453
|
+
set_user_id(me['user_id'])
|
|
454
|
+
return me['username']
|
|
455
|
+
return "Unknown"
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
async def get_api_headers():
|
|
459
|
+
"""Return auth headers for XenForo API requests.
|
|
460
|
+
|
|
461
|
+
Priority order:
|
|
462
|
+
1) API key via env: `REDGUIDES_API_KEY`
|
|
463
|
+
2) Native OAuth2: cached `access_token` from keyring
|
|
464
|
+
"""
|
|
465
|
+
api_key = os.environ.get('REDGUIDES_API_KEY')
|
|
466
|
+
if api_key:
|
|
467
|
+
headers = {'XF-Api-Key': api_key}
|
|
468
|
+
user_id = os.environ.get('REDGUIDES_USER_ID')
|
|
469
|
+
if not user_id:
|
|
470
|
+
user_id = await fetch_user_id_from_api(api_key)
|
|
471
|
+
if not user_id:
|
|
472
|
+
raise RuntimeError("Unable to retrieve user ID using the provided API key.")
|
|
473
|
+
headers['XF-Api-User'] = str(user_id)
|
|
474
|
+
return headers
|
|
475
|
+
|
|
476
|
+
access_token = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
|
|
477
|
+
refresh_tok = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
|
|
478
|
+
|
|
479
|
+
if access_token or refresh_tok:
|
|
480
|
+
if access_token and token_is_valid():
|
|
481
|
+
return {"Authorization": f"Bearer {access_token}"}
|
|
482
|
+
|
|
483
|
+
if refresh_tok:
|
|
484
|
+
client_id = _get_setting("OAUTH_CLIENT_ID")
|
|
485
|
+
client_secret = _get_setting("OAUTH_CLIENT_SECRET", "")
|
|
486
|
+
redirect_uri = _get_setting("OAUTH_REDIRECT_URI", DEFAULT_REDIRECT_URI)
|
|
487
|
+
|
|
488
|
+
if not client_id:
|
|
489
|
+
raise RuntimeError("OAuth client is not configured.")
|
|
490
|
+
|
|
491
|
+
refreshed = await asyncio.to_thread(
|
|
492
|
+
refresh_token,
|
|
493
|
+
str(client_id),
|
|
494
|
+
str(client_secret or ""),
|
|
495
|
+
redirect_uri=str(redirect_uri),
|
|
496
|
+
)
|
|
497
|
+
if refreshed:
|
|
498
|
+
access_token = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
|
|
499
|
+
if access_token:
|
|
500
|
+
return {"Authorization": f"Bearer {access_token}"}
|
|
501
|
+
|
|
502
|
+
raise RuntimeError("OAuth token refresh failed. Please run `redfetch logout` and authorize again.")
|
|
503
|
+
|
|
504
|
+
raise RuntimeError("OAuth access token is expired and no refresh token is available. Please authorize again.")
|
|
505
|
+
|
|
506
|
+
raise RuntimeError(
|
|
507
|
+
"Not authenticated. Set REDGUIDES_API_KEY (and optionally REDGUIDES_USER_ID), "
|
|
508
|
+
"or authorize via OAuth."
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
async def get_username():
|
|
513
|
+
"""Fetch the username from the environment variable, disk cache, or API."""
|
|
514
|
+
username = os.environ.get('REDFETCH_USERNAME')
|
|
515
|
+
if username:
|
|
516
|
+
return username
|
|
517
|
+
|
|
518
|
+
username = get_username_from_cache()
|
|
519
|
+
if username:
|
|
520
|
+
return username
|
|
521
|
+
|
|
522
|
+
api_key = os.environ.get('REDGUIDES_API_KEY')
|
|
523
|
+
if api_key:
|
|
524
|
+
username = await fetch_username(api_key)
|
|
525
|
+
if username != "Unknown":
|
|
526
|
+
return username
|
|
527
|
+
raise RuntimeError("Unable to retrieve username using the provided API key.")
|
|
528
|
+
|
|
529
|
+
access_token = keyring.get_password(KEYRING_SERVICE_NAME, "access_token")
|
|
530
|
+
refresh_tok = keyring.get_password(KEYRING_SERVICE_NAME, "refresh_token")
|
|
531
|
+
if access_token or refresh_tok:
|
|
532
|
+
headers = await get_api_headers()
|
|
533
|
+
async with httpx.AsyncClient(headers=headers, http2=True) as client:
|
|
534
|
+
me = await fetch_me(client)
|
|
535
|
+
if me and me.get("username"):
|
|
536
|
+
set_username(me["username"])
|
|
537
|
+
set_user_id(me["user_id"])
|
|
538
|
+
return me["username"]
|
|
539
|
+
raise RuntimeError("Unable to retrieve username using the stored OAuth token.")
|
|
540
|
+
|
|
541
|
+
raise RuntimeError("Username not found. Set REDGUIDES_API_KEY or authorize via OAuth.")
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def initialize_keyring():
|
|
545
|
+
# Skip keyring init if using env var (mainly for CI on Linux)
|
|
546
|
+
if os.environ.get('REDGUIDES_API_KEY'):
|
|
547
|
+
return
|
|
548
|
+
|
|
549
|
+
try:
|
|
550
|
+
# Attempt to use the keyring to trigger any potential errors
|
|
551
|
+
keyring.get_password('test_service', 'test_user')
|
|
552
|
+
except (NoKeyringError, ModuleNotFoundError):
|
|
553
|
+
raise RuntimeError(
|
|
554
|
+
"No suitable keyring backend found, probably because you're not on Windows.\n\n"
|
|
555
|
+
"Please install `keyrings.alt` by running:\n"
|
|
556
|
+
" pipx inject redfetch keyrings.alt\n\n"
|
|
557
|
+
"Then restart the application."
|
|
558
|
+
)
|
|
559
|
+
except Exception as e:
|
|
560
|
+
# Catch any other exceptions that may occur and handle them gracefully
|
|
561
|
+
raise RuntimeError(
|
|
562
|
+
f"An error occurred while initializing keyring: {e}\n\n"
|
|
563
|
+
"Please ensure that a suitable keyring backend is available."
|
|
564
|
+
) from e
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
if __name__ == "__main__":
|
|
568
|
+
initialize_keyring()
|
|
569
|
+
if not os.environ.get("REDGUIDES_API_KEY"):
|
|
570
|
+
# Initialize config lazily if invoked directly, so this can be used as a standalone script.
|
|
571
|
+
if config.settings is None:
|
|
572
|
+
config.initialize_config()
|
|
573
|
+
authorize()
|