"""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 starlette.background import BackgroundTask 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), ): if _guard(file_service.is_dir, path): tmp, filename = _guard(file_service.archive_dir, path) return FileResponse( tmp, filename=filename, media_type="application/zip", background=BackgroundTask(os.unlink, tmp), ) 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 class TransferBody(BaseModel): src: str dest_dir: str overwrite: bool = False @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.post("/copy") def copy( body: TransferBody, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: result = _guard(file_service.copy, body.src, body.dest_dir, body.overwrite) audit_service.record( session, user=user.username, action="file.copy", target=body.src, detail=f"-> {result['path']}", ip=_ip(request), ) return result @router.post("/move") def move( body: TransferBody, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: result = _guard(file_service.move, body.src, body.dest_dir, body.overwrite) audit_service.record( session, user=user.username, action="file.move", target=body.src, 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), rel_path: str = Form(""), 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, rel_path or None ) # 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=rel_path or file.filename, ip=_ip(request), ) return {"ok": True, "name": rel_path or file.filename}