From e3313fb4acd022e4560ecc34bf0e128b0df50c56 Mon Sep 17 00:00:00 2001 From: menzelj Date: Mon, 8 Jun 2026 10:33:38 +0000 Subject: [PATCH] Phase 12: file browser (0.12.0) Add a full host filesystem browser reachable from the sidebar (/files): breadcrumb navigation, browse-root chips, show-hidden toggle, and a table with size/permissions/mtime. Text files open in a Monaco editor (language by extension); binary/oversized files fall back to download. Admins can create folders/files, rename, delete (recursive for dirs), upload, and save edits; download is available to all users. Every mutation is audit-logged. Backend: new services/file_service.py reuses device_service's sandbox helpers (confined to ALLOWED_BROWSE_ROOTS, mapped via HOST_ROOT_PREFIX) and rejects path traversal and deleting a browse root. routers/files.py exposes /api/files/{list,read,download,write,mkdir,touch,rename,upload,DELETE} (reads: any user; mutations: admin). device_service.browse entries gained mtime + symlink (non-breaking). Deployment: ALLOWED_BROWSE_ROOTS + HOST_ROOT_PREFIX are now env-wired in docker-compose.yml and .env.example, with a commented /:/host_root mount to browse/manage the real host filesystem. Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 + .gitignore | 3 + README.md | 19 +- backend/agent_app.py | 2 +- backend/main.py | 4 +- backend/routers/files.py | 194 ++++++++ backend/services/device_service.py | 2 + backend/services/file_service.py | 188 ++++++++ docker-compose.yml | 9 + frontend/package.json | 2 +- frontend/src/App.tsx | 2 + frontend/src/api/files.ts | 60 +++ frontend/src/components/layout/Sidebar.tsx | 2 + frontend/src/pages/Files.tsx | 510 +++++++++++++++++++++ frontend/src/types/index.ts | 2 + 15 files changed, 1004 insertions(+), 4 deletions(-) create mode 100644 backend/routers/files.py create mode 100644 backend/services/file_service.py create mode 100644 frontend/src/api/files.ts create mode 100644 frontend/src/pages/Files.tsx diff --git a/.env.example b/.env.example index 18f5740..8ade19c 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,12 @@ NOTIFY_WEBHOOKS= # Throwaway image used to read/write named-volume contents during backups. BACKUP_HELPER_IMAGE=alpine:latest + +# File browser (sidebar) + volume host-path picker. +# ALLOWED_BROWSE_ROOTS: comma-separated paths the browser may reach (sandbox). +# HOST_ROOT_PREFIX: where the host filesystem is mounted inside the backend +# container. Leave empty to browse the container's own filesystem. To browse +# the real host, uncomment the "/:/host_root" volume in docker-compose.yml and +# set HOST_ROOT_PREFIX=/host_root here (mount without :ro to allow edits). +ALLOWED_BROWSE_ROOTS=/,/mnt,/media,/srv,/opt +HOST_ROOT_PREFIX= diff --git a/.gitignore b/.gitignore index 532eb29..65c05eb 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,9 @@ venv/ # Node / frontend node_modules/ frontend/dist/ +*.tsbuildinfo +frontend/vite.config.d.ts +frontend/vite.config.js # App data / secrets data/ diff --git a/README.md b/README.md index a3273bd..5908d05 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. > Life) + Phase 4 (Operations) + Phase 5 (Multi-host) + Phase 6 (Backup > destinations) + Phase 7 (Scheduled backups) + Phase 8 (Remote-stack backups) > + Phase 9 (Networks) + Phase 10 (iGPU passthrough) + Phase 11 (Remote UX & -> network attach) complete. +> network attach) + Phase 12 (File browser) complete. ## What works today (Phase 1) @@ -145,6 +145,23 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows. container or connect any container on the host (`POST /api/networks/{id}/connect` / `/disconnect`). +### Phase 12 — File browser + +- **Files page (sidebar)**: a full host filesystem browser with breadcrumb + navigation, clickable browse-root chips, an *Up* control, and a show/hide + hidden-files toggle. Listings show size, permissions and modified time. +- **View & edit**: clicking a text file opens it in a Monaco editor (with syntax + highlighting picked from the extension). Binary and oversized files are + detected and offered as a download instead. Admins can edit and **Save**. +- **Manage** (admin): create folders/files, rename, delete (recursive for + folders), upload files, and download any file. Every mutation is audit-logged. +- **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path + traversal and deleting a browse root are refused. To reach the real host + filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the + commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under + `/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `upload`, + `download`, `DELETE`). + ## Deploying an agent on another host ```bash diff --git a/backend/agent_app.py b/backend/agent_app.py index 7f42f34..b54291d 100644 --- a/backend/agent_app.py +++ b/backend/agent_app.py @@ -40,7 +40,7 @@ from services import backup_service, compose_service logger = logging.getLogger("stackpilot.agent") -AGENT_VERSION = "0.11.1" +AGENT_VERSION = "0.12.0" # --------------------------------------------------------------------------- # diff --git a/backend/main.py b/backend/main.py index f696b37..8ac3e3b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -20,6 +20,7 @@ from routers import ( backups, destinations, editor, + files, images, networks, ports, @@ -54,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.11.1", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.12.0", lifespan=lifespan) app.add_middleware( CORSMiddleware, @@ -78,6 +79,7 @@ app.include_router(stacks.router) app.include_router(system.router) app.include_router(volumes.router) app.include_router(editor.router) +app.include_router(files.router) app.include_router(images.router) app.include_router(ports.router) app.include_router(templates.router) diff --git a/backend/routers/files.py b/backend/routers/files.py new file mode 100644 index 0000000..fe0fde3 --- /dev/null +++ b/backend/routers/files.py @@ -0,0 +1,194 @@ +"""Full host filesystem browser: list, read, edit, manage, up/download. + +Listing and reads require an authenticated user; every mutating operation +(write, mkdir, rename, delete, upload) requires admin and is audit-logged. +All paths are sandboxed by :mod:`services.file_service`. +""" +from __future__ import annotations + +import os +import tempfile + +from fastapi import ( + APIRouter, + Depends, + File, + Form, + HTTPException, + Query, + Request, + UploadFile, +) +from fastapi.responses import FileResponse +from pydantic import BaseModel +from sqlmodel import Session + +from auth import get_current_user, require_admin +from database import get_session +from models.user import User +from services import audit_service, device_service, file_service + +router = APIRouter(prefix="/api/files", tags=["files"]) + + +def _ip(request: Request) -> str: + return request.client.host if request.client else "unknown" + + +def _guard(fn, *args, **kwargs): + try: + return fn(*args, **kwargs) + except file_service.BrowseError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +# --------------------------------------------------------------------------- # +# Read-only +# --------------------------------------------------------------------------- # + + +@router.get("/list") +def list_dir( + path: str = Query("/"), + show_hidden: bool = Query(False), + _user: User = Depends(get_current_user), +) -> dict: + return _guard(device_service.browse, path, show_hidden) + + +@router.get("/read") +def read_file( + path: str = Query(...), + _user: User = Depends(get_current_user), +) -> dict: + return _guard(file_service.read_file, path) + + +@router.get("/download") +def download( + path: str = Query(...), + _user: User = Depends(get_current_user), +): + real, filename = _guard(file_service.resolve_download, path) + return FileResponse(real, filename=filename, media_type="application/octet-stream") + + +# --------------------------------------------------------------------------- # +# Mutating (admin only) +# --------------------------------------------------------------------------- # + + +class WriteBody(BaseModel): + path: str + content: str + + +class NameBody(BaseModel): + path: str + name: str + + +class RenameBody(BaseModel): + path: str + new_name: str + + +@router.put("/write") +def write_file( + body: WriteBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + result = _guard(file_service.write_file, body.path, body.content) + audit_service.record( + session, user=user.username, action="file.write", target=body.path, ip=_ip(request) + ) + return result + + +@router.post("/mkdir") +def mkdir( + body: NameBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + result = _guard(file_service.create_dir, body.path, body.name) + audit_service.record( + session, user=user.username, action="file.mkdir", target=result["path"], ip=_ip(request) + ) + return result + + +@router.post("/touch") +def touch( + body: NameBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + result = _guard(file_service.create_file, body.path, body.name) + audit_service.record( + session, user=user.username, action="file.create", target=result["path"], ip=_ip(request) + ) + return result + + +@router.post("/rename") +def rename( + body: RenameBody, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + result = _guard(file_service.rename, body.path, body.new_name) + audit_service.record( + session, user=user.username, action="file.rename", + target=body.path, detail=f"-> {result['path']}", ip=_ip(request), + ) + return result + + +@router.delete("") +def delete( + request: Request, + path: str = Query(...), + recursive: bool = Query(False), + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + result = _guard(file_service.delete, path, recursive) + audit_service.record( + session, user=user.username, action="file.delete", target=path, + detail="recursive" if recursive else None, ip=_ip(request), + ) + return result + + +@router.post("/upload") +async def upload( + request: Request, + path: str = Form(...), + overwrite: bool = Form(False), + file: UploadFile = File(...), + session: Session = Depends(get_session), + user: User = Depends(require_admin), +) -> dict: + real = _guard(file_service.upload_target, path, file.filename or "", overwrite) + # Stream to a temp file first, then move into place atomically. + tmp = tempfile.NamedTemporaryFile(delete=False, dir=os.path.dirname(real)) + try: + while chunk := await file.read(1024 * 1024): + tmp.write(chunk) + tmp.close() + os.replace(tmp.name, real) + except OSError as exc: + if os.path.exists(tmp.name): + os.unlink(tmp.name) + raise HTTPException(status_code=400, detail=f"Upload failed: {exc}") from exc + audit_service.record( + session, user=user.username, action="file.upload", + target=path, detail=file.filename, ip=_ip(request), + ) + return {"ok": True, "name": file.filename} diff --git a/backend/services/device_service.py b/backend/services/device_service.py index ffb4446..f8f2ca6 100644 --- a/backend/services/device_service.py +++ b/backend/services/device_service.py @@ -138,6 +138,8 @@ def browse(path: str = "/", show_hidden: bool = False) -> dict: "type": "dir" if is_dir else "file", "size": st.st_size if not is_dir else None, "permissions": oct(st.st_mode & 0o777), + "mtime": st.st_mtime, + "symlink": os.path.islink(full_real), } ) except OSError: diff --git a/backend/services/file_service.py b/backend/services/file_service.py new file mode 100644 index 0000000..a22a666 --- /dev/null +++ b/backend/services/file_service.py @@ -0,0 +1,188 @@ +"""Sandboxed host filesystem operations for the web file browser. + +All paths are *logical* host paths (what the user sees, e.g. ``/opt/foo``). +They are validated against ``ALLOWED_BROWSE_ROOTS`` and then mapped into the +container's view via ``HOST_ROOT_PREFIX`` before any I/O. Directory listing is +provided by :func:`device_service.browse`; this module adds the read/write, +upload/download and management operations needed for a full browser. +""" +from __future__ import annotations + +import os +import shutil + +from services.device_service import BrowseError, _is_allowed, _real_root + +# Largest file we will load into the in-browser text editor. +MAX_EDIT_BYTES = 2 * 1024 * 1024 # 2 MiB + + +def _safe_real(path: str) -> str: + """Validate a logical path against the sandbox and return its real path.""" + path = os.path.normpath(path or "/") + if not path.startswith("/"): + raise BrowseError("Path must be absolute") + if not _is_allowed(path): + raise BrowseError("Path is outside the allowed browse roots") + return _real_root(path) + + +def _child(path: str, name: str) -> str: + """Return the logical path of ``name`` directly inside ``path``. + + ``name`` must be a single path component (no separators, no traversal). + """ + if not name or name in (".", "..") or "/" in name or "\\" in name: + raise BrowseError("Invalid name") + base = "" if path == "/" else path.rstrip("/") + return f"{base}/{name}" + + +def _looks_binary(chunk: bytes) -> bool: + return b"\x00" in chunk + + +# --------------------------------------------------------------------------- # +# Read / write text +# --------------------------------------------------------------------------- # + + +def read_file(path: str) -> dict: + real = _safe_real(path) + if not os.path.isfile(real): + raise BrowseError(f"Not a file: {path}") + size = os.path.getsize(real) + if size > MAX_EDIT_BYTES: + return { + "path": path, + "content": None, + "size": size, + "binary": False, + "too_large": True, + } + try: + with open(real, "rb") as fh: + raw = fh.read() + except PermissionError as exc: + raise BrowseError(f"Permission denied: {path}") from exc + + if _looks_binary(raw[:8192]): + return {"path": path, "content": None, "size": size, "binary": True, "too_large": False} + try: + content = raw.decode("utf-8") + except UnicodeDecodeError: + return {"path": path, "content": None, "size": size, "binary": True, "too_large": False} + return {"path": path, "content": content, "size": size, "binary": False, "too_large": False} + + +def write_file(path: str, content: str) -> dict: + real = _safe_real(path) + if os.path.isdir(real): + raise BrowseError(f"Is a directory: {path}") + parent = os.path.dirname(real) + if not os.path.isdir(parent): + raise BrowseError("Parent directory does not exist") + try: + with open(real, "w", encoding="utf-8") as fh: + fh.write(content) + except PermissionError as exc: + raise BrowseError(f"Permission denied: {path}") from exc + return {"path": path, "size": os.path.getsize(real)} + + +# --------------------------------------------------------------------------- # +# Management +# --------------------------------------------------------------------------- # + + +def create_dir(path: str, name: str) -> dict: + child = _child(path, name) + real = _safe_real(child) + if os.path.exists(real): + raise BrowseError(f"Already exists: {name}") + try: + os.mkdir(real) + except PermissionError as exc: + raise BrowseError(f"Permission denied: {path}") from exc + return {"path": child} + + +def create_file(path: str, name: str) -> dict: + child = _child(path, name) + real = _safe_real(child) + if os.path.exists(real): + raise BrowseError(f"Already exists: {name}") + try: + with open(real, "x", encoding="utf-8"): + pass + except PermissionError as exc: + raise BrowseError(f"Permission denied: {path}") from exc + return {"path": child} + + +def rename(path: str, new_name: str) -> dict: + real = _safe_real(path) + if not os.path.lexists(real): + raise BrowseError(f"No such path: {path}") + parent = os.path.dirname(path) or "/" + dest = _child(parent, new_name) + dest_real = _safe_real(dest) + if os.path.lexists(dest_real): + raise BrowseError(f"Already exists: {new_name}") + try: + os.rename(real, dest_real) + except PermissionError as exc: + raise BrowseError(f"Permission denied: {path}") from exc + return {"path": dest} + + +def delete(path: str, recursive: bool = False) -> dict: + real = _safe_real(path) + norm = os.path.normpath(path) + if norm == "/" or norm in {os.path.normpath(r) for r in _root_paths()}: + raise BrowseError("Refusing to delete a browse root") + if not os.path.lexists(real): + raise BrowseError(f"No such path: {path}") + try: + if os.path.isdir(real) and not os.path.islink(real): + if recursive: + shutil.rmtree(real) + else: + os.rmdir(real) # fails if non-empty + else: + os.remove(real) + except OSError as exc: + raise BrowseError(f"Could not delete {path}: {exc.strerror or exc}") from exc + return {"path": path} + + +def _root_paths() -> list[str]: + from config import settings + + return settings.ALLOWED_BROWSE_ROOTS + + +# --------------------------------------------------------------------------- # +# Download / upload +# --------------------------------------------------------------------------- # + + +def resolve_download(path: str) -> tuple[str, str]: + """Return (real_path, filename) for a file download, or raise BrowseError.""" + real = _safe_real(path) + if not os.path.isfile(real): + raise BrowseError(f"Not a file: {path}") + return real, os.path.basename(path) + + +def upload_target(dir_path: str, filename: str, overwrite: bool = False) -> str: + """Validate an upload destination and return the real path to write to.""" + real_dir = _safe_real(dir_path) + if not os.path.isdir(real_dir): + raise BrowseError(f"Not a directory: {dir_path}") + name = os.path.basename(filename or "") + child = _child(dir_path, name) + real = _safe_real(child) + if os.path.exists(real) and not overwrite: + raise BrowseError(f"Already exists: {name}") + return real diff --git a/docker-compose.yml b/docker-compose.yml index 1499b38..c258bcb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,11 @@ services: - NOTIFY_WEBHOOKS=${NOTIFY_WEBHOOKS:-} # Throwaway image used to snapshot named-volume contents during backups. - BACKUP_HELPER_IMAGE=${BACKUP_HELPER_IMAGE:-alpine:latest} + # File browser (sidebar) + volume host-path picker. ALLOWED_BROWSE_ROOTS + # limits which paths are reachable; HOST_ROOT_PREFIX is where the host + # filesystem is mounted inside this container (see the volume below). + - ALLOWED_BROWSE_ROOTS=${ALLOWED_BROWSE_ROOTS:-/,/mnt,/media,/srv,/opt} + - HOST_ROOT_PREFIX=${HOST_ROOT_PREFIX:-} volumes: - /var/run/docker.sock:/var/run/docker.sock - ./data:/data @@ -22,6 +27,10 @@ services: # Host devices for GPU/device detection + passthrough (USB/TTY/DRI). # Read-only; remove if you don't need GPU/device features. - /dev:/dev:ro + # File browser: to browse/manage the real host filesystem, mount it here + # and set HOST_ROOT_PREFIX=/host_root in .env. Use :ro for read-only + # browsing, or drop :ro to allow edits/uploads/deletes from the UI. + # - /:/host_root expose: - "5008" # Uncomment to expose the API directly (normally proxied by the frontend): diff --git a/frontend/package.json b/frontend/package.json index 77cb0b5..3b1fd49 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "stackpilot-frontend", "private": true, - "version": "0.11.1", + "version": "0.12.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e530a43..61a0faf 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { StackDetail } from "@/pages/StackDetail"; import { StackEditor } from "@/pages/StackEditor"; import { RemoteStackDetail } from "@/pages/RemoteStackDetail"; import { Images } from "@/pages/Images"; +import { Files } from "@/pages/Files"; import { Templates } from "@/pages/Templates"; import { Settings } from "@/pages/Settings"; import { Audit } from "@/pages/Audit"; @@ -47,6 +48,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/files.ts b/frontend/src/api/files.ts new file mode 100644 index 0000000..72c9200 --- /dev/null +++ b/frontend/src/api/files.ts @@ -0,0 +1,60 @@ +import api from "./client"; +import type { HostPathResult } from "@/types"; + +export interface FileContent { + path: string; + content: string | null; + size: number; + binary: boolean; + too_large: boolean; +} + +function triggerDownload(blob: Blob, filename: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + +export const filesApi = { + list: (path: string, showHidden = false) => + api + .get("/api/files/list", { params: { path, show_hidden: showHidden } }) + .then((r) => r.data), + + read: (path: string) => + api.get("/api/files/read", { params: { path } }).then((r) => r.data), + + write: (path: string, content: string) => + api.put<{ path: string; size: number }>("/api/files/write", { path, content }).then((r) => r.data), + + mkdir: (path: string, name: string) => + api.post<{ path: string }>("/api/files/mkdir", { path, name }).then((r) => r.data), + + touch: (path: string, name: string) => + api.post<{ path: string }>("/api/files/touch", { path, name }).then((r) => r.data), + + rename: (path: string, newName: string) => + api.post<{ path: string }>("/api/files/rename", { path, new_name: newName }).then((r) => r.data), + + remove: (path: string, recursive = false) => + api.delete("/api/files", { params: { path, recursive } }).then((r) => r.data), + + download: async (path: string, filename: string) => { + const res = await api.get("/api/files/download", { params: { path }, responseType: "blob" }); + triggerDownload(res.data as Blob, filename); + }, + + upload: async (path: string, file: File, overwrite = false) => { + const form = new FormData(); + form.append("path", path); + form.append("overwrite", String(overwrite)); + form.append("file", file); + const res = await api.post<{ ok: boolean; name: string }>("/api/files/upload", form); + return res.data; + }, +}; diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 70b8c9e..df39b3f 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -4,6 +4,7 @@ import { Boxes, Network, Image, + FolderTree, LayoutTemplate, ScrollText, Settings, @@ -22,6 +23,7 @@ const nav = [ { to: "/stacks", label: "Stacks", icon: Boxes }, { to: "/networks", label: "Networks", icon: Network }, { to: "/images", label: "Images", icon: Image }, + { to: "/files", label: "Files", icon: FolderTree }, { to: "/templates", label: "Templates", icon: LayoutTemplate }, { to: "/audit", label: "Audit log", icon: ScrollText }, { to: "/settings", label: "Settings", icon: Settings }, diff --git a/frontend/src/pages/Files.tsx b/frontend/src/pages/Files.tsx new file mode 100644 index 0000000..e0e3626 --- /dev/null +++ b/frontend/src/pages/Files.tsx @@ -0,0 +1,510 @@ +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Folder, + File as FileIcon, + ArrowUp, + RefreshCw, + FolderPlus, + FilePlus, + Upload, + Download, + Pencil, + Trash2, + Eye, + EyeOff, + Save, + X, + HardDrive, + Link2, +} from "lucide-react"; +import { toast } from "sonner"; +import Editor from "@monaco-editor/react"; +import { Button, Card, Input, Spinner } from "@/components/ui"; +import { ConfirmDialog } from "@/components/ui/ConfirmDialog"; +import { filesApi } from "@/api/files"; +import { apiErrorMessage } from "@/api/client"; +import { useAuthStore } from "@/store/auth"; +import { useThemeStore } from "@/store/theme"; +import { formatBytes, relativeTime } from "@/lib/utils"; +import type { HostPathEntry } from "@/types"; + +const LANG_BY_EXT: Record = { + yml: "yaml", yaml: "yaml", json: "json", js: "javascript", ts: "typescript", + tsx: "typescript", jsx: "javascript", py: "python", sh: "shell", bash: "shell", + env: "ini", ini: "ini", conf: "ini", cfg: "ini", toml: "ini", md: "markdown", + html: "html", css: "css", xml: "xml", sql: "sql", dockerfile: "dockerfile", +}; + +function langForName(name: string): string { + const lower = name.toLowerCase(); + if (lower === "dockerfile") return "dockerfile"; + const ext = lower.includes(".") ? lower.split(".").pop()! : ""; + return LANG_BY_EXT[ext] ?? "plaintext"; +} + +function join(path: string, name: string) { + return `${path === "/" ? "" : path}/${name}`; +} + +function crumbs(path: string): { label: string; path: string }[] { + const parts = path.split("/").filter(Boolean); + const out = [{ label: "/", path: "/" }]; + let acc = ""; + for (const p of parts) { + acc += `/${p}`; + out.push({ label: p, path: acc }); + } + return out; +} + +export function Files() { + const isAdmin = useAuthStore((s) => s.user?.role === "admin"); + const qc = useQueryClient(); + const [path, setPath] = useState("/"); + const [showHidden, setShowHidden] = useState(false); + const [editing, setEditing] = useState(null); + const [renaming, setRenaming] = useState(null); + const [deleting, setDeleting] = useState(null); + const [newKind, setNewKind] = useState<"dir" | "file" | null>(null); + const fileInput = useRef(null); + + const { data, isLoading, isFetching, error } = useQuery({ + queryKey: ["files", path, showHidden], + queryFn: () => filesApi.list(path, showHidden), + }); + + const refresh = () => qc.invalidateQueries({ queryKey: ["files"] }); + + const upload = useMutation({ + mutationFn: (file: File) => filesApi.upload(path, file), + onSuccess: (r) => { + toast.success(`Uploaded ${r.name}`); + refresh(); + }, + onError: (e: unknown) => { + const msg = apiErrorMessage(e); + if (msg.startsWith("Already exists")) { + toast.error(`${msg} — rename or remove the existing file first.`); + } else { + toast.error(msg); + } + }, + onSettled: () => { + if (fileInput.current) fileInput.current.value = ""; + }, + }); + + const remove = useMutation({ + mutationFn: (e: HostPathEntry) => filesApi.remove(join(path, e.name), e.type === "dir"), + onSuccess: () => { + toast.success("Deleted"); + setDeleting(null); + refresh(); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + const download = (e: HostPathEntry) => + filesApi.download(join(path, e.name), e.name).catch((err) => toast.error(apiErrorMessage(err))); + + return ( +
+ {/* Roots + actions */} +
+ {data?.roots.map((r) => ( + + ))} +
+ + + {isAdmin && ( + <> + + + + { + const f = e.target.files?.[0]; + if (f) upload.mutate(f); + }} + /> + + )} +
+
+ + {/* Breadcrumb */} + +
+ + {crumbs(path).map((c, i, arr) => ( + + + {i < arr.length - 1 && i > 0 && /} + + ))} +
+ + {error ? ( +

{apiErrorMessage(error)}

+ ) : isLoading ? ( + + ) : ( +
+ + + + + + + + + + + + {data?.entries.map((e) => ( + + + + + + + + ))} + {data?.entries.length === 0 && ( + + + + )} + +
NameSizePermissionsModified
+ + + {e.type === "dir" ? "—" : formatBytes(e.size ?? 0)} + {e.permissions} + {e.mtime ? relativeTime(new Date(e.mtime * 1000).toISOString()) : "—"} + +
+ {e.type === "file" && ( + + )} + {isAdmin && ( + <> + + + + )} +
+
+ Empty directory. +
+
+ )} +
+ + {editing && ( + setEditing(null)} + onSaved={refresh} + /> + )} + {newKind && ( + setNewKind(null)} + onDone={() => { + setNewKind(null); + refresh(); + }} + /> + )} + {renaming && ( + setRenaming(null)} + onDone={() => { + setRenaming(null); + refresh(); + }} + /> + )} + {deleting && ( + remove.mutate(deleting)} + onCancel={() => setDeleting(null)} + /> + )} +
+ ); +} + +// --------------------------------------------------------------------------- // + +function FileEditor({ + path, + name, + isAdmin, + onClose, + onSaved, +}: { + path: string; + name: string; + isAdmin: boolean; + onClose: () => void; + onSaved: () => void; +}) { + const theme = useThemeStore((s) => s.theme); + const [content, setContent] = useState(""); + const [dirty, setDirty] = useState(false); + const { data, isLoading, error } = useQuery({ + queryKey: ["file-content", path], + queryFn: () => filesApi.read(path), + }); + + useEffect(() => { + if (data?.content != null) setContent(data.content); + }, [data]); + + const save = useMutation({ + mutationFn: () => filesApi.write(path, content), + onSuccess: () => { + toast.success("Saved"); + setDirty(false); + onSaved(); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + const readOnly = !isAdmin; + const unviewable = data && (data.binary || data.too_large); + + return ( +
+
e.stopPropagation()} + > +
+
+

{name}

+

{path}

+
+
+ {!readOnly && !unviewable && ( + + )} + +
+
+ + {error ? ( +

{apiErrorMessage(error)}

+ ) : isLoading ? ( + + ) : unviewable ? ( +
+ +

+ {data?.binary + ? "This looks like a binary file and can't be edited here." + : `File is too large to edit (${formatBytes(data?.size ?? 0)}).`} +

+ +
+ ) : ( +
+ { + setContent(v ?? ""); + setDirty(true); + }} + options={{ + readOnly, + minimap: { enabled: false }, + fontSize: 13, + tabSize: 2, + }} + /> +
+ )} +
+
+ ); +} + +function NewEntryDialog({ + kind, + dir, + onCancel, + onDone, +}: { + kind: "dir" | "file"; + dir: string; + onCancel: () => void; + onDone: () => void; +}) { + const [name, setName] = useState(""); + const create = useMutation({ + mutationFn: () => (kind === "dir" ? filesApi.mkdir(dir, name.trim()) : filesApi.touch(dir, name.trim())), + onSuccess: () => { + toast.success(kind === "dir" ? "Folder created" : "File created"); + onDone(); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + return ( + name.trim() && create.mutate()} + onCancel={onCancel} + > + setName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && name.trim() && create.mutate()} + placeholder={kind === "dir" ? "folder-name" : "file.txt"} + /> + + ); +} + +function RenameDialog({ + entry, + dir, + onCancel, + onDone, +}: { + entry: HostPathEntry; + dir: string; + onCancel: () => void; + onDone: () => void; +}) { + const [name, setName] = useState(entry.name); + const rename = useMutation({ + mutationFn: () => filesApi.rename(join(dir, entry.name), name.trim()), + onSuccess: () => { + toast.success("Renamed"); + onDone(); + }, + onError: (e) => toast.error(apiErrorMessage(e)), + }); + + return ( + name.trim() && name.trim() !== entry.name && rename.mutate()} + onCancel={onCancel} + > + setName(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && name.trim() && name.trim() !== entry.name && rename.mutate() + } + /> + + ); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index bd45a84..5d85d3f 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -126,6 +126,8 @@ export interface HostPathEntry { type: "dir" | "file"; size?: number | null; permissions: string; + mtime?: number; + symlink?: boolean; } export interface HostPathResult {