import logging import os import re import secrets import shutil import time from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, Query from fastapi.responses import StreamingResponse from config import VOLUME_ROOT from rbac import require_admin router = APIRouter() logger = logging.getLogger(__name__) BASE = Path(VOLUME_ROOT) MAX_UPLOAD_SIZE = 100 * 1024 * 1024 # 100 MB BLOCKED_DIRS = {"@appstore", "@docker", "@eaDir", "@S2S", "@sharesnap", "@tmp", "docker", "homes", "backups"} # Download token storage: {token: (path, expiry_timestamp)} _download_tokens = {} TOKEN_EXPIRY = 300 # 5 minutes _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") # Check if we have permission to read the directory try: items = list(target.iterdir()) except PermissionError: raise HTTPException(status_code=403, detail="Permission denied") except OSError as e: logger.error(f"Error reading directory {target}: {e}") raise HTTPException(status_code=500, detail="Error reading directory") entries = [] for item in sorted(items): 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.post("/download-token") def create_download_token(path: str): """Generate a temporary download token for large file downloads.""" try: target = _safe_path(path) if not target.exists(): raise HTTPException(status_code=404, detail="File not found") # Clean up expired tokens now = time.time() expired = [t for t, (_, exp) in _download_tokens.items() if exp < now] for t in expired: del _download_tokens[t] # Generate new token token = secrets.token_urlsafe(32) _download_tokens[token] = (str(target), now + TOKEN_EXPIRY) return {"token": token, "expires_in": TOKEN_EXPIRY} except ValueError: raise HTTPException(status_code=403, detail="Access denied") @router.get("/download") def download(path: str = None, token: str = Query(None)): """Download a file. Supports both authenticated access (path param) and token-based access (token param).""" try: if token: # Token-based download (no auth required) if token not in _download_tokens: raise HTTPException(status_code=403, detail="Invalid or expired token") file_path, expiry = _download_tokens[token] if time.time() > expiry: del _download_tokens[token] raise HTTPException(status_code=403, detail="Token expired") # Consume token after use del _download_tokens[token] target = Path(file_path) else: # Authenticated download if not path: raise HTTPException(status_code=400, detail="Path or token required") target = _safe_path(path) if not target.exists(): raise HTTPException(status_code=404, detail="File not found") def file_iterator(): with open(target, "rb") as f: while chunk := f.read(8192 * 1024): # 8MB chunks yield chunk return StreamingResponse( file_iterator(), media_type="application/octet-stream", headers={"Content-Disposition": f'attachment; filename="{target.name}"'} ) 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: logger.exception("OS error during file deletion: %s", path) raise HTTPException(status_code=400, detail="Failed to delete file") return {"ok": True}