From 28e3062bef9f988dee67fac0c7ce5c9e3c461298 Mon Sep 17 00:00:00 2001
From: arabcoders
Date: Mon, 1 Sep 2025 19:35:25 +0300
Subject: [PATCH] Enhance file browser API and dialog components
---
app/routes/api/browser.py | 141 +++++++++++++++++--------
ui/app/components/Dialog.vue | 9 +-
ui/app/composables/useDialog.ts | 4 +
ui/app/pages/browser/[...slug].vue | 161 +++++++++++++++++++++++++----
4 files changed, 250 insertions(+), 65 deletions(-)
diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py
index f41d1126..42075f85 100644
--- a/app/routes/api/browser.py
+++ b/app/routes/api/browser.py
@@ -135,7 +135,24 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
req_path: str = request.match_info.get("path")
req_path: str = "/" if not req_path else unquote_plus(req_path)
- test: Path = Path(config.download_path).joinpath(req_path)
+ # Normalize requested path to always be inside download root.
+ raw_req: str = (req_path or "").strip()
+ root_dir: Path = Path(config.download_path).resolve()
+ if raw_req in ("", "/"):
+ test: Path = root_dir
+ rel_for_listing = "/"
+ else:
+ # Strip leading slash so joinpath doesn't ignore the base path.
+ test = root_dir.joinpath(raw_req.lstrip("/")).resolve(strict=False)
+ rel_for_listing = raw_req.lstrip("/")
+
+ try:
+ test.relative_to(root_dir)
+ except Exception:
+ return web.json_response(
+ data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
+ )
+
if not test.exists():
return web.json_response(
data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code
@@ -149,8 +166,8 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
try:
return web.json_response(
data={
- "path": req_path,
- "contents": get_files(base_path=Path(config.download_path), dir=req_path),
+ "path": rel_for_listing,
+ "contents": get_files(base_path=root_dir, dir=rel_for_listing),
},
status=web.HTTPOk.status_code,
dumps=encoder.encode,
@@ -235,13 +252,14 @@ async def path_actions(request: Request, config: Config) -> Response:
rel: str = str(op_path).strip("/")
except Exception:
rel = str(op_path)
- entry: dict = {
- "path": rel if rel else "/",
- "status": ok,
- "error": error,
- }
+ if not rel or rel == ".":
+ rel = "/"
+
+ entry: dict = {"path": rel, "status": ok, "error": error}
+
if action:
entry["action"] = action
+
if extra:
norm_extra: dict = {}
for k, v in extra.items():
@@ -253,6 +271,7 @@ async def path_actions(request: Request, config: Config) -> Response:
else:
norm_extra[k] = v
entry.update(norm_extra)
+
operations_status.append(entry)
# perform each action
@@ -289,7 +308,7 @@ async def path_actions(request: Request, config: Config) -> Response:
continue
except Exception as e:
LOG.exception(e)
- record(req_path, ok=False, error=str(e), action=action)
+ record(req_path, ok=False, error=str(e), action=action, extra={"item": params})
continue
if "directory" != action:
@@ -301,6 +320,38 @@ async def path_actions(request: Request, config: Config) -> Response:
record(path, ok=False, error="Path outside download root.", action=action)
continue
+ if "directory" == action:
+ new_dir = params.get("new_dir").lstrip("/").strip()
+ if not new_dir:
+ record(path, ok=False, error="New directory name is required.", action=action)
+ continue
+
+ new_path = path.joinpath(*new_dir.split("/"))
+ try:
+ new_path = new_path.resolve(strict=False)
+ except Exception:
+ record(path, ok=False, error="Invalid directory path.", action=action, extra={"new_dir": new_dir})
+ continue
+
+ root_real = Path(config.download_path).resolve()
+ if not new_path.is_relative_to(root_real):
+ record(path, ok=False, error="Destination outside root.", action=action, extra={"new_dir": new_path})
+ continue
+
+ if new_path.exists():
+ dst = new_path.relative_to(config.download_path)
+ record(path, ok=False, error="Directory already exists.", action=action, extra={"new_dir": dst})
+ continue
+
+ try:
+ new_path.mkdir(parents=True, exist_ok=True)
+ record(path, ok=True, action=action, extra={"new_dir": new_path.relative_to(config.download_path)})
+ LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
+ except OSError as e:
+ LOG.exception(e)
+ record(path, ok=False, error=str(e), action=action, extra={"item": params})
+ continue
+
if "rename" == action:
new_name: str = params.get("new_name")
if not new_name:
@@ -315,16 +366,16 @@ async def path_actions(request: Request, config: Config) -> Response:
continue
try:
- path.rename(new_path)
+ renamed = path.rename(new_path)
LOG.info(
- f"Renamed '{path.relative_to(config.download_path)}' to '{new_path.relative_to(config.download_path)}'"
+ f"Renamed '{path.relative_to(config.download_path)}' to '{renamed.relative_to(config.download_path)}'"
)
except OSError as e:
LOG.exception(e)
- record(path, ok=False, error=str(e), action=action, extra={"new_path": new_path})
+ record(path, ok=False, error=str(e), action=action, extra={"item": params})
continue
else:
- record(path, ok=True, action=action, extra={"new_path": new_path})
+ record(path, ok=True, action=action, extra={"new_path": renamed})
if "delete" == action:
try:
@@ -340,7 +391,7 @@ async def path_actions(request: Request, config: Config) -> Response:
LOG.info(f"Deleted '{path.relative_to(config.download_path)}'")
except OSError as e:
LOG.exception(e)
- record(path, ok=False, error=str(e), action=action)
+ record(path, ok=False, error=str(e), action=action, extra={"item": params})
continue
else:
record(path, ok=True, action=action, extra={"deleted": True})
@@ -348,46 +399,54 @@ async def path_actions(request: Request, config: Config) -> Response:
if "move" == action:
new_path = params.get("new_path")
if not new_path:
- record(path, ok=False, error="New path is required for move action.", action=action)
+ record(path, ok=False, error="New path is required for move.", action=action, extra={"item": params})
continue
- new_path = Path(config.download_path).joinpath(unquote_plus(new_path))
- if not new_path.exists() or not new_path.is_dir():
- record(path, ok=False, error="Destination path is invalid.", action=action, extra={"move_to": new_path})
- continue
+ raw_new: str = unquote_plus(str(new_path)).strip()
+ target_dir = (
+ Path(config.download_path)
+ if not raw_new or raw_new in ("/", ".")
+ else Path(config.download_path).joinpath(raw_new.lstrip("/"))
+ )
try:
- dest = new_path.joinpath(path.name)
- path.rename(dest)
- except OSError as e:
- LOG.exception(e)
- record(path, ok=False, error=str(e), action=action, extra={"moved_to": dest})
- continue
- else:
- record(path, ok=True, action=action, extra={"moved_to": dest})
-
- if "directory" == action:
- new_dir = params.get("new_dir").lstrip("/").strip()
- if not new_dir:
- record(path, ok=False, error="New directory name is required for directory action.", action=action)
- continue
-
- new_path = path.joinpath(*new_dir.split("/"))
- if new_path.exists():
+ target_dir = target_dir.resolve()
+ except Exception:
record(
- new_path, ok=False, error="Directory already exists.", action=action, extra={"created": new_path}
+ path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
)
continue
+ root_real = Path(config.download_path).resolve()
+ if not target_dir.exists() or not target_dir.is_dir():
+ record(
+ path, ok=False, error="Destination path is invalid.", action=action, extra={"new_path": target_dir}
+ )
+ continue
+
+ if not target_dir.is_relative_to(root_real):
+ record(
+ path,
+ ok=False,
+ error="Destination outside download root.",
+ action=action,
+ extra={"new_path": target_dir},
+ )
+ continue
+
+ if path.parent == target_dir:
+ record(path, ok=False, error="Source and destination are the same.", action=action)
+ continue
+
try:
- new_path.mkdir(parents=True, exist_ok=True)
- LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'")
+ dest = target_dir.joinpath(path.name)
+ path.rename(dest)
except OSError as e:
LOG.exception(e)
- record(new_path, ok=False, error=str(e), action=action, extra={"created": new_path})
+ record(path, ok=False, error=str(e), action=action, extra={"item": params})
continue
else:
- record(new_path, ok=True, action=action, extra={"created": new_path})
+ record(path, ok=True, action=action, extra={"new_path": dest})
return web.json_response(data=operations_status, status=web.HTTPOk.status_code)
diff --git a/ui/app/components/Dialog.vue b/ui/app/components/Dialog.vue
index 330a5789..eada7cb3 100644
--- a/ui/app/components/Dialog.vue
+++ b/ui/app/components/Dialog.vue
@@ -56,6 +56,8 @@
+