feat(extensions): add gallery browse, install, and uninstall (#4746)
This commit is contained in:
1 parent
ed5a217ae1
commit
ddd5c8d936
8 files changed
+930
-11
No files matched your search
@@ -14,6 +14,11 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from urllib.parse import unquote, urlsplit
|
||||
import hashlib
|
||||
import io
|
||||
import time
|
||||
import zipfile
|
||||
from urllib.request import urlopen
|
||||
|
||||
from api.helpers import _security_headers, j
|
||||
|
||||
@@ -46,6 +51,14 @@ class ExtensionToggleError(Exception):
|
||||
self.status = status
|
||||
|
||||
|
||||
class ExtensionInstallError(Exception):
|
||||
"""Sanitized extension install/uninstall error safe to return to the browser."""
|
||||
|
||||
def __init__(self, message: str, status: int = 400):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
EXTENSION_ROUTE_PREFIX = "/extensions/"
|
||||
_EXTENSION_DIR_ENV = "HERMES_WEBUI_EXTENSION_DIR"
|
||||
_EXTENSION_SCRIPT_URLS_ENV = "HERMES_WEBUI_EXTENSION_SCRIPT_URLS"
|
||||
@@ -62,6 +75,15 @@ _EXTENSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
_EXTENSION_STATE_WARNING_SOURCE = "extension_state"
|
||||
_EXTENSION_STATE_LOCK = threading.Lock()
|
||||
|
||||
_GALLERY_INSTALL_STATE_FILENAME = "extension-install-manifest.json"
|
||||
_MAX_INSTALL_MANIFEST_BYTES = 128 * 1024
|
||||
_MAX_GALLERY_INSTALLED_IDS = 256
|
||||
_MAX_ZIP_DOWNLOAD_BYTES = 32 * 1024 * 1024
|
||||
_REGISTRY_URL = "https://hermes-webui.github.io/hermes-webui-extensions/registry.json"
|
||||
_REGISTRY_ALLOWED_DOWNLOAD_HOST = "hermes-webui.github.io"
|
||||
_REGISTRY_CACHE: dict = {}
|
||||
_REGISTRY_TTL_SECONDS = 300
|
||||
|
||||
_EXTENSION_MIME = {
|
||||
"css": "text/css",
|
||||
"js": "application/javascript",
|
||||
@@ -845,6 +867,7 @@ def get_extension_status() -> Dict[str, Any]:
|
||||
},
|
||||
"manifest": manifest_status,
|
||||
"extensions": extensions,
|
||||
"gallery_installed": _load_install_manifest().get("installed", {}),
|
||||
"warnings": diagnostics["warnings"],
|
||||
}
|
||||
|
||||
@@ -885,6 +908,225 @@ def set_extension_user_enabled(extension_id: object, enabled: object) -> Dict[st
|
||||
return get_extension_status()
|
||||
|
||||
|
||||
def _install_manifest_file() -> Path:
|
||||
return _extension_state_dir() / _GALLERY_INSTALL_STATE_FILENAME
|
||||
|
||||
|
||||
def _empty_install_manifest() -> Dict[str, Any]:
|
||||
return {"version": 1, "installed": {}}
|
||||
|
||||
|
||||
def _load_install_manifest() -> Dict[str, Any]:
|
||||
"""Load gallery install manifest, failing safe on any error."""
|
||||
mfile = _install_manifest_file()
|
||||
try:
|
||||
if not mfile.exists() or not mfile.is_file():
|
||||
return _empty_install_manifest()
|
||||
with mfile.open("rb") as fh:
|
||||
raw = fh.read(_MAX_INSTALL_MANIFEST_BYTES + 1)
|
||||
if len(raw) > _MAX_INSTALL_MANIFEST_BYTES:
|
||||
return _empty_install_manifest()
|
||||
parsed = json.loads(raw.decode("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError, RecursionError):
|
||||
return _empty_install_manifest()
|
||||
if not isinstance(parsed, dict) or not isinstance(parsed.get("installed"), dict):
|
||||
return _empty_install_manifest()
|
||||
installed: Dict[str, Any] = {}
|
||||
for ext_id, entry in parsed["installed"].items():
|
||||
if not _valid_extension_id(ext_id):
|
||||
continue
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
files = entry.get("files", [])
|
||||
if not isinstance(files, list):
|
||||
continue
|
||||
installed[ext_id] = {
|
||||
"version": str(entry.get("version", "unknown")),
|
||||
"files": [f for f in files if isinstance(f, str)],
|
||||
"installed_at": str(entry.get("installed_at", "")),
|
||||
}
|
||||
if len(installed) >= _MAX_GALLERY_INSTALLED_IDS:
|
||||
break
|
||||
return {"version": 1, "installed": installed}
|
||||
|
||||
|
||||
def _write_install_manifest(manifest: Dict[str, Any]) -> None:
|
||||
"""Persist install manifest with atomic same-directory replace."""
|
||||
target = _install_manifest_file()
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = target.with_name(f".{target.name}.{os.getpid()}.{threading.get_ident()}.tmp")
|
||||
data = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
try:
|
||||
with tmp.open("wb") as fh:
|
||||
fh.write(data)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, target)
|
||||
finally:
|
||||
try:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def install_extension(id: object, download_url: object, sha256: object) -> Dict[str, Any]:
|
||||
"""Download, verify, and extract a gallery extension."""
|
||||
if not _valid_extension_id(id):
|
||||
raise ExtensionInstallError("Invalid extension id")
|
||||
ext_id = str(id).strip()
|
||||
if not isinstance(download_url, str) or not download_url.startswith("https://"):
|
||||
raise ExtensionInstallError("Invalid download URL")
|
||||
parsed_url = urlsplit(download_url)
|
||||
if parsed_url.hostname != _REGISTRY_ALLOWED_DOWNLOAD_HOST:
|
||||
raise ExtensionInstallError("Invalid download URL")
|
||||
if not isinstance(sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", sha256):
|
||||
raise ExtensionInstallError("Invalid sha256")
|
||||
root = _extension_root()
|
||||
if root is None:
|
||||
raise ExtensionInstallError("Extensions not configured", 404)
|
||||
try:
|
||||
raw_data = urlopen(download_url, timeout=30).read(_MAX_ZIP_DOWNLOAD_BYTES + 1)
|
||||
except Exception as exc:
|
||||
raise ExtensionInstallError("Download failed", 502) from exc
|
||||
if len(raw_data) > _MAX_ZIP_DOWNLOAD_BYTES:
|
||||
raise ExtensionInstallError("Download too large")
|
||||
if hashlib.sha256(raw_data).hexdigest() != sha256:
|
||||
raise ExtensionInstallError("SHA-256 mismatch")
|
||||
try:
|
||||
zf = zipfile.ZipFile(io.BytesIO(raw_data))
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise ExtensionInstallError("Invalid zip archive") from exc
|
||||
ext_dir = root / ext_id
|
||||
member_names = zf.namelist()
|
||||
total_uncompressed = sum(info.file_size for info in zf.infolist() if not info.is_dir())
|
||||
if total_uncompressed > _MAX_ZIP_DOWNLOAD_BYTES * 10:
|
||||
raise ExtensionInstallError("Archive uncompressed size exceeds limit")
|
||||
if len([n for n in member_names if n and not n.endswith("/")]) > 1024:
|
||||
raise ExtensionInstallError("Archive contains too many files")
|
||||
for member_name in member_names:
|
||||
if not member_name or member_name.endswith("/"):
|
||||
continue
|
||||
decoded = _fully_unquote_path(member_name)
|
||||
if not _is_safe_relative_path(decoded):
|
||||
raise ExtensionInstallError("Unsafe archive member")
|
||||
resolved = (ext_dir / decoded).resolve()
|
||||
try:
|
||||
resolved.relative_to(ext_dir.resolve())
|
||||
except ValueError as exc:
|
||||
raise ExtensionInstallError("Zip-slip detected") from exc
|
||||
# Determine version from manifest.json in zip if present
|
||||
version = "unknown"
|
||||
if "manifest.json" in member_names:
|
||||
try:
|
||||
mdata = json.loads(zf.read("manifest.json").decode("utf-8"))
|
||||
if isinstance(mdata, dict) and isinstance(mdata.get("version"), str):
|
||||
version = mdata["version"]
|
||||
except Exception:
|
||||
pass
|
||||
ext_dir.mkdir(parents=True, exist_ok=True)
|
||||
rollback: List[Path] = []
|
||||
try:
|
||||
for member_name in member_names:
|
||||
if not member_name or member_name.endswith("/"):
|
||||
continue
|
||||
decoded = _fully_unquote_path(member_name)
|
||||
dest = (ext_dir / decoded).resolve()
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(zf.read(member_name))
|
||||
rollback.append(dest)
|
||||
except Exception as exc:
|
||||
for path in rollback:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if ext_dir.exists() and not any(ext_dir.iterdir()):
|
||||
ext_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
raise ExtensionInstallError("Extraction failed", 500) from exc
|
||||
try:
|
||||
with _EXTENSION_STATE_LOCK:
|
||||
manifest = _load_install_manifest()
|
||||
from datetime import datetime, timezone
|
||||
manifest["installed"][ext_id] = {
|
||||
"version": version,
|
||||
"files": [str(p.relative_to(ext_dir)) for p in rollback],
|
||||
"installed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
_write_install_manifest(manifest)
|
||||
except Exception as exc:
|
||||
for path in rollback:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if ext_dir.exists() and not any(ext_dir.iterdir()):
|
||||
ext_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
raise ExtensionInstallError("Failed to record install", 500) from exc
|
||||
return {"installed": True, "id": ext_id, "version": version}
|
||||
|
||||
|
||||
def uninstall_extension(id: object) -> Dict[str, Any]:
|
||||
"""Remove a gallery-installed extension's files and manifest entry."""
|
||||
if not _valid_extension_id(id):
|
||||
raise ExtensionInstallError("Invalid extension id")
|
||||
ext_id = str(id).strip()
|
||||
root = _extension_root()
|
||||
if root is None:
|
||||
raise ExtensionInstallError("Extensions not configured", 404)
|
||||
with _EXTENSION_STATE_LOCK:
|
||||
manifest = _load_install_manifest()
|
||||
entry = manifest["installed"].get(ext_id)
|
||||
if entry is None:
|
||||
raise ExtensionInstallError("Extension not installed", 404)
|
||||
ext_dir = root / ext_id
|
||||
for rel_path in entry.get("files", []):
|
||||
if not _is_safe_relative_path(rel_path):
|
||||
continue
|
||||
target = (ext_dir / rel_path).resolve()
|
||||
try:
|
||||
target.relative_to(ext_dir.resolve())
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
target.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
if ext_dir.exists() and not any(ext_dir.iterdir()):
|
||||
ext_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
del manifest["installed"][ext_id]
|
||||
_write_install_manifest(manifest)
|
||||
return {"uninstalled": True, "id": ext_id}
|
||||
|
||||
|
||||
def get_extension_registry() -> Dict[str, Any]:
|
||||
"""Fetch the extension registry with a 5-minute TTL cache."""
|
||||
now = time.monotonic()
|
||||
cached = _REGISTRY_CACHE.get("data")
|
||||
cached_at = _REGISTRY_CACHE.get("fetched_at", 0.0)
|
||||
if cached is not None and (now - cached_at) < _REGISTRY_TTL_SECONDS:
|
||||
return {"entries": cached}
|
||||
try:
|
||||
raw = urlopen(_REGISTRY_URL, timeout=10).read(2 * 1024 * 1024)
|
||||
data = json.loads(raw.decode("utf-8"))
|
||||
if not isinstance(data, list):
|
||||
data = data.get("entries", []) if isinstance(data, dict) else []
|
||||
_REGISTRY_CACHE["data"] = data
|
||||
_REGISTRY_CACHE["fetched_at"] = now
|
||||
return {"entries": data}
|
||||
except Exception:
|
||||
return {"entries": [], "error": "registry_unavailable"}
|
||||
|
||||
|
||||
def inject_extension_tags(index_html: str) -> str:
|
||||
"""Inject configured extension tags into the app shell.
|
||||
|
||||
|
||||
Reference in new issue
Block a user