"""YakPanel - File manager API""" import os from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi.responses import FileResponse from pydantic import BaseModel from app.core.config import get_runtime_config from app.core.utils import read_file, write_file, path_safe_check from app.api.auth import get_current_user from app.models.user import User router = APIRouter(prefix="/files", tags=["files"]) def _resolve_path(path: str) -> str: """Resolve and validate path within allowed roots (cross-platform)""" cfg = get_runtime_config() www_root = os.path.abspath(cfg["www_root"]) setup_path = os.path.abspath(cfg["setup_path"]) allowed = [www_root, setup_path] if os.name != "nt": allowed.append(os.path.abspath("/www")) if ".." in path: raise HTTPException(status_code=401, detail="Path traversal not allowed") norm_path = path.strip().replace("\\", "/").strip("/") # Root or www_root-style path if not norm_path or norm_path in ("www", "www/wwwroot", "wwwroot"): full = www_root elif norm_path.startswith("www/wwwroot/"): full = os.path.abspath(os.path.join(www_root, norm_path[12:])) else: full = os.path.abspath(os.path.join(www_root, norm_path)) if not any( full == r or (full + os.sep).startswith(r + os.sep) for r in allowed ): raise HTTPException(status_code=403, detail="Path not allowed") return full @router.get("/list") async def files_list( path: str = "/", current_user: User = Depends(get_current_user), ): """List directory contents""" try: full = _resolve_path(path) except HTTPException: raise if not os.path.isdir(full): raise HTTPException(status_code=401, detail="Not a directory") items = [] for name in os.listdir(full): item_path = os.path.join(full, name) try: stat = os.stat(item_path) items.append({ "name": name, "is_dir": os.path.isdir(item_path), "size": stat.st_size if os.path.isfile(item_path) else 0, }) except OSError: pass return {"path": path, "items": items} @router.get("/read") async def files_read( path: str, current_user: User = Depends(get_current_user), ): """Read file content""" try: full = _resolve_path(path) except HTTPException: raise if not os.path.isfile(full): raise HTTPException(status_code=404, detail="Not a file") content = read_file(full) if content is None: raise HTTPException(status_code=500, detail="Failed to read file") return {"path": path, "content": content} @router.get("/download") async def files_download( path: str, current_user: User = Depends(get_current_user), ): """Download file""" try: full = _resolve_path(path) except HTTPException: raise if not os.path.isfile(full): raise HTTPException(status_code=404, detail="Not a file") return FileResponse(full, filename=os.path.basename(full)) @router.post("/upload") async def files_upload( path: str = Form(...), file: UploadFile = File(...), current_user: User = Depends(get_current_user), ): """Upload file to directory""" try: full = _resolve_path(path) except HTTPException: raise if not os.path.isdir(full): raise HTTPException(status_code=400, detail="Path must be a directory") filename = file.filename or "upload" if ".." in filename or "/" in filename or "\\" in filename: raise HTTPException(status_code=400, detail="Invalid filename") dest = os.path.join(full, filename) content = await file.read() if not write_file(dest, content, "wb"): raise HTTPException(status_code=500, detail="Failed to write file") return {"status": True, "msg": "Uploaded", "path": path + "/" + filename} class MkdirRequest(BaseModel): path: str name: str class RenameRequest(BaseModel): path: str old_name: str new_name: str class DeleteRequest(BaseModel): path: str name: str is_dir: bool @router.post("/mkdir") async def files_mkdir( body: MkdirRequest, current_user: User = Depends(get_current_user), ): """Create directory""" try: parent = _resolve_path(body.path) except HTTPException: raise if not os.path.isdir(parent): raise HTTPException(status_code=400, detail="Parent must be a directory") if not body.name or ".." in body.name or "/" in body.name or "\\" in body.name: raise HTTPException(status_code=400, detail="Invalid directory name") if not path_safe_check(body.name): raise HTTPException(status_code=400, detail="Invalid directory name") full = os.path.join(parent, body.name) if os.path.exists(full): raise HTTPException(status_code=400, detail="Already exists") try: os.makedirs(full, 0o755) except OSError as e: raise HTTPException(status_code=500, detail=str(e)) return {"status": True, "msg": "Created", "path": body.path.rstrip("/") + "/" + body.name} @router.post("/rename") async def files_rename( body: RenameRequest, current_user: User = Depends(get_current_user), ): """Rename file or directory""" try: parent = _resolve_path(body.path) except HTTPException: raise if not body.old_name or not body.new_name: raise HTTPException(status_code=400, detail="Names required") for n in (body.old_name, body.new_name): if ".." in n or "/" in n or "\\" in n or not path_safe_check(n): raise HTTPException(status_code=400, detail="Invalid name") old_full = os.path.join(parent, body.old_name) new_full = os.path.join(parent, body.new_name) if not os.path.exists(old_full): raise HTTPException(status_code=404, detail="Not found") if os.path.exists(new_full): raise HTTPException(status_code=400, detail="Target already exists") try: os.rename(old_full, new_full) except OSError as e: raise HTTPException(status_code=500, detail=str(e)) return {"status": True, "msg": "Renamed"} @router.post("/delete") async def files_delete( body: DeleteRequest, current_user: User = Depends(get_current_user), ): """Delete file or directory""" try: parent = _resolve_path(body.path) except HTTPException: raise if not body.name or ".." in body.name or "/" in body.name or "\\" in body.name: raise HTTPException(status_code=400, detail="Invalid name") full = os.path.join(parent, body.name) if not os.path.exists(full): raise HTTPException(status_code=404, detail="Not found") try: if body.is_dir: import shutil shutil.rmtree(full) else: os.remove(full) except OSError as e: raise HTTPException(status_code=500, detail=str(e)) return {"status": True, "msg": "Deleted"} class WriteFileRequest(BaseModel): path: str content: str @router.post("/write") async def files_write( body: WriteFileRequest, current_user: User = Depends(get_current_user), ): """Write text file content""" try: full = _resolve_path(body.path) except HTTPException: raise if os.path.isdir(full): raise HTTPException(status_code=400, detail="Cannot write to directory") if not write_file(full, body.content): raise HTTPException(status_code=500, detail="Failed to write file") return {"status": True, "msg": "Saved"}