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/download.py ADDED
@@ -0,0 +1,312 @@
1
+ # standard
2
+ import os
3
+ import shutil
4
+ import time
5
+ import hashlib
6
+ from zipfile import ZipFile, is_zipfile
7
+ import asyncio
8
+
9
+ # third-party
10
+ import httpx
11
+ import aiofiles
12
+ from tenacity import (
13
+ retry,
14
+ stop_after_attempt,
15
+ wait_exponential,
16
+ retry_if_exception_type,
17
+ )
18
+
19
+ # local
20
+ from redfetch import config
21
+ from redfetch.utils import is_safe_path
22
+
23
+ # ZIP safety constants
24
+ MAX_UNCOMPRESSED_SIZE = 2 * 1024 * 1024 * 1024 # 2GB limit
25
+ MAX_FILES_PER_ZIP = 60000 # Safety cap to avoid zipbombs
26
+
27
+
28
+ async def download_install_target_async(
29
+ *,
30
+ client: httpx.AsyncClient,
31
+ resource_id: str,
32
+ download_url: str,
33
+ filename: str,
34
+ file_hash: str | None,
35
+ folder_path: str,
36
+ should_flatten: bool = False,
37
+ protected_files: list[str] | None = None,
38
+ ) -> bool:
39
+ try:
40
+ print(f"Starting download: {filename} (ID: {resource_id})", flush=True)
41
+ file_path = os.path.join(folder_path, filename)
42
+ ok = await download_file_async(client, download_url, file_path, file_hash)
43
+ if not ok:
44
+ print(f"Download failed for resource {resource_id}.")
45
+ return False
46
+ if is_zipfile(file_path):
47
+ extracted = await asyncio.to_thread(
48
+ extract_and_discard_zip,
49
+ file_path,
50
+ folder_path,
51
+ resource_id,
52
+ should_flatten,
53
+ protected_files,
54
+ )
55
+ if not extracted:
56
+ print(f"Extraction failed for resource {resource_id}.")
57
+ return False
58
+ return True
59
+ except httpx.HTTPError as e:
60
+ print(f"Failed to fetch or download resource {resource_id}: {str(e)}")
61
+ return False
62
+
63
+
64
+ @retry(
65
+ stop=stop_after_attempt(3),
66
+ wait=wait_exponential(multiplier=0.5, min=0.5, max=4),
67
+ retry=retry_if_exception_type((httpx.TimeoutException, httpx.NetworkError, httpx.HTTPStatusError)),
68
+ reraise=True,
69
+ )
70
+ async def download_file_async(
71
+ client: httpx.AsyncClient,
72
+ download_url: str,
73
+ file_path: str,
74
+ expected_md5: str | None = None,
75
+ ) -> bool:
76
+ """
77
+ Download a file to disk using an async flow:
78
+ 1. open HTTP stream
79
+ 2. stream body into a temp file with async for / await
80
+ 3. optionally verify MD5
81
+ 4. atomically move temp file into place
82
+ """
83
+ os.makedirs(os.path.dirname(file_path), exist_ok=True)
84
+
85
+ hasher = hashlib.md5() if expected_md5 else None
86
+ tmp_path = None
87
+
88
+ try:
89
+ async with client.stream("GET", download_url, timeout=60.0, follow_redirects=True) as resp:
90
+ resp.raise_for_status()
91
+ _ensure_disk_space(resp, file_path)
92
+
93
+ # Core async loop lives in a small helper
94
+ tmp_path = await _stream_to_tempfile(resp, file_path, hasher)
95
+
96
+ if expected_md5 and hasher:
97
+ if not _hash_matches(hasher, expected_md5, file_path):
98
+ return False
99
+
100
+ if tmp_path is None:
101
+ return False
102
+
103
+ os.replace(tmp_path, file_path)
104
+ print(f"Downloaded file {file_path}")
105
+ return True
106
+ finally:
107
+ try:
108
+ if tmp_path and os.path.exists(tmp_path):
109
+ os.remove(tmp_path)
110
+ except Exception:
111
+ pass
112
+
113
+
114
+ def _ensure_disk_space(resp, file_path: str) -> None:
115
+ """Disk space guard using Content-Length when available."""
116
+ content_length = resp.headers.get("Content-Length")
117
+ if content_length is None or not content_length.isdigit():
118
+ return
119
+
120
+ expected_size = int(content_length)
121
+ target_dir = os.path.dirname(file_path)
122
+ free_bytes = shutil.disk_usage(target_dir).free
123
+ # Require a 100MB cushion
124
+ if free_bytes < expected_size + 100 * 1024 * 1024:
125
+ raise OSError(f"Insufficient disk space for download: need ~{expected_size} bytes")
126
+
127
+
128
+ async def _stream_to_tempfile(resp, file_path: str, hasher) -> str:
129
+ """Stream HTTP body into a temp file."""
130
+ tmp_dir = os.path.dirname(file_path)
131
+ tmp_path = None
132
+
133
+ try:
134
+ async with aiofiles.tempfile.NamedTemporaryFile("wb", delete=False, dir=tmp_dir) as tmp:
135
+ tmp_path = tmp.name
136
+
137
+ async for chunk in resp.aiter_bytes(chunk_size=262_144):
138
+ if not chunk:
139
+ continue
140
+
141
+ if hasher:
142
+ hasher.update(chunk)
143
+
144
+ await tmp.write(chunk)
145
+
146
+ return tmp_path
147
+ except Exception:
148
+ # Clean up any partially written temp file on error
149
+ if tmp_path and os.path.exists(tmp_path):
150
+ os.remove(tmp_path)
151
+ raise
152
+
153
+
154
+ def _hash_matches(hasher, expected_md5: str, file_path: str) -> bool:
155
+ """Return True when the computed MD5 matches *expected_md5*."""
156
+ actual = hasher.hexdigest().lower()
157
+ expected = expected_md5.strip().lower()
158
+
159
+ if actual == expected:
160
+ return True
161
+
162
+ print(f"MD5 mismatch for {os.path.basename(file_path)}")
163
+ print(f" Expected: {expected}")
164
+ print(f" Got: {actual}")
165
+ return False
166
+
167
+
168
+ #
169
+ # zip functions
170
+ #
171
+
172
+ def extract_and_discard_zip(zip_path, extract_to, resource_id, should_flatten=False, protected_files=None) -> bool:
173
+ try:
174
+ zip_size = os.path.getsize(zip_path)
175
+ if zip_size > MAX_UNCOMPRESSED_SIZE:
176
+ print(f"ZIP file {zip_path} exceeds the 2GB size limit. Extraction aborted.")
177
+ return False
178
+
179
+ with ZipFile(zip_path, 'r') as zip_ref:
180
+ try:
181
+ bad_member = zip_ref.testzip()
182
+ if bad_member is not None:
183
+ print(f"ZIP integrity check failed at member: {bad_member}. Extraction aborted.")
184
+ return False
185
+ except Exception as e:
186
+ print(f"ZIP integrity check failed: {e}. Extraction aborted.")
187
+ return False
188
+
189
+ infos = zip_ref.infolist()
190
+ if len(infos) > MAX_FILES_PER_ZIP:
191
+ print(f"ZIP has too many files ({len(infos)} > {MAX_FILES_PER_ZIP}). Extraction aborted.")
192
+ return False
193
+
194
+ total_uncompressed_size = sum(zinfo.file_size for zinfo in infos)
195
+ if total_uncompressed_size > MAX_UNCOMPRESSED_SIZE:
196
+ print(f"Total uncompressed size {total_uncompressed_size} exceeds the 2GB limit. Extraction aborted.")
197
+ return False
198
+
199
+ try:
200
+ free_bytes = shutil.disk_usage(extract_to).free
201
+ if free_bytes < total_uncompressed_size + 500 * 1024 * 1024:
202
+ print("Insufficient disk space for extraction. Extraction aborted.")
203
+ return False
204
+ except Exception:
205
+ pass
206
+
207
+ if protected_files is None:
208
+ protected_files = config.settings.from_env(config.settings.ENV).PROTECTED_FILES_BY_RESOURCE.get(resource_id, [])
209
+ if should_flatten:
210
+ extract_flattened(zip_ref, extract_to, protected_files)
211
+ else:
212
+ extract_with_structure(zip_ref, extract_to, protected_files)
213
+ return True
214
+ finally:
215
+ delete_zip_file(zip_path)
216
+
217
+
218
+ def extract_flattened(zip_ref, extract_to, protected_files):
219
+ print(f"Flattening extraction to {extract_to}")
220
+ protected_files_lower = {f.lower() for f in protected_files}
221
+ for member in zip_ref.infolist():
222
+ filename = os.path.basename(member.filename)
223
+ if not filename:
224
+ continue
225
+ target_path = os.path.join(extract_to, filename)
226
+ normalized_path = os.path.normpath(target_path)
227
+ if is_protected(filename, normalized_path, protected_files, protected_files_lower):
228
+ print(f"Skipping protected file {filename}")
229
+ continue
230
+ if is_safe_path(extract_to, normalized_path):
231
+ extract_zip_member(zip_ref, member, normalized_path)
232
+ else:
233
+ print(f"Skipping unsafe file {member.filename}")
234
+
235
+
236
+ def extract_with_structure(zip_ref, extract_to, protected_files):
237
+ print(f"Extracting with structure to {extract_to}")
238
+ protected_files_lower = {f.lower() for f in protected_files}
239
+ for member in zip_ref.infolist():
240
+ target_path = os.path.join(extract_to, member.filename)
241
+ normalized_path = os.path.normpath(target_path)
242
+ if not is_safe_path(extract_to, normalized_path):
243
+ print(f"Skipping unsafe file {member.filename}")
244
+ continue
245
+ if is_protected(os.path.basename(member.filename), normalized_path, protected_files, protected_files_lower):
246
+ print(f"Skipping protected file {member.filename}")
247
+ continue
248
+ if member.is_dir():
249
+ os.makedirs(normalized_path, exist_ok=True)
250
+ continue
251
+ extract_zip_member(zip_ref, member, normalized_path)
252
+
253
+
254
+ def extract_zip_member(zip_ref, member, target_path):
255
+ os.makedirs(os.path.dirname(target_path), exist_ok=True)
256
+ max_retries = 3
257
+ retry_delay = 10 # seconds
258
+
259
+ for attempt in range(max_retries):
260
+ try:
261
+ with zip_ref.open(member) as source, open(target_path, 'wb') as target:
262
+ shutil.copyfileobj(source, target)
263
+ return # Successful extraction, exit the function
264
+ except PermissionError:
265
+ file_name = os.path.basename(target_path)
266
+ folder_path = os.path.dirname(target_path)
267
+
268
+ error_msg = [
269
+ f"\nPermission Error: Unable to extract {file_name}",
270
+ "\nThis could be because:",
271
+ "1. The file is currently in use by another program (e.g., MacroQuest, EQBCS)",
272
+ "2. You don't have write permissions for this location",
273
+ "\nPossible solutions:",
274
+ "1. Close all EverQuest-related programs (MacroQuest, EQBCS, etc.)",
275
+ f"2. Change the installation directory in settings to a location you own",
276
+ f"3. Manually set write permissions on: {folder_path}",
277
+ ]
278
+
279
+ if attempt < max_retries - 1:
280
+ error_msg.append(f"\nRetrying in {retry_delay} seconds... (Attempt {attempt + 1} of {max_retries})")
281
+ print("\n".join(error_msg))
282
+ time.sleep(retry_delay)
283
+ else:
284
+ error_msg.append("\nMaximum retry attempts reached. Please resolve the permission issue and try again.")
285
+ print("\n".join(error_msg))
286
+ raise PermissionError(f"Failed to extract {file_name} after {max_retries} attempts.")
287
+ except Exception as e:
288
+ print(f"Unexpected error while extracting {os.path.basename(target_path)}: {str(e)}")
289
+ raise
290
+
291
+
292
+ def delete_zip_file(zip_path):
293
+ try:
294
+ os.remove(zip_path)
295
+ except PermissionError as e:
296
+ print(f"PermissionError: Unable to delete zip file {zip_path}. Error: {e}")
297
+
298
+
299
+ #
300
+ # utility functions
301
+ #
302
+
303
+ def is_protected(filename, target_path, protected_files, protected_files_lower):
304
+ # Overwrite protection for specified files
305
+ filename_lower = filename.lower()
306
+ if filename_lower in protected_files_lower and os.path.exists(target_path):
307
+ # Retrieve the original filename case for message consistency
308
+ protected_list = [f for f in protected_files if f.lower() == filename_lower]
309
+ original_filename = protected_list[0] if protected_list else filename
310
+ print(f"Protected {original_filename}, skipping extraction.")
311
+ return True
312
+ return False
redfetch/listener.py ADDED
@@ -0,0 +1,216 @@
1
+ """Local API for the RedGuides.com web interface."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import webbrowser
6
+ from typing import Any, Dict, Optional
7
+
8
+ from aiohttp import web
9
+
10
+ from redfetch import store
11
+ from redfetch import sync
12
+ from redfetch.special import compute_special_status
13
+
14
+ log = logging.getLogger(__name__)
15
+
16
+
17
+ REDGUIDES_ORIGIN = "https://www.redguides.com"
18
+
19
+
20
+ @web.middleware
21
+ async def cors_middleware(request: web.Request, handler):
22
+ """Simple CORS middleware allowing RedGuides origin."""
23
+ if request.method == "OPTIONS":
24
+ resp = web.Response(status=204)
25
+ else:
26
+ resp = await handler(request)
27
+
28
+ resp.headers["Access-Control-Allow-Origin"] = REDGUIDES_ORIGIN
29
+ resp.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
30
+ resp.headers["Access-Control-Allow-Headers"] = "Content-Type"
31
+ resp.headers["Access-Control-Allow-Credentials"] = "true"
32
+ return resp
33
+
34
+
35
+ async def _get_root_version_local_async(db_name: str, resource_id: str) -> Optional[int]:
36
+ """Async helper to fetch root version_local for a tracked root install target."""
37
+ db_path = store.get_db_path(db_name)
38
+ try:
39
+ return await store.fetch_root_version_local(db_path, resource_id)
40
+ except Exception as e:
41
+ if "no such table" in str(e):
42
+ # Table doesn't exist yet; treat as not installed
43
+ return None
44
+ log.error("Database error during health check: %s", e, exc_info=True)
45
+ raise
46
+
47
+
48
+ async def handle_health(request: web.Request) -> web.Response:
49
+ db_name = request.app["db_name"]
50
+
51
+ resource_id = request.query.get("resource_id")
52
+ remote_version_str = request.query.get("remote_version")
53
+
54
+ if resource_id and remote_version_str is not None:
55
+ try:
56
+ remote_version = int(remote_version_str)
57
+ except ValueError:
58
+ return web.json_response({"success": False, "message": "Invalid remote_version"}, status=400)
59
+
60
+ try:
61
+ version_local = await _get_root_version_local_async(db_name, resource_id)
62
+ except Exception:
63
+ return web.json_response({"success": False, "message": "Database error"}, status=500)
64
+
65
+ if version_local is None:
66
+ return web.json_response({"action": "install"})
67
+ elif version_local < remote_version:
68
+ return web.json_response({"action": "update"})
69
+ else:
70
+ return web.json_response({"action": "re-install"})
71
+
72
+ return web.json_response({"status": "up"})
73
+
74
+
75
+ async def handle_download(request: web.Request) -> web.Response:
76
+ app = request.app
77
+ db_name = app["db_name"]
78
+ headers = app["headers"]
79
+
80
+ try:
81
+ payload: Dict[str, Any] = await request.json()
82
+ except Exception:
83
+ return web.json_response({"success": False, "message": "JSON body is required."}, status=400)
84
+
85
+ resource_id = payload.get("resource_id")
86
+ if resource_id is None:
87
+ return web.json_response({"success": False, "message": "Resource ID is required."}, status=400)
88
+
89
+ try:
90
+ resource_id_str = str(resource_id)
91
+ db_path = store.get_db_path(db_name)
92
+ success = await sync.run_sync(db_path, headers, resource_ids=[resource_id_str])
93
+ if success:
94
+ return web.json_response({"success": True, "message": "Download completed successfully."})
95
+ return web.json_response({"success": False, "message": "Download failed due to internal error."}, status=500)
96
+ except Exception as e:
97
+ log.error("Error during download: %s", e, exc_info=True)
98
+ return web.json_response({"success": False, "message": f"Download failed: {e}"}, status=500)
99
+
100
+
101
+ async def handle_download_watched(request: web.Request) -> web.Response:
102
+ app = request.app
103
+ db_name = app["db_name"]
104
+ headers = app["headers"]
105
+
106
+ try:
107
+ db_path = store.get_db_path(db_name)
108
+ success = await sync.run_sync(db_path, headers)
109
+ if success:
110
+ return web.json_response(
111
+ {"success": True, "message": "All watched resources downloaded successfully."}
112
+ )
113
+ return web.json_response(
114
+ {"success": False, "message": "Download of one or more resources failed."}, status=500
115
+ )
116
+ except Exception as e:
117
+ log.error("Error during download of watched resources: %s", e, exc_info=True)
118
+ return web.json_response({"success": False, "message": f"Download failed: {e}"}, status=500)
119
+
120
+
121
+ async def handle_reset_download_date(request: web.Request) -> web.Response:
122
+ app = request.app
123
+ db_name = app["db_name"]
124
+
125
+ try:
126
+ payload: Dict[str, Any] = await request.json()
127
+ except Exception:
128
+ return web.json_response({"success": False, "message": "JSON body is required."}, status=400)
129
+
130
+ resource_id = payload.get("resource_id")
131
+ if not resource_id:
132
+ return web.json_response({"success": False, "message": "Resource ID is required."}, status=400)
133
+
134
+ try:
135
+ int(str(resource_id))
136
+ except ValueError:
137
+ return web.json_response({"success": False, "message": "Invalid resource ID format."}, status=400)
138
+
139
+ def _reset() -> bool:
140
+ try:
141
+ with store.get_db_connection(db_name) as conn:
142
+ cursor = conn.cursor()
143
+ store.reset_versions_for_resource(cursor, str(resource_id))
144
+ conn.commit()
145
+ return True
146
+ except Exception as e:
147
+ log.error("Error during resetting download date: %s", e, exc_info=True)
148
+ return False
149
+
150
+ ok = await asyncio.to_thread(_reset)
151
+ if ok:
152
+ return web.json_response({"success": True, "message": "Download date reset successfully."})
153
+ return web.json_response({"success": False, "message": "Reset failed."}, status=500)
154
+
155
+
156
+ async def handle_category_map(request: web.Request) -> web.Response:
157
+ category_map = request.app["category_map"]
158
+ return web.json_response(list(category_map.keys()))
159
+
160
+
161
+ async def handle_special_resource_ids(request: web.Request) -> web.Response:
162
+ status = compute_special_status(None)
163
+ special_resource_ids = [int(rid) for rid, info in status.items() if info["is_special"]]
164
+ return web.json_response(special_resource_ids)
165
+
166
+
167
+ async def create_app(
168
+ settings,
169
+ db_name: str,
170
+ headers: dict,
171
+ category_map,
172
+ ) -> web.Application:
173
+ """Create the aiohttp application for the RedGuides interface."""
174
+ app = web.Application(middlewares=[cors_middleware])
175
+
176
+ app["settings"] = settings
177
+ app["db_name"] = db_name
178
+ app["headers"] = headers
179
+ app["category_map"] = category_map
180
+
181
+ app.router.add_get("/health", handle_health)
182
+ app.router.add_post("/download", handle_download)
183
+ app.router.add_post("/download-watched", handle_download_watched)
184
+ app.router.add_post("/reset-download-date", handle_reset_download_date)
185
+ app.router.add_get("/category-map", handle_category_map)
186
+ app.router.add_get("/special-resource-ids", handle_special_resource_ids)
187
+
188
+ return app
189
+
190
+
191
+ async def run_server_async(
192
+ settings,
193
+ db_name: str,
194
+ headers: dict,
195
+ category_map,
196
+ ) -> None:
197
+ """Run the interface server until cancelled."""
198
+ app = await create_app(settings, db_name, headers, category_map)
199
+
200
+ runner = web.AppRunner(app)
201
+ await runner.setup()
202
+ site = web.TCPSite(runner, "127.0.0.1", 7734)
203
+ await site.start()
204
+
205
+ webbrowser.open_new("https://www.redguides.com/cookie/set_marker.php")
206
+ log.info("Server starting. Browse resources on https://www.redguides.com/community/resources")
207
+
208
+ try:
209
+ # Wait indefinitely until the task is cancelled by the caller (CLI or TUI).
210
+ await asyncio.Event().wait()
211
+ except asyncio.CancelledError:
212
+ log.info("Server task cancelled, shutting down...")
213
+ finally:
214
+ await runner.cleanup()
215
+ log.info("Server stopped.")
216
+