Files
yakpanel-core/YakPanel-server/backend/app/api/files.py

588 lines
19 KiB
Python
Raw Normal View History

2026-04-07 02:04:22 +05:30
"""YakPanel - File manager API"""
import os
2026-04-07 09:46:22 +05:30
import shutil
import stat
import zipfile
from datetime import datetime, timezone
2026-04-07 02:04:22 +05:30
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse
2026-04-07 09:46:22 +05:30
from pydantic import BaseModel, Field
2026-04-07 02:04:22 +05:30
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:
2026-04-07 05:26:12 +05:30
"""
Resolve API path to an OS path.
On Linux/macOS: path is an absolute POSIX path from filesystem root (/) so admins
2026-04-07 13:23:35 +05:30
can browse the whole server (typical expectation for a full-server admin file manager).
2026-04-07 05:26:12 +05:30
On Windows (dev): paths stay sandboxed under www_root / setup_path.
"""
2026-04-07 02:04:22 +05:30
if ".." in path:
2026-04-07 05:17:40 +05:30
raise HTTPException(status_code=400, detail="Path traversal not allowed")
2026-04-07 05:26:12 +05:30
raw = path.strip().replace("\\", "/")
if os.name == "nt":
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]
norm_path = raw.strip("/")
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
# POSIX: absolute paths from /
if not raw or raw == "/":
return "/"
if not raw.startswith("/"):
raw = "/" + raw
full = os.path.normpath(raw)
if not full.startswith("/"):
raise HTTPException(status_code=400, detail="Invalid path")
2026-04-07 02:04:22 +05:30
return full
2026-04-07 09:46:22 +05:30
def _stat_entry(path: str, name: str) -> dict | None:
item_path = os.path.join(path, name)
try:
st = os.stat(item_path, follow_symlinks=False)
except OSError:
return None
is_dir = os.path.isdir(item_path)
owner = str(st.st_uid)
group = str(st.st_gid)
try:
import pwd
import grp
owner = pwd.getpwuid(st.st_uid).pw_name
group = grp.getgrgid(st.st_gid).gr_name
except (ImportError, KeyError, OSError):
pass
try:
sym = stat.filemode(st.st_mode)
except Exception:
sym = ""
return {
"name": name,
"is_dir": is_dir,
"size": st.st_size if not is_dir else 0,
"mtime": datetime.fromtimestamp(st.st_mtime, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
"mtime_ts": int(st.st_mtime),
"mode": format(st.st_mode & 0o777, "o"),
"mode_symbolic": sym,
"owner": owner,
"group": group,
}
2026-04-07 02:04:22 +05:30
@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):
2026-04-07 05:17:40 +05:30
raise HTTPException(status_code=404, detail="Not a directory")
2026-04-07 02:04:22 +05:30
items = []
2026-04-07 05:26:12 +05:30
try:
names = os.listdir(full)
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except FileNotFoundError:
raise HTTPException(status_code=404, detail="Not found")
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
2026-04-07 09:46:22 +05:30
for name in sorted(names, key=str.lower):
row = _stat_entry(full, name)
if row:
items.append(row)
2026-04-07 05:26:12 +05:30
display_path = full if full == "/" else full.rstrip("/")
return {"path": display_path, "items": items}
2026-04-07 02:04:22 +05:30
2026-04-07 09:46:22 +05:30
@router.get("/dir-size")
async def files_dir_size(
path: str,
current_user: User = Depends(get_current_user),
):
"""Return total byte size of directory tree (may be slow on large trees)."""
try:
full = _resolve_path(path)
except HTTPException:
raise
if not os.path.isdir(full):
raise HTTPException(status_code=400, detail="Not a directory")
total = 0
try:
for root, _dirs, files in os.walk(full):
for fn in files:
fp = os.path.join(root, fn)
try:
total += os.path.getsize(fp)
except OSError:
pass
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
return {"size": total}
@router.get("/search")
async def files_search(
q: str,
path: str = "/",
max_results: int = 200,
current_user: User = Depends(get_current_user),
):
"""Find files/folders whose name contains q (case-insensitive), walking from path."""
if not q or not q.strip():
return {"path": path, "results": []}
try:
root = _resolve_path(path)
except HTTPException:
raise
if not os.path.isdir(root):
raise HTTPException(status_code=400, detail="Not a directory")
qn = q.strip().lower()
results: list[dict] = []
skip_prefixes = tuple()
if os.name != "nt" and root in ("/", "//"):
skip_prefixes = ("/proc", "/sys", "/dev")
def should_skip(p: str) -> bool:
ap = os.path.abspath(p)
return any(ap == sp or ap.startswith(sp + os.sep) for sp in skip_prefixes)
try:
for dirpath, dirnames, filenames in os.walk(root, topdown=True):
if len(results) >= max_results:
break
if should_skip(dirpath):
dirnames[:] = []
continue
try:
for dn in list(dirnames):
if len(results) >= max_results:
break
if qn in dn.lower():
rel = os.path.join(dirpath, dn)
results.append({"path": rel.replace("\\", "/"), "name": dn, "is_dir": True})
for fn in filenames:
if len(results) >= max_results:
break
if qn in fn.lower():
rel = os.path.join(dirpath, fn)
results.append({"path": rel.replace("\\", "/"), "name": fn, "is_dir": False})
except OSError:
continue
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
return {"path": path, "query": q, "results": results[:max_results]}
2026-04-07 02:04:22 +05:30
@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"}
2026-04-07 09:46:22 +05:30
class TouchRequest(BaseModel):
path: str
name: str
class ChmodRequest(BaseModel):
file_path: str
mode: str = Field(description="Octal mode e.g. 0644 or 755")
recursive: bool = False
class CopyRequest(BaseModel):
path: str
name: str
dest_path: str
dest_name: str | None = None
class MoveRequest(BaseModel):
path: str
name: str
dest_path: str
dest_name: str | None = None
class CompressRequest(BaseModel):
path: str
names: list[str]
archive_name: str
@router.post("/touch")
async def files_touch(
body: TouchRequest,
current_user: User = Depends(get_current_user),
):
"""Create an empty file in directory path."""
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")
if not path_safe_check(body.name):
raise HTTPException(status_code=400, detail="Invalid name")
full = os.path.join(parent, body.name)
if os.path.exists(full):
raise HTTPException(status_code=400, detail="Already exists")
try:
open(full, "a", encoding="utf-8").close()
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Created"}
@router.post("/chmod")
async def files_chmod(
body: ChmodRequest,
current_user: User = Depends(get_current_user),
):
"""chmod a file or directory. mode is octal string (644, 0755)."""
try:
full = _resolve_path(body.file_path)
except HTTPException:
raise
if not os.path.exists(full):
raise HTTPException(status_code=404, detail="Not found")
try:
mode = int(body.mode.strip(), 8)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid mode")
if mode < 0 or mode > 0o7777:
raise HTTPException(status_code=400, detail="Invalid mode")
def _chmod_one(p: str) -> None:
os.chmod(p, mode)
try:
if body.recursive and os.path.isdir(full):
for root, dirs, files in os.walk(full):
for d in dirs:
_chmod_one(os.path.join(root, d))
for f in files:
_chmod_one(os.path.join(root, f))
_chmod_one(full)
else:
_chmod_one(full)
except PermissionError:
raise HTTPException(status_code=403, detail="Permission denied")
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Permissions updated"}
@router.post("/copy")
async def files_copy(
body: CopyRequest,
current_user: User = Depends(get_current_user),
):
"""Copy file or directory into dest_path (optionally new name)."""
try:
src_parent = _resolve_path(body.path)
dest_parent = _resolve_path(body.dest_path)
except HTTPException:
raise
if not body.name or ".." in body.name:
raise HTTPException(status_code=400, detail="Invalid name")
src = os.path.join(src_parent, body.name)
dest_name = body.dest_name or body.name
if ".." in dest_name or "/" in dest_name or "\\" in dest_name:
raise HTTPException(status_code=400, detail="Invalid destination name")
dest = os.path.join(dest_parent, dest_name)
if not os.path.exists(src):
raise HTTPException(status_code=404, detail="Source not found")
if os.path.exists(dest):
raise HTTPException(status_code=400, detail="Destination already exists")
if not os.path.isdir(dest_parent):
raise HTTPException(status_code=400, detail="Destination parent must be a directory")
try:
if os.path.isdir(src):
shutil.copytree(src, dest, symlinks=True)
else:
shutil.copy2(src, dest)
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Copied"}
@router.post("/move")
async def files_move(
body: MoveRequest,
current_user: User = Depends(get_current_user),
):
"""Move (rename) file or directory to another folder."""
try:
src_parent = _resolve_path(body.path)
dest_parent = _resolve_path(body.dest_path)
except HTTPException:
raise
if not body.name or ".." in body.name:
raise HTTPException(status_code=400, detail="Invalid name")
src = os.path.join(src_parent, body.name)
dest_name = body.dest_name or body.name
if ".." in dest_name or "/" in dest_name or "\\" in dest_name:
raise HTTPException(status_code=400, detail="Invalid destination name")
dest = os.path.join(dest_parent, dest_name)
if not os.path.exists(src):
raise HTTPException(status_code=404, detail="Source not found")
if os.path.exists(dest):
raise HTTPException(status_code=400, detail="Destination already exists")
if not os.path.isdir(dest_parent):
raise HTTPException(status_code=400, detail="Destination parent must be a directory")
try:
shutil.move(src, dest)
except OSError as e:
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Moved"}
@router.post("/compress")
async def files_compress(
body: CompressRequest,
current_user: User = Depends(get_current_user),
):
"""Create a zip in path containing named files/folders."""
try:
parent = _resolve_path(body.path)
except HTTPException:
raise
if not os.path.isdir(parent):
raise HTTPException(status_code=400, detail="Not a directory")
if not body.names:
raise HTTPException(status_code=400, detail="Nothing to compress")
name = (body.archive_name or "archive").strip()
if not name.lower().endswith(".zip"):
name += ".zip"
if ".." in name or "/" in name or "\\" in name:
raise HTTPException(status_code=400, detail="Invalid archive name")
zip_path = os.path.join(parent, name)
if os.path.exists(zip_path):
raise HTTPException(status_code=400, detail="Archive already exists")
try:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for entry in body.names:
if not entry or ".." in entry or "/" in entry or "\\" in entry:
continue
src = os.path.join(parent, entry)
if not os.path.exists(src):
continue
if os.path.isfile(src):
zf.write(src, arcname=entry.replace("\\", "/"))
elif os.path.isdir(src):
for root, _dirs, files in os.walk(src):
for f in files:
fp = os.path.join(root, f)
zn = os.path.relpath(fp, parent).replace("\\", "/")
zf.write(fp, zn)
except OSError as e:
if os.path.exists(zip_path):
try:
os.remove(zip_path)
except OSError:
pass
raise HTTPException(status_code=500, detail=str(e))
return {"status": True, "msg": "Compressed", "archive": name}