new changes
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
"""YakPanel - File manager API"""
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.config import get_runtime_config
|
||||
from app.core.utils import read_file, write_file, path_safe_check
|
||||
@@ -56,6 +61,40 @@ def _resolve_path(path: str) -> str:
|
||||
return full
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def files_list(
|
||||
path: str = "/",
|
||||
@@ -77,21 +116,94 @@ async def files_list(
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
except OSError as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
for name in names:
|
||||
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
|
||||
for name in sorted(names, key=str.lower):
|
||||
row = _stat_entry(full, name)
|
||||
if row:
|
||||
items.append(row)
|
||||
display_path = full if full == "/" else full.rstrip("/")
|
||||
return {"path": display_path, "items": items}
|
||||
|
||||
|
||||
@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]}
|
||||
|
||||
|
||||
@router.get("/read")
|
||||
async def files_read(
|
||||
path: str,
|
||||
@@ -265,3 +377,211 @@ async def files_write(
|
||||
if not write_file(full, body.content):
|
||||
raise HTTPException(status_code=500, detail="Failed to write file")
|
||||
return {"status": True, "msg": "Saved"}
|
||||
|
||||
|
||||
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}
|
||||
|
||||
Reference in New Issue
Block a user