Harden network requests, downloads, and path handling
- Add core/network_utils.py with URL validation, public-IP enforcement, redirect validation, and safe request_public_url/validate_public_http_url helpers - Replace direct requests calls in downloader/type_utils/sources with request_public_url so redirects are validated before follow-up - Replace netloc string checks with host_matches_domain for CivitAI, CivArchive, and HuggingFace host matching - Add HuggingFace Xet download backend with progress reporting and cancellation support via hf_xet/huggingface_hub - Harden aria2 installer: reject symlinks/special files in archives, restrict custom aria2c paths to the managed install, and update UI copy - Normalize paths with realpath and enforce configured model roots in local hash/metadata routes, plus remove caller-selected metadata_path injection in sidecar writing - Sanitize download/network errors to strip signed query strings before logging or showing them to the user
This commit is contained in:
+81
-43
@@ -265,11 +265,16 @@ class ModelResolverExtension:
|
||||
read_json_safe,
|
||||
write_json_atomic,
|
||||
)
|
||||
from .core.network_utils import (
|
||||
UnsafeUrlError,
|
||||
host_matches_domain,
|
||||
request_public_url,
|
||||
validate_public_http_url,
|
||||
)
|
||||
from .core.type_utils import (
|
||||
first_non_empty,
|
||||
to_int,
|
||||
to_bool,
|
||||
MODEL_EXTENSIONS,
|
||||
format_size_bytes,
|
||||
normalize_sha256,
|
||||
extract_sha256_from_metadata,
|
||||
@@ -305,9 +310,11 @@ class ModelResolverExtension:
|
||||
stop_aria2_daemon,
|
||||
get_download_directory,
|
||||
get_metadata_sidecar_path,
|
||||
get_safe_metadata_sidecar_path,
|
||||
normalize_download_category,
|
||||
write_lora_manager_metadata,
|
||||
sanitize_download_filename,
|
||||
is_allowed_model_download_filename,
|
||||
)
|
||||
from .core.aria2_installer import Aria2InstallError, install_aria2_engine
|
||||
from .core.sources.popular import (
|
||||
@@ -733,8 +740,17 @@ class ModelResolverExtension:
|
||||
{"error": "path is required"}, status=400
|
||||
)
|
||||
|
||||
normalized_path = os.path.realpath(
|
||||
os.path.abspath(os.path.normpath(str(path)))
|
||||
)
|
||||
if not is_path_in_configured_model_roots(normalized_path):
|
||||
return web.json_response(
|
||||
{"error": "path is outside configured model directories"},
|
||||
status=403,
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
get_local_model_hash_metadata(path, model=model)
|
||||
get_local_model_hash_metadata(normalized_path, model=model)
|
||||
)
|
||||
|
||||
@routes.post("/model_resolver/workflow-model-hashes")
|
||||
@@ -905,7 +921,9 @@ class ModelResolverExtension:
|
||||
{"error": "path is required"}, status=400
|
||||
)
|
||||
|
||||
normalized_path = os.path.abspath(os.path.normpath(target_path))
|
||||
normalized_path = os.path.realpath(
|
||||
os.path.abspath(os.path.normpath(target_path))
|
||||
)
|
||||
if not os.path.exists(normalized_path):
|
||||
return web.json_response(
|
||||
{"error": "path does not exist"}, status=404
|
||||
@@ -942,7 +960,9 @@ class ModelResolverExtension:
|
||||
if not file_path:
|
||||
return "", "file_path is required"
|
||||
|
||||
normalized_path = _os.path.abspath(_os.path.normpath(file_path))
|
||||
normalized_path = _os.path.realpath(
|
||||
_os.path.abspath(_os.path.normpath(file_path))
|
||||
)
|
||||
if not _os.path.exists(normalized_path) or not _os.path.isfile(normalized_path):
|
||||
return "", "file does not exist"
|
||||
if not is_path_in_configured_model_roots(normalized_path):
|
||||
@@ -951,25 +971,12 @@ class ModelResolverExtension:
|
||||
|
||||
def write_calculated_hash_metadata(
|
||||
normalized_path,
|
||||
metadata_path,
|
||||
sha256,
|
||||
sha256_source="file",
|
||||
):
|
||||
import os as _os
|
||||
|
||||
model_dir = _os.path.abspath(_os.path.dirname(normalized_path))
|
||||
resolved_metadata_path = ""
|
||||
if metadata_path:
|
||||
candidate_path = _os.path.abspath(_os.path.normpath(metadata_path))
|
||||
try:
|
||||
same_dir = _os.path.commonpath([model_dir, candidate_path]) == model_dir
|
||||
except ValueError:
|
||||
same_dir = False
|
||||
if same_dir:
|
||||
resolved_metadata_path = candidate_path
|
||||
|
||||
if not resolved_metadata_path:
|
||||
resolved_metadata_path = get_metadata_sidecar_path(normalized_path)
|
||||
resolved_metadata_path = get_safe_metadata_sidecar_path(normalized_path)
|
||||
|
||||
metadata_updated = False
|
||||
try:
|
||||
@@ -1128,7 +1135,6 @@ class ModelResolverExtension:
|
||||
async def calculate_file_hash_route(request):
|
||||
"""Calculate SHA256 for a local model and persist it to sidecar metadata."""
|
||||
data = await request.json()
|
||||
metadata_path = data.get("metadata_path") or ""
|
||||
|
||||
normalized_path, error = resolve_hash_file_request(data)
|
||||
if error == "file_path is required":
|
||||
@@ -1148,7 +1154,6 @@ class ModelResolverExtension:
|
||||
)
|
||||
resolved_metadata_path, metadata_updated = write_calculated_hash_metadata(
|
||||
normalized_path,
|
||||
metadata_path,
|
||||
sha256,
|
||||
sha256_source,
|
||||
)
|
||||
@@ -1172,7 +1177,6 @@ class ModelResolverExtension:
|
||||
import uuid
|
||||
|
||||
data = await request.json()
|
||||
metadata_path = data.get("metadata_path") or ""
|
||||
normalized_path, error = resolve_hash_file_request(data)
|
||||
if error == "file_path is required":
|
||||
return web.json_response(
|
||||
@@ -1212,7 +1216,6 @@ class ModelResolverExtension:
|
||||
)
|
||||
resolved_metadata_path, metadata_updated = write_calculated_hash_metadata(
|
||||
normalized_path,
|
||||
metadata_path,
|
||||
sha256,
|
||||
sha256_source,
|
||||
)
|
||||
@@ -1958,6 +1961,29 @@ class ModelResolverExtension:
|
||||
if not file_path:
|
||||
file_path = find_local_file_path(filename, category)
|
||||
|
||||
if file_path:
|
||||
file_path = _os.path.realpath(
|
||||
_os.path.abspath(_os.path.normpath(str(file_path)))
|
||||
)
|
||||
if not is_path_in_configured_model_roots(file_path):
|
||||
if resolved_path:
|
||||
return web.json_response(
|
||||
{
|
||||
"error": (
|
||||
"resolved_path is outside configured model directories"
|
||||
)
|
||||
},
|
||||
status=403,
|
||||
)
|
||||
file_path = None
|
||||
elif not _os.path.isfile(file_path):
|
||||
if resolved_path:
|
||||
return web.json_response(
|
||||
{"error": "resolved_path is not an existing model file"},
|
||||
status=404,
|
||||
)
|
||||
file_path = None
|
||||
|
||||
if file_path and _os.path.exists(file_path):
|
||||
file_location = _os.path.dirname(file_path).replace("\\", "/")
|
||||
if file_location and not file_location.endswith("/"):
|
||||
@@ -2208,20 +2234,15 @@ class ModelResolverExtension:
|
||||
return urls
|
||||
|
||||
def remote_download_url_is_alive(url):
|
||||
try:
|
||||
import requests
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
headers = {
|
||||
"User-Agent": "ComfyUI-Model-Resolver/1.0",
|
||||
"Accept": "*/*",
|
||||
}
|
||||
try:
|
||||
response = requests.head(
|
||||
response, _final_url, _final_headers = request_public_url(
|
||||
"HEAD",
|
||||
url,
|
||||
headers=headers,
|
||||
allow_redirects=True,
|
||||
timeout=8,
|
||||
)
|
||||
try:
|
||||
@@ -2235,10 +2256,10 @@ class ModelResolverExtension:
|
||||
pass
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
response, _final_url, _final_headers = request_public_url(
|
||||
"GET",
|
||||
url,
|
||||
headers={**headers, "Range": "bytes=0-0"},
|
||||
allow_redirects=True,
|
||||
stream=True,
|
||||
timeout=8,
|
||||
)
|
||||
@@ -3113,15 +3134,16 @@ class ModelResolverExtension:
|
||||
if raw_url.startswith("hf://"):
|
||||
normalized_url = raw_url
|
||||
else:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed_input = urlparse(raw_url)
|
||||
if parsed_input.scheme not in {"http", "https"}:
|
||||
try:
|
||||
normalized_url = await asyncio.to_thread(
|
||||
validate_public_http_url,
|
||||
raw_url,
|
||||
)
|
||||
except UnsafeUrlError as exc:
|
||||
return web.json_response(
|
||||
{"error": "Only http, https, and hf:// URLs are supported"},
|
||||
{"error": str(exc)},
|
||||
status=400,
|
||||
)
|
||||
normalized_url = raw_url
|
||||
|
||||
category = data.get("category") or ""
|
||||
expected_filename = (
|
||||
@@ -3225,9 +3247,9 @@ class ModelResolverExtension:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed_direct = urlparse(normalized_url)
|
||||
direct_host = parsed_direct.netloc.lower()
|
||||
direct_host = parsed_direct.hostname
|
||||
if (
|
||||
direct_host.endswith("civarchive.com")
|
||||
host_matches_domain(direct_host, "civarchive.com")
|
||||
and "/api/download/" in parsed_direct.path
|
||||
):
|
||||
source = "civarchive"
|
||||
@@ -4549,6 +4571,14 @@ class ModelResolverExtension:
|
||||
return web.json_response(
|
||||
{"error": "URL is required"}, status=400
|
||||
)
|
||||
try:
|
||||
url = await asyncio.to_thread(validate_public_http_url, url)
|
||||
except UnsafeUrlError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
|
||||
from urllib.parse import urlparse as _download_urlparse
|
||||
|
||||
download_host = _download_urlparse(url).hostname
|
||||
|
||||
if not filename:
|
||||
# Extract filename from URL
|
||||
@@ -4562,14 +4592,22 @@ class ModelResolverExtension:
|
||||
return web.json_response(
|
||||
{"error": "Could not determine filename"}, status=400
|
||||
)
|
||||
if not is_allowed_model_download_filename(filename):
|
||||
return web.json_response(
|
||||
{"error": "Unsupported model file extension"}, status=400
|
||||
)
|
||||
|
||||
# Build headers if needed
|
||||
headers = {}
|
||||
if "huggingface.co" in url:
|
||||
if host_matches_domain(download_host, "huggingface.co"):
|
||||
hf_token = data.get("hf_token", "")
|
||||
if hf_token:
|
||||
headers["Authorization"] = f"Bearer {hf_token}"
|
||||
elif "civitai.com" in url or "civitai.red" in url:
|
||||
elif host_matches_domain(
|
||||
download_host,
|
||||
"civitai.com",
|
||||
"civitai.red",
|
||||
):
|
||||
civitai_key = data.get("civitai_key", "")
|
||||
if civitai_key and "token=" not in url:
|
||||
url += (
|
||||
@@ -4590,9 +4628,9 @@ class ModelResolverExtension:
|
||||
return to_int(value)
|
||||
|
||||
inferred_source = ""
|
||||
if "civitai.com" in url:
|
||||
if host_matches_domain(download_host, "civitai.com", "civitai.red"):
|
||||
inferred_source = "civitai"
|
||||
elif "huggingface.co" in url:
|
||||
elif host_matches_domain(download_host, "huggingface.co"):
|
||||
inferred_source = "huggingface"
|
||||
|
||||
download_metadata.setdefault("filename", filename)
|
||||
|
||||
+16
-3
@@ -169,7 +169,7 @@ def _select_release_asset(release: Dict[str, Any], tokens: Dict[str, str]) -> Di
|
||||
asset_names = ", ".join(_asset_name(asset) for asset in assets if _asset_name(asset))
|
||||
raise Aria2InstallError(
|
||||
f"No prebuilt aria2 desktop binary for {tokens['label']} was found in {release_name}. "
|
||||
"Install aria2 with your system package manager or configure aria2c_path manually. "
|
||||
"Install aria2 with your system package manager so aria2c is available on PATH. "
|
||||
f"Available assets: {asset_names or 'none'}"
|
||||
)
|
||||
|
||||
@@ -184,7 +184,7 @@ def _fetch_latest_release() -> Dict[str, Any]:
|
||||
except requests.RequestException as exc:
|
||||
raise Aria2InstallError(
|
||||
"Could not contact GitHub to check the latest aria2 release. "
|
||||
"Check your internet connection, firewall/proxy settings, or configure aria2c_path manually. "
|
||||
"Check your internet connection or firewall/proxy settings. "
|
||||
f"Details: {exc}"
|
||||
) from exc
|
||||
data = response.json()
|
||||
@@ -206,6 +206,11 @@ def _safe_extract_zip(archive_path: Path, destination: Path) -> None:
|
||||
destination_abs = destination.resolve()
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
for member in archive.infolist():
|
||||
unix_mode = member.external_attr >> 16
|
||||
if unix_mode and stat.S_ISLNK(unix_mode):
|
||||
raise Aria2InstallError(
|
||||
f"Refusing symbolic link in aria2 archive: {member.filename}"
|
||||
)
|
||||
target = destination_abs / member.filename
|
||||
target_abs = target.resolve()
|
||||
if not is_path_within(str(target_abs), str(destination_abs)) and target_abs != destination_abs:
|
||||
@@ -217,6 +222,14 @@ def _safe_extract_tar(archive_path: Path, destination: Path) -> None:
|
||||
destination_abs = destination.resolve()
|
||||
with tarfile.open(archive_path) as archive:
|
||||
for member in archive.getmembers():
|
||||
if member.issym() or member.islnk():
|
||||
raise Aria2InstallError(
|
||||
f"Refusing link in aria2 archive: {member.name}"
|
||||
)
|
||||
if not (member.isfile() or member.isdir()):
|
||||
raise Aria2InstallError(
|
||||
f"Refusing special file in aria2 archive: {member.name}"
|
||||
)
|
||||
target = destination_abs / member.name
|
||||
target_abs = target.resolve()
|
||||
if not is_path_within(str(target_abs), str(destination_abs)) and target_abs != destination_abs:
|
||||
@@ -356,7 +369,7 @@ def _download_file(url: str, destination: Path) -> int:
|
||||
except requests.RequestException as exc:
|
||||
raise Aria2InstallError(
|
||||
"Could not download the aria2 release asset from GitHub. "
|
||||
"Check your internet connection, firewall/proxy settings, or configure aria2c_path manually. "
|
||||
"Check your internet connection or firewall/proxy settings. "
|
||||
f"Details: {exc}"
|
||||
) from exc
|
||||
return total
|
||||
|
||||
+363
-30
@@ -26,7 +26,12 @@ log = create_module_logger(__name__)
|
||||
from .resolver import invalidate_local_hash_match_cache, normalize_sha256
|
||||
from .scanner import invalidate_model_files_cache
|
||||
from .path_utils import is_path_within, get_path_identity, write_json_atomic, read_json_safe, get_comfy_root_path, calculate_file_sha256, get_filename_from_path
|
||||
from .type_utils import as_dict, as_list, first_non_empty, format_size_bytes as format_bytes, DEFAULT_BROWSER_USER_AGENT, extract_response_file_size, normalize_category_to_model_type, get_category_folder_keys
|
||||
from .network_utils import (
|
||||
host_matches_domain,
|
||||
request_public_url,
|
||||
validate_public_http_url,
|
||||
)
|
||||
from .type_utils import as_dict, as_list, first_non_empty, format_size_bytes as format_bytes, DEFAULT_BROWSER_USER_AGENT, extract_response_file_size, normalize_category_to_model_type, get_category_folder_keys, MODEL_EXTENSIONS
|
||||
|
||||
|
||||
try:
|
||||
@@ -55,6 +60,7 @@ CLI_LOG_INTERVAL = 5 # Log progress to CLI every N seconds
|
||||
ARIA2_RPC_TIMEOUT = (2, 5) # local JSON-RPC should respond quickly
|
||||
ARIA2_IDLE_STOP_SECONDS = 5 * 60
|
||||
DOWNLOAD_USER_AGENT = DEFAULT_BROWSER_USER_AGENT
|
||||
MANAGED_ARIA2_ROOT = Path(__file__).resolve().parents[1] / "tools" / "aria2"
|
||||
|
||||
from .settings import CATEGORY_MAP, load_settings, normalize_download_backend, normalize_download_category, normalize_relative_subfolder
|
||||
|
||||
@@ -88,6 +94,7 @@ SENSITIVE_QUERY_KEYS = {
|
||||
}
|
||||
|
||||
_INVALID_DOWNLOAD_FILENAME_RE = re.compile(r'[<>:"/\\|?*\x00-\x1f]+')
|
||||
_HTTP_URL_IN_TEXT_RE = re.compile(r"https?://[^\s]+", re.IGNORECASE)
|
||||
|
||||
|
||||
def sanitize_download_filename(filename: Any) -> str:
|
||||
@@ -100,6 +107,12 @@ def sanitize_download_filename(filename: Any) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def is_allowed_model_download_filename(filename: Any) -> bool:
|
||||
"""Return True only for model file extensions supported by the resolver."""
|
||||
safe_name = sanitize_download_filename(filename)
|
||||
return bool(safe_name and os.path.splitext(safe_name)[1].lower() in MODEL_EXTENSIONS)
|
||||
|
||||
|
||||
def _is_sensitive_metadata_key(key: Any) -> bool:
|
||||
key_text = str(key or "").strip().lower()
|
||||
return (
|
||||
@@ -129,6 +142,23 @@ def _strip_sensitive_url_params(value: str) -> str:
|
||||
return urlunparse(parsed._replace(query=urlencode(filtered, doseq=True)))
|
||||
|
||||
|
||||
def _sanitize_download_error(value: Any) -> str:
|
||||
"""Remove signed query strings and credentials from errors shown or logged."""
|
||||
text = str(value or "")
|
||||
|
||||
def redact_url(match: re.Match) -> str:
|
||||
raw_url = match.group(0)
|
||||
try:
|
||||
parsed = urlparse(raw_url)
|
||||
if parsed.query:
|
||||
return urlunparse(parsed._replace(query="", fragment=""))
|
||||
except Exception:
|
||||
pass
|
||||
return raw_url
|
||||
|
||||
return _HTTP_URL_IN_TEXT_RE.sub(redact_url, text)
|
||||
|
||||
|
||||
def _clean_http_header_value(value: Any) -> str:
|
||||
return str(value or "").replace("\r", "").replace("\n", "").strip()
|
||||
|
||||
@@ -162,8 +192,8 @@ def build_download_headers(
|
||||
_set_header_default(request_headers, "Accept", "*/*")
|
||||
_set_header_default(request_headers, "Accept-Encoding", "identity")
|
||||
|
||||
host = urlparse(str(url or "")).netloc.lower()
|
||||
if host in {"civitai.com", "www.civitai.com", "civitai.red", "www.civitai.red"}:
|
||||
host = urlparse(str(url or "")).hostname
|
||||
if host_matches_domain(host, "civitai.com", "civitai.red"):
|
||||
_set_header_default(request_headers, "Referer", "https://civitai.com/")
|
||||
_set_header_default(request_headers, "Origin", "https://civitai.com")
|
||||
|
||||
@@ -172,9 +202,9 @@ def build_download_headers(
|
||||
|
||||
def _is_civitai_api_download_url(url: str) -> bool:
|
||||
parsed = urlparse(str(url or ""))
|
||||
host = parsed.netloc.lower()
|
||||
host = parsed.hostname
|
||||
return (
|
||||
host in {"civitai.com", "www.civitai.com", "civitai.red", "www.civitai.red"}
|
||||
host_matches_domain(host, "civitai.com", "civitai.red")
|
||||
and parsed.path.startswith("/api/download/")
|
||||
)
|
||||
|
||||
@@ -184,14 +214,15 @@ def _resolve_civitai_download_url_for_aria2(
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> str:
|
||||
"""Resolve CivitAI's API download redirect before handing the URL to aria2."""
|
||||
if not _is_civitai_api_download_url(url):
|
||||
return url
|
||||
validated_url = validate_public_http_url(url)
|
||||
if not _is_civitai_api_download_url(validated_url):
|
||||
return validated_url
|
||||
|
||||
response = None
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers=build_download_headers(url, headers),
|
||||
validated_url,
|
||||
headers=build_download_headers(validated_url, headers),
|
||||
allow_redirects=False,
|
||||
stream=True,
|
||||
timeout=20,
|
||||
@@ -203,12 +234,12 @@ def _resolve_civitai_download_url_for_aria2(
|
||||
if not location:
|
||||
return url
|
||||
|
||||
resolved_url = urljoin(url, location.strip())
|
||||
resolved_url = validate_public_http_url(urljoin(validated_url, location.strip()))
|
||||
log.debug("Resolved CivitAI download redirect for aria2")
|
||||
return resolved_url
|
||||
except Exception as exc:
|
||||
log.warning(f"Could not pre-resolve CivitAI download URL for aria2: {exc}")
|
||||
return url
|
||||
return validated_url
|
||||
finally:
|
||||
if response is not None:
|
||||
try:
|
||||
@@ -217,6 +248,28 @@ def _resolve_civitai_download_url_for_aria2(
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_download_url_for_aria2(
|
||||
url: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
) -> tuple[str, Dict[str, str]]:
|
||||
"""Preflight an aria2 URL and validate every redirect before RPC handoff."""
|
||||
request_headers = build_download_headers(url, headers)
|
||||
response = None
|
||||
try:
|
||||
response, resolved_url, resolved_headers = request_public_url(
|
||||
"GET",
|
||||
url,
|
||||
headers=request_headers,
|
||||
timeout=20,
|
||||
stream=True,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return resolved_url, resolved_headers
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
|
||||
|
||||
def _json_safe_metadata(value: Any, depth: int = 0) -> Any:
|
||||
if depth > 10:
|
||||
return str(value)
|
||||
@@ -272,6 +325,24 @@ def get_metadata_sidecar_path(file_path: str) -> str:
|
||||
return f"{base_path}.metadata.json"
|
||||
|
||||
|
||||
def get_safe_metadata_sidecar_path(file_path: str) -> str:
|
||||
"""Return the canonical sidecar path without allowing caller-selected targets."""
|
||||
raw_model_path = str(file_path or "").strip()
|
||||
if not raw_model_path:
|
||||
raise ValueError("A model path is required")
|
||||
model_path = os.path.realpath(os.path.abspath(raw_model_path))
|
||||
model_dir = os.path.realpath(os.path.dirname(model_path))
|
||||
metadata_path = os.path.realpath(get_metadata_sidecar_path(model_path))
|
||||
if (
|
||||
not model_dir
|
||||
or os.path.dirname(metadata_path) != model_dir
|
||||
or metadata_path == model_path
|
||||
or not is_path_within(metadata_path, model_dir)
|
||||
):
|
||||
raise ValueError("Metadata path is outside the model directory")
|
||||
return metadata_path
|
||||
|
||||
|
||||
def _resolve_lora_manager_model_type(category: str, source_type: Any = "") -> str:
|
||||
res = normalize_category_to_model_type(category)
|
||||
if res in ("checkpoint", "diffusion_model", "embedding"):
|
||||
@@ -778,16 +849,34 @@ def _resolve_aria2c_executable(settings: Optional[Dict[str, Any]] = None) -> str
|
||||
active_settings = settings if isinstance(settings, dict) else load_settings()
|
||||
configured = str(active_settings.get("aria2c_path") or "").strip()
|
||||
candidate = os.path.expandvars(os.path.expanduser(configured or "aria2c"))
|
||||
expected_names = {"aria2c", "aria2c.exe"}
|
||||
candidate_name = os.path.basename(candidate).lower()
|
||||
has_path_component = bool(
|
||||
os.path.isabs(candidate)
|
||||
or os.path.dirname(candidate)
|
||||
or "/" in candidate
|
||||
or "\\" in candidate
|
||||
)
|
||||
|
||||
resolved = shutil.which(candidate)
|
||||
if resolved:
|
||||
return resolved
|
||||
if has_path_component:
|
||||
if (
|
||||
candidate_name in expected_names
|
||||
and os.path.isfile(candidate)
|
||||
and is_path_within(candidate, MANAGED_ARIA2_ROOT)
|
||||
):
|
||||
return os.path.realpath(os.path.abspath(candidate))
|
||||
raise Aria2Error(
|
||||
"Custom aria2c paths are restricted to the managed Model Resolver install. "
|
||||
"Use the built-in aria2 installer or place aria2c on PATH."
|
||||
)
|
||||
|
||||
if configured and os.path.isfile(candidate):
|
||||
return candidate
|
||||
if candidate_name in expected_names:
|
||||
resolved = shutil.which(candidate)
|
||||
if resolved and os.path.basename(resolved).lower() in expected_names:
|
||||
return os.path.realpath(os.path.abspath(resolved))
|
||||
|
||||
raise Aria2Error(
|
||||
"aria2c executable was not found. Install aria2 or configure aria2c_path."
|
||||
"aria2c executable was not found. Use the built-in installer or place aria2c on PATH."
|
||||
)
|
||||
|
||||
|
||||
@@ -1150,6 +1239,230 @@ def _delete_partial_download_files(dest_path: str) -> None:
|
||||
log.warning(f"Could not delete incomplete download file {path}: {exc}")
|
||||
|
||||
|
||||
class _HuggingFaceXetDownloadCancelled(Exception):
|
||||
"""Raised by the Xet progress adapter when a download is cancelled."""
|
||||
|
||||
|
||||
class _HuggingFaceXetProgressAdapter:
|
||||
"""Forward hf_xet byte progress to the resolver's download state."""
|
||||
|
||||
def __init__(self, download_id: str, total_size: int, start_time: float) -> None:
|
||||
self.download_id = download_id
|
||||
self.total_size = max(0, int(total_size or 0))
|
||||
self.downloaded = 0
|
||||
self.last_update = start_time
|
||||
|
||||
def update(self, byte_delta: Any) -> None:
|
||||
if self.download_id in cancelled_downloads:
|
||||
raise _HuggingFaceXetDownloadCancelled("Download cancelled")
|
||||
|
||||
try:
|
||||
delta = max(0, int(float(byte_delta or 0)))
|
||||
except (TypeError, ValueError):
|
||||
delta = 0
|
||||
|
||||
now = time.time()
|
||||
elapsed = max(now - self.last_update, 0.001)
|
||||
self.last_update = now
|
||||
self.downloaded += delta
|
||||
speed = int(delta / elapsed) if delta else 0
|
||||
progress = (
|
||||
min(100, int((self.downloaded / self.total_size) * 100))
|
||||
if self.total_size > 0
|
||||
else 0
|
||||
)
|
||||
|
||||
with download_lock:
|
||||
state = download_progress.get(self.download_id)
|
||||
if state is not None:
|
||||
state.update(
|
||||
{
|
||||
"status": "downloading",
|
||||
"progress": progress,
|
||||
"downloaded": self.downloaded,
|
||||
"total_size": self.total_size,
|
||||
"speed": speed,
|
||||
"download_backend": "huggingface_xet",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _download_huggingface_xet(
|
||||
url: str,
|
||||
dest_path: str,
|
||||
download_id: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
category: str = "",
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Download Hugging Face Xet files with the official hf_xet transport."""
|
||||
validated_url = validate_public_http_url(url)
|
||||
parsed_url = urlparse(validated_url)
|
||||
if not (
|
||||
host_matches_domain(parsed_url.hostname, "huggingface.co")
|
||||
and "/resolve/" in parsed_url.path
|
||||
):
|
||||
return None
|
||||
|
||||
try:
|
||||
__import__("hf_xet")
|
||||
from huggingface_hub.file_download import get_hf_file_metadata, xet_get
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
request_headers = build_download_headers(validated_url, headers)
|
||||
try:
|
||||
file_metadata = get_hf_file_metadata(
|
||||
validated_url,
|
||||
headers=request_headers,
|
||||
timeout=20,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.debug(
|
||||
"Hugging Face Xet metadata probe failed; using HTTP fallback: "
|
||||
f"{type(exc).__name__}"
|
||||
)
|
||||
return None
|
||||
|
||||
xet_file_data = getattr(file_metadata, "xet_file_data", None)
|
||||
if xet_file_data is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
expected_size = max(0, int(getattr(file_metadata, "size", 0) or 0))
|
||||
except (TypeError, ValueError):
|
||||
expected_size = 0
|
||||
|
||||
result = {
|
||||
"success": False,
|
||||
"download_id": download_id,
|
||||
"path": dest_path,
|
||||
"error": None,
|
||||
"size": 0,
|
||||
}
|
||||
start_time = time.time()
|
||||
filename = get_filename_from_path(dest_path)
|
||||
partial_path = f"{dest_path}.xet-part"
|
||||
progress_adapter = _HuggingFaceXetProgressAdapter(
|
||||
download_id,
|
||||
expected_size,
|
||||
start_time,
|
||||
)
|
||||
|
||||
with download_lock:
|
||||
download_progress[download_id] = {
|
||||
"status": "starting",
|
||||
"progress": 0,
|
||||
"total_size": expected_size,
|
||||
"downloaded": 0,
|
||||
"filename": filename,
|
||||
"path": dest_path,
|
||||
"directory": os.path.dirname(dest_path),
|
||||
"url": validated_url,
|
||||
"error": None,
|
||||
"speed": 0,
|
||||
"start_time": start_time,
|
||||
"download_backend": "huggingface_xet",
|
||||
}
|
||||
|
||||
cancelled_downloads.discard(download_id)
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
if os.path.exists(partial_path):
|
||||
os.remove(partial_path)
|
||||
|
||||
log.info(f"Starting Hugging Face Xet download: {filename}")
|
||||
xet_get(
|
||||
incomplete_path=Path(partial_path),
|
||||
xet_file_data=xet_file_data,
|
||||
headers=request_headers,
|
||||
expected_size=expected_size or None,
|
||||
displayed_filename=filename,
|
||||
_tqdm_bar=progress_adapter,
|
||||
)
|
||||
|
||||
if download_id in cancelled_downloads:
|
||||
raise _HuggingFaceXetDownloadCancelled("Download cancelled")
|
||||
|
||||
size = os.path.getsize(partial_path)
|
||||
if expected_size and size != expected_size:
|
||||
raise IOError(
|
||||
f"Downloaded size mismatch: expected {expected_size}, received {size}"
|
||||
)
|
||||
os.replace(partial_path, dest_path)
|
||||
|
||||
metadata_path = write_lora_manager_metadata(
|
||||
dest_path,
|
||||
metadata or {},
|
||||
category,
|
||||
validated_url,
|
||||
)
|
||||
with download_lock:
|
||||
state = download_progress.get(download_id)
|
||||
if state is not None:
|
||||
state.update(
|
||||
{
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"downloaded": size,
|
||||
"total_size": expected_size or size,
|
||||
"speed": 0,
|
||||
}
|
||||
)
|
||||
if metadata_path:
|
||||
state["metadata_path"] = metadata_path
|
||||
|
||||
result.update(
|
||||
{
|
||||
"success": True,
|
||||
"size": size,
|
||||
"metadata_path": metadata_path,
|
||||
}
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
avg_speed = size / elapsed if elapsed > 0 else 0
|
||||
log.info(f"✓ Hugging Face Xet download complete: {filename}")
|
||||
log.info(
|
||||
f"Size: {format_bytes(size)}, Time: {elapsed:.1f}s, "
|
||||
f"Avg speed: {format_bytes(int(avg_speed))}/s"
|
||||
)
|
||||
invalidate_model_files_cache()
|
||||
invalidate_local_hash_match_cache()
|
||||
return result
|
||||
except Exception as exc:
|
||||
was_cancelled = (
|
||||
isinstance(exc, _HuggingFaceXetDownloadCancelled)
|
||||
or download_id in cancelled_downloads
|
||||
)
|
||||
try:
|
||||
if os.path.exists(partial_path):
|
||||
os.remove(partial_path)
|
||||
except Exception as cleanup_exc:
|
||||
log.warning(f"Could not delete incomplete Xet file: {cleanup_exc}")
|
||||
|
||||
error_msg = (
|
||||
"Download cancelled" if was_cancelled else _sanitize_download_error(exc)
|
||||
)
|
||||
with download_lock:
|
||||
state = download_progress.get(download_id)
|
||||
if state is not None:
|
||||
state.update(
|
||||
{
|
||||
"status": "cancelled" if was_cancelled else "error",
|
||||
"error": error_msg,
|
||||
"speed": 0,
|
||||
}
|
||||
)
|
||||
cancelled_downloads.discard(download_id)
|
||||
result["error"] = error_msg
|
||||
if was_cancelled:
|
||||
log.info(f"Hugging Face Xet download cancelled: {filename}")
|
||||
else:
|
||||
log.error(f"✗ Hugging Face Xet download failed: {filename}")
|
||||
log.error(f"Error: {error_msg}")
|
||||
return result
|
||||
|
||||
|
||||
def download_file_with_aria2(
|
||||
url: str,
|
||||
dest_path: str,
|
||||
@@ -1189,13 +1502,7 @@ def download_file_with_aria2(
|
||||
try:
|
||||
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||
_ensure_aria2_daemon(settings)
|
||||
aria2_url = _resolve_civitai_download_url_for_aria2(url, headers)
|
||||
aria2_headers = (
|
||||
None
|
||||
if aria2_url != url and _is_civitai_api_download_url(url)
|
||||
else headers
|
||||
)
|
||||
request_headers = build_download_headers(aria2_url, aria2_headers)
|
||||
aria2_url, request_headers = _resolve_download_url_for_aria2(url, headers)
|
||||
|
||||
options: Dict[str, Any] = {
|
||||
"dir": os.path.dirname(dest_path),
|
||||
@@ -1350,7 +1657,7 @@ def download_file_with_aria2(
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as exc:
|
||||
error_msg = str(exc)
|
||||
error_msg = _sanitize_download_error(exc)
|
||||
with download_lock:
|
||||
if download_id in download_progress:
|
||||
download_progress[download_id]["status"] = "error"
|
||||
@@ -1396,6 +1703,17 @@ def download_file(
|
||||
"""
|
||||
global download_progress, cancelled_downloads
|
||||
|
||||
xet_result = _download_huggingface_xet(
|
||||
url,
|
||||
dest_path,
|
||||
download_id,
|
||||
headers=headers,
|
||||
metadata=metadata,
|
||||
category=category,
|
||||
)
|
||||
if xet_result is not None:
|
||||
return xet_result
|
||||
|
||||
if _download_backend_from_settings() == "aria2":
|
||||
return download_file_with_aria2(
|
||||
url,
|
||||
@@ -1447,11 +1765,12 @@ def download_file(
|
||||
|
||||
# Verbose logging - what model and from where
|
||||
filename = get_filename_from_path(dest_path)
|
||||
source_host = urlparse(url).hostname
|
||||
source = (
|
||||
"HuggingFace"
|
||||
if "huggingface.co" in url
|
||||
if host_matches_domain(source_host, "huggingface.co")
|
||||
else "CivitAI"
|
||||
if "civitai.com" in url
|
||||
if host_matches_domain(source_host, "civitai.com", "civitai.red")
|
||||
else "URL"
|
||||
)
|
||||
log.info(f"Starting download: {filename}")
|
||||
@@ -1460,8 +1779,16 @@ def download_file(
|
||||
|
||||
# Start download
|
||||
request_headers = build_download_headers(url, headers)
|
||||
response = requests.get(url, headers=request_headers, stream=True, timeout=30)
|
||||
response, final_url, _final_headers = request_public_url(
|
||||
"GET",
|
||||
url,
|
||||
headers=request_headers,
|
||||
stream=True,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
if final_url != url:
|
||||
log.debug("Validated download redirect target")
|
||||
|
||||
# Get total size
|
||||
total_size = extract_response_file_size(response) or 0
|
||||
@@ -1607,7 +1934,7 @@ def download_file(
|
||||
invalidate_local_hash_match_cache()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
error_msg = str(e)
|
||||
error_msg = _sanitize_download_error(e)
|
||||
# Check for specific HTTP errors
|
||||
if hasattr(e, "response") and e.response is not None:
|
||||
status_code = e.response.status_code
|
||||
@@ -1640,7 +1967,7 @@ def download_file(
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
error_msg = _sanitize_download_error(e)
|
||||
with download_lock:
|
||||
download_progress[download_id]["status"] = "error"
|
||||
download_progress[download_id]["error"] = error_msg
|
||||
@@ -1690,6 +2017,12 @@ def download_model(
|
||||
"download_id": download_id,
|
||||
"error": "Invalid filename",
|
||||
}
|
||||
if not is_allowed_model_download_filename(filename):
|
||||
return {
|
||||
"success": False,
|
||||
"download_id": download_id,
|
||||
"error": "Unsupported model file extension",
|
||||
}
|
||||
subfolder = normalize_relative_subfolder(subfolder)
|
||||
|
||||
# Get destination directory
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
"""Network security helpers for outbound HTTP requests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import socket
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
REDIRECT_STATUS_CODES = {301, 302, 303, 307, 308}
|
||||
SENSITIVE_REDIRECT_HEADERS = {"authorization", "cookie", "proxy-authorization"}
|
||||
|
||||
|
||||
class UnsafeUrlError(ValueError):
|
||||
"""Raised when an outbound URL could access a non-public network target."""
|
||||
|
||||
|
||||
def host_matches_domain(host: Any, *domains: str) -> bool:
|
||||
"""Return True for an exact domain or one of its real DNS subdomains."""
|
||||
normalized_host = str(host or "").strip().lower().rstrip(".")
|
||||
if not normalized_host:
|
||||
return False
|
||||
for domain in domains:
|
||||
normalized_domain = str(domain or "").strip().lower().rstrip(".")
|
||||
if normalized_domain and (
|
||||
normalized_host == normalized_domain
|
||||
or normalized_host.endswith(f".{normalized_domain}")
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _is_public_ip(address: str) -> bool:
|
||||
try:
|
||||
parsed = ipaddress.ip_address(str(address).split("%", 1)[0])
|
||||
except ValueError:
|
||||
return False
|
||||
if isinstance(parsed, ipaddress.IPv6Address) and parsed.ipv4_mapped:
|
||||
parsed = parsed.ipv4_mapped
|
||||
return bool(parsed.is_global)
|
||||
|
||||
|
||||
def validate_public_http_url(url: Any) -> str:
|
||||
"""Validate an HTTP(S) URL and reject local, private, or reserved targets."""
|
||||
text = str(url or "").strip()
|
||||
if not text or len(text) > 8192:
|
||||
raise UnsafeUrlError("A valid download URL is required")
|
||||
|
||||
try:
|
||||
parsed = urlparse(text)
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise UnsafeUrlError("The download URL is malformed") from exc
|
||||
|
||||
if parsed.scheme.lower() not in {"http", "https"}:
|
||||
raise UnsafeUrlError("Only http and https download URLs are supported")
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise UnsafeUrlError("Credentials embedded in download URLs are not allowed")
|
||||
|
||||
host = str(parsed.hostname or "").strip().lower().rstrip(".")
|
||||
if not host:
|
||||
raise UnsafeUrlError("The download URL must include a hostname")
|
||||
if host_matches_domain(host, "localhost"):
|
||||
raise UnsafeUrlError("Localhost download URLs are not allowed")
|
||||
|
||||
try:
|
||||
direct_ip = ipaddress.ip_address(host.split("%", 1)[0])
|
||||
except ValueError:
|
||||
direct_ip = None
|
||||
|
||||
if direct_ip is not None:
|
||||
if not _is_public_ip(str(direct_ip)):
|
||||
raise UnsafeUrlError("Private, local, and reserved download addresses are not allowed")
|
||||
return text
|
||||
|
||||
try:
|
||||
ascii_host = host.encode("idna").decode("ascii")
|
||||
addresses = {
|
||||
item[4][0]
|
||||
for item in socket.getaddrinfo(
|
||||
ascii_host,
|
||||
port or (443 if parsed.scheme.lower() == "https" else 80),
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
if item and len(item) > 4 and item[4]
|
||||
}
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise UnsafeUrlError("The download hostname could not be resolved") from exc
|
||||
|
||||
if not addresses:
|
||||
raise UnsafeUrlError("The download hostname did not resolve to an address")
|
||||
if any(not _is_public_ip(address) for address in addresses):
|
||||
raise UnsafeUrlError("Private, local, and reserved download addresses are not allowed")
|
||||
return text
|
||||
|
||||
|
||||
def _url_origin(url: str) -> Tuple[str, str, int]:
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
port = parsed.port or (443 if scheme == "https" else 80)
|
||||
return scheme, str(parsed.hostname or "").lower().rstrip("."), port
|
||||
|
||||
|
||||
def _redirect_headers(headers: Dict[str, str], source_url: str, target_url: str) -> Dict[str, str]:
|
||||
if _url_origin(source_url) == _url_origin(target_url):
|
||||
return dict(headers)
|
||||
return {
|
||||
key: value
|
||||
for key, value in headers.items()
|
||||
if str(key).lower() not in SENSITIVE_REDIRECT_HEADERS
|
||||
}
|
||||
|
||||
|
||||
def request_public_url(
|
||||
method: str,
|
||||
url: Any,
|
||||
*,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: Any = 30,
|
||||
stream: bool = True,
|
||||
max_redirects: int = 5,
|
||||
) -> Tuple[requests.Response, str, Dict[str, str]]:
|
||||
"""Perform a GET/HEAD request while validating every redirect target."""
|
||||
current_url = validate_public_http_url(url)
|
||||
current_headers = dict(headers or {})
|
||||
request_method = str(method or "GET").upper()
|
||||
if request_method not in {"GET", "HEAD"}:
|
||||
raise ValueError("request_public_url supports only GET and HEAD")
|
||||
|
||||
for redirect_count in range(max_redirects + 1):
|
||||
request_func = requests.head if request_method == "HEAD" else requests.get
|
||||
response = request_func(
|
||||
current_url,
|
||||
headers=current_headers,
|
||||
allow_redirects=False,
|
||||
stream=stream,
|
||||
timeout=timeout,
|
||||
)
|
||||
if response.status_code not in REDIRECT_STATUS_CODES:
|
||||
return response, current_url, current_headers
|
||||
|
||||
location = response.headers.get("Location") or response.headers.get("location")
|
||||
if not location:
|
||||
return response, current_url, current_headers
|
||||
if redirect_count >= max_redirects:
|
||||
response.close()
|
||||
raise UnsafeUrlError("The download URL has too many redirects")
|
||||
|
||||
candidate_url = urljoin(current_url, str(location).strip())
|
||||
try:
|
||||
next_url = validate_public_http_url(candidate_url)
|
||||
except UnsafeUrlError:
|
||||
response.close()
|
||||
raise
|
||||
next_headers = _redirect_headers(current_headers, current_url, next_url)
|
||||
response.close()
|
||||
current_url = next_url
|
||||
current_headers = next_headers
|
||||
|
||||
raise UnsafeUrlError("The download URL has too many redirects")
|
||||
@@ -14,6 +14,7 @@ from urllib.parse import parse_qs, unquote, urljoin, urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from ..network_utils import host_matches_domain
|
||||
from ..matcher import (
|
||||
calculate_archived_model_confidence,
|
||||
calculate_model_title_confidence,
|
||||
@@ -481,7 +482,7 @@ def parse_civarchive_url(url: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
parsed = urlparse(urljoin(CIVARCHIVE_BASE_URL, url))
|
||||
if parsed.netloc and "civarchive.com" not in parsed.netloc:
|
||||
if parsed.hostname and not host_matches_domain(parsed.hostname, "civarchive.com"):
|
||||
return None
|
||||
|
||||
sha_match = re.search(r"/sha256/([a-fA-F0-9]{64})", parsed.path)
|
||||
@@ -573,18 +574,18 @@ def _prepare_size_probe_url(url: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
parsed = urlparse(normalized)
|
||||
host = parsed.netloc.lower()
|
||||
host = parsed.hostname
|
||||
path = parsed.path or ""
|
||||
|
||||
if host.endswith("huggingface.co") and "/blob/" in path:
|
||||
if host_matches_domain(host, "huggingface.co") and "/blob/" in path:
|
||||
normalized = normalized.replace("/blob/", "/resolve/", 1)
|
||||
parsed = urlparse(normalized)
|
||||
host = parsed.netloc.lower()
|
||||
host = parsed.hostname
|
||||
path = parsed.path or ""
|
||||
|
||||
if host.endswith("civarchive.com") and not path.startswith("/api/download/"):
|
||||
if host_matches_domain(host, "civarchive.com") and not path.startswith("/api/download/"):
|
||||
return None
|
||||
if (host.endswith("civitai.com") or host.endswith("civitai.red")) and not path.startswith("/api/download/"):
|
||||
if host_matches_domain(host, "civitai.com", "civitai.red") and not path.startswith("/api/download/"):
|
||||
return None
|
||||
|
||||
return normalized
|
||||
|
||||
@@ -18,6 +18,7 @@ from ..log_system import create_module_logger
|
||||
log = create_module_logger(__name__)
|
||||
|
||||
from ..path_utils import write_json_atomic, METADATA_DIR, read_json_safe
|
||||
from ..network_utils import host_matches_domain
|
||||
from ..type_utils import check_credential_http, parse_size_header as _parse_size_header, fetch_remote_file_size, fetch_remote_file_size_cached, clear_remote_size_cache, extract_file_size
|
||||
from ..matcher import build_filename_search_queries, clean_filename_for_search
|
||||
|
||||
@@ -302,7 +303,7 @@ def parse_huggingface_url(url: str) -> Optional[Dict[str, str]]:
|
||||
return None
|
||||
|
||||
parsed = urlparse(url)
|
||||
if "huggingface.co" not in parsed.netloc:
|
||||
if not host_matches_domain(parsed.hostname, "huggingface.co"):
|
||||
return None
|
||||
|
||||
match = re.match(r"^/([^/]+/[^/]+)/(resolve|blob)/([^/]+)/(.+)$", parsed.path)
|
||||
@@ -332,7 +333,7 @@ def _normalize_huggingface_size_probe_url(url: str) -> Optional[str]:
|
||||
return None
|
||||
value = url.strip()
|
||||
parsed = urlparse(value)
|
||||
if "huggingface.co" not in parsed.netloc:
|
||||
if not host_matches_domain(parsed.hostname, "huggingface.co"):
|
||||
return None
|
||||
if "/blob/" in parsed.path:
|
||||
value = value.replace("/blob/", "/resolve/", 1)
|
||||
|
||||
+11
-10
@@ -559,15 +559,15 @@ def fetch_remote_file_size(
|
||||
"""
|
||||
Check a remote file size using HEAD and, when needed, GET Range=bytes=0-0.
|
||||
"""
|
||||
import requests
|
||||
from .network_utils import request_public_url
|
||||
request_headers = {**(headers or {}), "Accept-Encoding": "identity"}
|
||||
|
||||
size = None
|
||||
try:
|
||||
response = requests.head(
|
||||
response, _final_url, _final_headers = request_public_url(
|
||||
"HEAD",
|
||||
url,
|
||||
headers=request_headers,
|
||||
allow_redirects=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
try:
|
||||
@@ -580,10 +580,10 @@ def fetch_remote_file_size(
|
||||
|
||||
if not size:
|
||||
try:
|
||||
response = requests.get(
|
||||
response, _final_url, _final_headers = request_public_url(
|
||||
"GET",
|
||||
url,
|
||||
headers={**request_headers, "Range": "bytes=0-0"},
|
||||
allow_redirects=True,
|
||||
stream=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -604,26 +604,27 @@ def looks_like_model_file(url: str, expected_filename: str = "") -> bool:
|
||||
"""
|
||||
import os
|
||||
from urllib.parse import unquote, urlparse
|
||||
from .network_utils import host_matches_domain
|
||||
|
||||
text = str(url or "").strip()
|
||||
if not text.startswith(("http://", "https://", "hf://")):
|
||||
return False
|
||||
try:
|
||||
parsed = urlparse(text)
|
||||
host = parsed.netloc.lower()
|
||||
host = parsed.hostname
|
||||
path = unquote(parsed.path or "")
|
||||
except Exception:
|
||||
host = ""
|
||||
path = text
|
||||
|
||||
if host.endswith("huggingface.co") and path.startswith("/spaces/"):
|
||||
if host_matches_domain(host, "huggingface.co") and path.startswith("/spaces/"):
|
||||
return False
|
||||
|
||||
# Civitai/CivArchive api/download url prefix check
|
||||
if (
|
||||
host.endswith("civitai.com")
|
||||
or host.endswith("civitai.red")
|
||||
or host.endswith("civarchive.com")
|
||||
host_matches_domain(host, "civitai.com")
|
||||
or host_matches_domain(host, "civitai.red")
|
||||
or host_matches_domain(host, "civarchive.com")
|
||||
) and "/api/download/" in path:
|
||||
return True
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ export const optionsMethods = {
|
||||
<div class="mr-options-dependent-block mr-options-aria2-setting">
|
||||
<div class="mr-options-number-row mr-options-wide-row">
|
||||
<div class="mr-options-number-copy">
|
||||
<span class="mr-options-label">aria2c path <span class="mr-tooltip-badge" data-tooltip="Optional full path to aria2c/aria2c.exe. Leave empty to use aria2c from your system PATH.">?</span></span>
|
||||
<span class="mr-options-label">aria2c path <span class="mr-tooltip-badge" data-tooltip="Only paths created by the built-in installer are accepted. Leave empty to use aria2c from your system PATH.">?</span></span>
|
||||
</div>
|
||||
<input id="mr-options-aria2c-path" class="mr-options-input" type="text" value="${this.escapeHtml(tokens.aria2c_path || '')}" placeholder="Leave empty to use aria2c from PATH">
|
||||
</div>
|
||||
@@ -2269,7 +2269,7 @@ export const optionsMethods = {
|
||||
if (data?.available) {
|
||||
setAria2Status('');
|
||||
} else {
|
||||
setAria2Status(data?.error || 'aria2c was not found. Install aria2 or configure the aria2c path.', 'is-invalid');
|
||||
setAria2Status(data?.error || 'aria2c was not found. Use the built-in installer or add aria2c to PATH.', 'is-invalid');
|
||||
}
|
||||
} catch (error) {
|
||||
setAria2Status(error.message || 'aria2 check failed.', 'is-invalid');
|
||||
|
||||
Reference in New Issue
Block a user