Add dashboard file browser paths
This commit is contained in:
@@ -20,9 +20,11 @@ import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -657,6 +659,21 @@ class AudioTranscriptionRequest(BaseModel):
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
|
||||
class ManagedFileUpload(BaseModel):
|
||||
path: str
|
||||
data_url: str
|
||||
overwrite: bool = True
|
||||
|
||||
|
||||
class ManagedDirectoryCreate(BaseModel):
|
||||
path: str
|
||||
|
||||
|
||||
class ManagedFileDelete(BaseModel):
|
||||
path: str
|
||||
recursive: bool = False
|
||||
|
||||
|
||||
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
|
||||
"audio/aac": ".aac",
|
||||
"audio/flac": ".flac",
|
||||
@@ -819,6 +836,16 @@ _MEDIA_CONTENT_TYPES = {
|
||||
".ico": "image/x-icon",
|
||||
}
|
||||
_MEDIA_MAX_BYTES = 25 * 1024 * 1024
|
||||
_MANAGED_FILES_ROOT_ENV = "HERMES_DASHBOARD_FILES_ROOT"
|
||||
_MANAGED_FILE_MAX_BYTES = 100 * 1024 * 1024
|
||||
_HOSTED_MANAGED_FILES_ROOT = Path("/opt/data")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManagedFilesPolicy:
|
||||
default_path: Path
|
||||
locked_root: Path | None
|
||||
can_change_path: bool
|
||||
|
||||
|
||||
def _media_serve_roots() -> list[Path]:
|
||||
@@ -874,6 +901,297 @@ async def get_media(path: str):
|
||||
return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"}
|
||||
|
||||
|
||||
def _canonical_path(path: Path, *, require_exists: bool = False) -> Path:
|
||||
try:
|
||||
return path.expanduser().resolve(strict=require_exists)
|
||||
except FileNotFoundError:
|
||||
if require_exists:
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
raise
|
||||
except (OSError, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
|
||||
|
||||
def _ensure_managed_root(raw_path: str | Path) -> Path:
|
||||
root = Path(raw_path).expanduser()
|
||||
try:
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
resolved = root.resolve()
|
||||
except (OSError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Managed files root is unavailable: {exc}")
|
||||
if not resolved.is_dir():
|
||||
raise HTTPException(status_code=500, detail="Managed files root is not a directory")
|
||||
return resolved
|
||||
|
||||
|
||||
def _path_is_under(root: Path, target: Path) -> bool:
|
||||
return target == root or root in target.parents
|
||||
|
||||
|
||||
def _path_text(raw_path: str | None) -> str:
|
||||
text = str(raw_path or "").strip()
|
||||
if "\x00" in text:
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
return text
|
||||
|
||||
|
||||
def _local_dashboard_request(request: Request) -> bool:
|
||||
if getattr(request.app.state, "auth_required", False):
|
||||
return False
|
||||
host = (request.url.hostname or "").lower()
|
||||
client_host = (request.client.host if request.client else "").lower()
|
||||
local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"}
|
||||
return host in local_hosts or client_host in local_hosts
|
||||
|
||||
|
||||
def _default_hermes_root_is_opt_data() -> bool:
|
||||
raw = os.environ.get("HERMES_HOME", "").strip()
|
||||
if not raw:
|
||||
return False
|
||||
try:
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
root = get_default_hermes_root().expanduser().resolve(strict=False)
|
||||
except (OSError, RuntimeError):
|
||||
root = Path(raw).expanduser().resolve(strict=False)
|
||||
return root == _HOSTED_MANAGED_FILES_ROOT
|
||||
|
||||
|
||||
def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy:
|
||||
raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip()
|
||||
if raw_forced_root:
|
||||
root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root))
|
||||
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
|
||||
|
||||
if not _local_dashboard_request(request) or _default_hermes_root_is_opt_data():
|
||||
root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT
|
||||
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
|
||||
|
||||
home = _canonical_path(Path.home())
|
||||
return ManagedFilesPolicy(default_path=home, locked_root=None, can_change_path=True)
|
||||
|
||||
|
||||
def _resolve_managed_path(
|
||||
raw_path: str | None,
|
||||
request: Request,
|
||||
*,
|
||||
for_write: bool = False,
|
||||
) -> tuple[ManagedFilesPolicy, Path, str]:
|
||||
policy = _managed_files_policy(request)
|
||||
text = _path_text(raw_path)
|
||||
root = policy.locked_root
|
||||
|
||||
if root is not None and (not text or text in {".", "/"}):
|
||||
candidate = root
|
||||
elif not text:
|
||||
candidate = policy.default_path
|
||||
else:
|
||||
candidate = Path(text).expanduser()
|
||||
if root is not None and not candidate.is_absolute():
|
||||
if any(part == ".." for part in candidate.parts):
|
||||
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
|
||||
candidate = root / candidate
|
||||
elif not candidate.is_absolute():
|
||||
raise HTTPException(status_code=400, detail="Path must be absolute")
|
||||
|
||||
if ".." in candidate.parts:
|
||||
raise HTTPException(status_code=400, detail="Path cannot contain '..'")
|
||||
|
||||
if for_write and not candidate.exists():
|
||||
parent = _canonical_path(candidate.parent)
|
||||
resolved = parent / candidate.name
|
||||
else:
|
||||
resolved = _canonical_path(candidate, require_exists=not for_write)
|
||||
|
||||
if root is not None and not _path_is_under(root, resolved):
|
||||
raise HTTPException(status_code=403, detail="Path outside managed files root")
|
||||
|
||||
return policy, resolved, str(resolved)
|
||||
|
||||
|
||||
def _managed_response_meta(policy: ManagedFilesPolicy) -> Dict[str, Any]:
|
||||
locked_root = str(policy.locked_root) if policy.locked_root is not None else None
|
||||
return {
|
||||
"root": locked_root,
|
||||
"locked_root": locked_root,
|
||||
"can_change_path": policy.can_change_path,
|
||||
}
|
||||
|
||||
|
||||
def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]:
|
||||
try:
|
||||
resolved = target.resolve()
|
||||
except (OSError, RuntimeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid path")
|
||||
if policy.locked_root is not None and not _path_is_under(policy.locked_root, resolved):
|
||||
raise HTTPException(status_code=403, detail="Path outside managed files root")
|
||||
|
||||
try:
|
||||
st = resolved.stat()
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not stat path: {exc}")
|
||||
|
||||
is_dir = resolved.is_dir()
|
||||
mime_type = None if is_dir else (mimetypes.guess_type(resolved.name)[0] or "application/octet-stream")
|
||||
return {
|
||||
"name": target.name or resolved.name or str(resolved),
|
||||
"path": str(resolved),
|
||||
"is_directory": is_dir,
|
||||
"size": None if is_dir else st.st_size,
|
||||
"mtime": st.st_mtime,
|
||||
"mime_type": mime_type,
|
||||
}
|
||||
|
||||
|
||||
def _decode_data_url(data_url: str) -> tuple[bytes, str]:
|
||||
text = (data_url or "").strip()
|
||||
if not text.startswith("data:") or "," not in text:
|
||||
raise HTTPException(status_code=400, detail="Upload payload must be a data URL")
|
||||
header, encoded = text.split(",", 1)
|
||||
mime_type = header[5:].split(";", 1)[0] or "application/octet-stream"
|
||||
if ";base64" not in header:
|
||||
raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded")
|
||||
try:
|
||||
data = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError):
|
||||
raise HTTPException(status_code=400, detail="Upload payload is not valid base64")
|
||||
if len(data) > _MANAGED_FILE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
return data, mime_type
|
||||
|
||||
|
||||
@app.get("/api/files")
|
||||
async def list_managed_files(request: Request, path: Optional[str] = None):
|
||||
policy, target, display_path = _resolve_managed_path(path, request)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
if not target.is_dir():
|
||||
raise HTTPException(status_code=400, detail="Path is not a directory")
|
||||
|
||||
try:
|
||||
entries = [_managed_file_entry(policy, child) for child in target.iterdir()]
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Directory is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}")
|
||||
|
||||
entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower()))
|
||||
locked_root = policy.locked_root
|
||||
parent = None
|
||||
if target.parent != target and (locked_root is None or target != locked_root):
|
||||
parent = str(target.parent)
|
||||
return {
|
||||
"path": display_path,
|
||||
"parent": parent,
|
||||
"entries": entries,
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/files/read")
|
||||
async def read_managed_file(request: Request, path: str):
|
||||
policy, target, display_path = _resolve_managed_path(path, request)
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
if not target.is_file():
|
||||
raise HTTPException(status_code=400, detail="Path is not a file")
|
||||
|
||||
try:
|
||||
size = target.stat().st_size
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}")
|
||||
if size > _MANAGED_FILE_MAX_BYTES:
|
||||
raise HTTPException(status_code=413, detail="File is too large")
|
||||
|
||||
mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream"
|
||||
try:
|
||||
encoded = base64.b64encode(target.read_bytes()).decode("ascii")
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not readable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not read file: {exc}")
|
||||
|
||||
return {
|
||||
"name": target.name,
|
||||
"path": display_path,
|
||||
"size": size,
|
||||
"mime_type": mime_type,
|
||||
"data_url": f"data:{mime_type};base64,{encoded}",
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/files/upload")
|
||||
async def upload_managed_file(payload: ManagedFileUpload, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
||||
if target.exists() and target.is_dir():
|
||||
raise HTTPException(status_code=409, detail="A directory already exists at that path")
|
||||
if target.exists() and not payload.overwrite:
|
||||
raise HTTPException(status_code=409, detail="File already exists")
|
||||
|
||||
data, _mime_type = _decode_data_url(payload.data_url)
|
||||
try:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(data)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="File is not writable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not write file: {exc}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"entry": _managed_file_entry(policy, target),
|
||||
"path": display_path,
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/files/mkdir")
|
||||
async def create_managed_directory(payload: ManagedDirectoryCreate, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request, for_write=True)
|
||||
if target.exists() and not target.is_dir():
|
||||
raise HTTPException(status_code=409, detail="A file already exists at that path")
|
||||
|
||||
try:
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
except PermissionError:
|
||||
raise HTTPException(status_code=403, detail="Directory is not writable")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Could not create directory: {exc}")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"entry": _managed_file_entry(policy, target),
|
||||
"path": display_path,
|
||||
**_managed_response_meta(policy),
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/files")
|
||||
async def delete_managed_file(payload: ManagedFileDelete, request: Request):
|
||||
policy, target, display_path = _resolve_managed_path(payload.path, request)
|
||||
if policy.locked_root is not None and target == policy.locked_root:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the managed files root")
|
||||
if target.parent == target:
|
||||
raise HTTPException(status_code=400, detail="Cannot delete the filesystem root")
|
||||
if not target.exists():
|
||||
raise HTTPException(status_code=404, detail="Path not found")
|
||||
|
||||
try:
|
||||
if target.is_dir():
|
||||
if payload.recursive:
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.rmdir()
|
||||
else:
|
||||
target.unlink()
|
||||
except OSError as exc:
|
||||
status_code = 409 if target.is_dir() and not payload.recursive else 500
|
||||
raise HTTPException(status_code=status_code, detail=f"Could not delete path: {exc}")
|
||||
|
||||
return {"ok": True, "path": display_path, **_managed_response_meta(policy)}
|
||||
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
current_ver, latest_ver = check_config_version()
|
||||
|
||||
Reference in New Issue
Block a user