From 16e7b6478e0b2eb22c13ac757a50fd85ff86d2a8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 09:15:27 +0000 Subject: [PATCH] feat(admin): add admin-only file manager, admin menu, de-emphasize status, improve dashboard Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/views/__init__.py | 2 + app/views/filemanager.py | 147 ++++++++++++++++ frontend/static/js/common.js | 12 ++ frontend/templates/base.html | 74 +++++++- frontend/templates/filemanager.html | 125 ++++++++++++++ frontend/templates/index.html | 251 +++++++++++++++------------- 6 files changed, 492 insertions(+), 119 deletions(-) create mode 100644 app/views/filemanager.py create mode 100644 frontend/templates/filemanager.html diff --git a/app/views/__init__.py b/app/views/__init__.py index bf263567..12057a41 100644 --- a/app/views/__init__.py +++ b/app/views/__init__.py @@ -5,6 +5,7 @@ Aggregated view routers for the application. from fastapi import APIRouter from app.views.dropbox import router as dropbox_router +from app.views.filemanager import router as filemanager_router # Import all the view routers from app.views.general import router as general_router @@ -25,3 +26,4 @@ router.include_router(dropbox_router) router.include_router(google_drive_router) router.include_router(license_router) # Include the license router router.include_router(settings_router) +router.include_router(filemanager_router) diff --git a/app/views/filemanager.py b/app/views/filemanager.py new file mode 100644 index 00000000..61c85828 --- /dev/null +++ b/app/views/filemanager.py @@ -0,0 +1,147 @@ +""" +Admin-only file manager view for browsing the workdir directory. +""" + +import logging +import mimetypes +import os +from datetime import datetime +from pathlib import Path + +from fastapi import Request +from fastapi.responses import FileResponse + +from app.config import settings +from app.views.base import APIRouter, require_login, templates +from app.views.settings import require_admin_access + +logger = logging.getLogger(__name__) +router = APIRouter() + +_SIZE_UNITS = ["B", "KB", "MB", "GB", "TB"] + + +def _format_size(size_bytes: int) -> str: + """Format file size in human-readable form.""" + size = float(size_bytes) + for unit in _SIZE_UNITS: + if size < 1024.0: + return f"{size:.1f} {unit}" + size /= 1024.0 + return f"{size:.1f} PB" + + +def _safe_path(workdir: str, rel_path: str) -> Path: + """ + Resolve a relative path inside workdir safely. + + Raises ValueError if the resolved path escapes workdir. + """ + base = Path(workdir).resolve() + target = (base / rel_path).resolve() + if not str(target).startswith(str(base)): + raise ValueError("Path traversal detected") + return target + + +@router.get("/admin/files") +@require_login +@require_admin_access +async def filemanager(request: Request): + """ + Admin-only file manager for browsing the workdir directory. + """ + workdir = settings.workdir + rel_path = request.query_params.get("path", "") + + # Sanitise the relative path – strip leading slashes / dots + rel_path = rel_path.lstrip("/").lstrip(".") + + try: + target = _safe_path(workdir, rel_path) + except ValueError: + logger.warning(f"Path traversal attempt blocked: path={rel_path!r}") + target = Path(workdir).resolve() + rel_path = "" + + if not target.exists(): + target = Path(workdir).resolve() + rel_path = "" + + entries = [] + if target.is_dir(): + for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower())): + try: + stat = item.stat() + item_rel = str(item.relative_to(Path(workdir).resolve())) + mime_type, _ = mimetypes.guess_type(item.name) + entries.append( + { + "name": item.name, + "rel_path": item_rel, + "is_dir": item.is_dir(), + "size": _format_size(stat.st_size) if not item.is_dir() else "", + "size_bytes": stat.st_size if not item.is_dir() else 0, + "modified": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M"), + "mime_type": mime_type or "", + } + ) + except (PermissionError, OSError) as exc: + logger.warning(f"Could not stat {item}: {exc}") + + # Build breadcrumb trail + breadcrumbs = [] + if rel_path: + parts = Path(rel_path).parts + accumulated = "" + for part in parts: + accumulated = str(Path(accumulated) / part) if accumulated else part + breadcrumbs.append({"name": part, "path": accumulated}) + + # Parent path for the "go up" link + parent_path = str(Path(rel_path).parent) if rel_path and Path(rel_path).parent != Path(".") else "" + if parent_path == ".": + parent_path = "" + + return templates.TemplateResponse( + "filemanager.html", + { + "request": request, + "entries": entries, + "current_path": rel_path, + "parent_path": parent_path, + "breadcrumbs": breadcrumbs, + "workdir": workdir, + "app_version": settings.version, + }, + ) + + +@router.get("/admin/files/download") +@require_login +@require_admin_access +async def filemanager_download(request: Request): + """ + Admin-only endpoint to download a file from workdir. + """ + workdir = settings.workdir + rel_path = request.query_params.get("path", "").lstrip("/").lstrip(".") + + try: + target = _safe_path(workdir, rel_path) + except ValueError: + from fastapi import HTTPException + + raise HTTPException(status_code=400, detail="Invalid path") + + if not target.exists() or not target.is_file(): + from fastapi import HTTPException + + raise HTTPException(status_code=404, detail="File not found") + + mime_type, _ = mimetypes.guess_type(target.name) + return FileResponse( + path=str(target), + filename=target.name, + media_type=mime_type or "application/octet-stream", + ) diff --git a/frontend/static/js/common.js b/frontend/static/js/common.js index 2244ee8e..0d906055 100644 --- a/frontend/static/js/common.js +++ b/frontend/static/js/common.js @@ -16,6 +16,18 @@ // Get the display name (prefer name, fall back to preferred_username, then email) const displayName = data.name || data.preferred_username || data.email; + // Show admin menu items if the user is an admin + if (data.is_admin) { + const adminMenuContainer = document.getElementById("adminMenuContainer"); + if (adminMenuContainer) { + adminMenuContainer.classList.remove("hidden"); + } + const mobileAdminSection = document.getElementById("mobileAdminSection"); + if (mobileAdminSection) { + mobileAdminSection.classList.remove("hidden"); + } + } + // User is logged in - use DOM API to prevent XSS if (authSection) { authSection.textContent = ''; // Clear existing content diff --git a/frontend/templates/base.html b/frontend/templates/base.html index 4ab0505c..7be7e0cd 100644 --- a/frontend/templates/base.html +++ b/frontend/templates/base.html @@ -41,13 +41,55 @@ -
+