import os import re import shutil from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse from config import VOLUME_ROOT from rbac import require_admin router = APIRouter() BASE = Path(VOLUME_ROOT) MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker", "homes", "backups"} _BASE_RESOLVED = str(BASE.resolve()) def _safe_path(path: str) -> Path: # Strip leading slashes to prevent absolute path interpretation path = path.lstrip("/") target = (BASE / path).resolve() if not str(target).startswith(_BASE_RESOLVED): raise ValueError("forbidden") # Block access to sensitive directories rel = target.relative_to(BASE.resolve()) if rel.parts and rel.parts[0] in BLOCKED_DIRS: raise ValueError("forbidden") return target def _is_safe_symlink(item: Path) -> bool: """Check if a symlink resolves to a path within BASE and not in blocked dirs.""" if not item.is_symlink(): return True try: resolved = item.resolve() if not str(resolved).startswith(_BASE_RESOLVED): return False rel = resolved.relative_to(BASE.resolve()) if rel.parts and rel.parts[0] in BLOCKED_DIRS: return False return True except (OSError, ValueError): return False def _sanitize_filename(name: str) -> str: name = os.path.basename(name) name = name.replace("\x00", "").replace("/", "").replace("\\", "") name = re.sub(r"\.\.+", ".", name) name = name.strip(". ") if not name: raise ValueError("invalid filename") return name @router.get("/browse") def browse(path: str = ""): try: target = _safe_path(path) except ValueError: raise HTTPException(status_code=403, detail="Access denied") entries = [] for item in sorted(target.iterdir()): try: if not _is_safe_symlink(item): continue st = item.stat() entries.append({"name": item.name, "is_dir": item.is_dir(), "size": st.st_size if item.is_file() else 0}) except (PermissionError, OSError): continue return {"path": str(target.relative_to(BASE)), "entries": entries} @router.get("/download") def download(path: str): try: target = _safe_path(path) if not target.exists(): raise HTTPException(status_code=404, detail="File not found") return FileResponse(target) except ValueError: raise HTTPException(status_code=403, detail="Access denied") @router.post("/upload", dependencies=[Depends(require_admin())]) async def upload(path: str, file: UploadFile = File(...)): try: safe_name = _sanitize_filename(file.filename) target = _safe_path(path) / safe_name except ValueError: raise HTTPException(status_code=403, detail="Access denied") # Stream with size check total = 0 with open(target, "wb") as f: while chunk := await file.read(1024 * 1024): total += len(chunk) if total > MAX_UPLOAD_SIZE: f.close() target.unlink(missing_ok=True) raise HTTPException(status_code=413, detail="File too large (max 100MB)") f.write(chunk) return {"ok": True} @router.delete("/delete", dependencies=[Depends(require_admin())]) def delete(path: str, recursive: bool = False): try: target = _safe_path(path) except ValueError: raise HTTPException(status_code=403, detail="Access denied") if target == BASE.resolve(): raise HTTPException(status_code=403, detail="Cannot delete base root") try: if target.is_dir(): if not recursive: raise HTTPException(status_code=400, detail="Directory deletion requires recursive=true") shutil.rmtree(target) else: target.unlink() except HTTPException: raise except FileNotFoundError: raise HTTPException(status_code=404, detail="Path not found") except PermissionError: raise HTTPException(status_code=403, detail="Permission denied") except OSError as exc: raise HTTPException(status_code=400, detail=str(exc)) return {"ok": True}