diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py index 1d09876a..f41d1126 100644 --- a/app/routes/api/browser.py +++ b/app/routes/api/browser.py @@ -160,8 +160,8 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) -@route("POST", "api/file/action/{path:.*}", "browser.actions") -async def path_action(request: Request, config: Config) -> Response: +@route("POST", "api/file/actions", "browser.file.actions") +async def path_actions(request: Request, config: Config) -> Response: """ Browser actions. @@ -184,134 +184,212 @@ async def path_action(request: Request, config: Config) -> Response: rootPath: Path = Path(config.download_path) try: - params = await request.json() - if not params or not isinstance(params, dict): - return web.json_response(data={"error": "Invalid parameters."}, status=web.HTTPBadRequest.status_code) + actions = await request.json() + if not actions or not isinstance(actions, list): + return web.json_response( + data={"error": "Invalid parameters expecting list of dicts."}, status=web.HTTPBadRequest.status_code + ) except Exception as e: LOG.exception(e) return web.json_response(data={"error": "Invalid JSON."}, status=web.HTTPBadRequest.status_code) - action = params.get("action").lower() - if not action: - return web.json_response(data={"error": "Action is required."}, status=web.HTTPBadRequest.status_code) - - 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) - if req_path and "/" != req_path: - test = test.joinpath(req_path) - - if not test.exists(): - return web.json_response( - data={"error": f"path '{req_path}' does not exist."}, status=web.HTTPNotFound.status_code - ) - - try: - path, status = get_file(download_path=config.download_path, file=str(test.relative_to(config.download_path))) - if web.HTTPOk.status_code != status: + # validate each action before performing any operations + for params in actions: + action = params.get("action").lower() + if not action or action not in ["rename", "delete", "move", "directory"]: return web.json_response( - data={"error": f"File {status}: '{test}' does not exist."}, status=web.HTTPNotFound.status_code - ) - if not path.is_relative_to(rootPath): - return web.json_response( - data={"error": "Cannot perform actions on files outside the download path."}, - status=web.HTTPBadRequest.status_code, - ) - except Exception as e: - LOG.exception(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) - - if "directory" != action: - if path == rootPath: - return web.json_response( - data={"error": "Cannot perform actions on the root directory."}, status=web.HTTPBadRequest.status_code - ) - - if not path.is_relative_to(rootPath): - return web.json_response( - data={"error": "Cannot perform actions on files outside the download path."}, + data={"error": f"Invalid action '{action}'. Must be one of rename, delete, move, directory."}, status=web.HTTPBadRequest.status_code, ) - if "rename" == action: - new_name = params.get("new_name") - if not new_name: - return web.json_response(data={"error": "New name is required."}, status=web.HTTPBadRequest.status_code) - - new_path = path.parent.joinpath(new_name) - if new_path.exists(): + if "rename" == action and not params.get("new_name"): return web.json_response( - data={"error": f"File '{new_name}' already exists."}, status=web.HTTPConflict.status_code + data={"error": "New name is required for rename action."}, status=web.HTTPBadRequest.status_code ) - try: - path.rename(new_path) - LOG.info( - f"Renamed '{path.relative_to(config.download_path)}' to '{test.relative_to(config.download_path)}'" + if "move" == action and not params.get("new_path"): + return web.json_response( + data={"error": "New path is required for move action."}, status=web.HTTPBadRequest.status_code ) - except OSError as e: - LOG.exception(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) - if "delete" == action: + if "directory" == action and not params.get("new_dir"): + return web.json_response( + data={"error": "New directory name is required for directory action."}, + status=web.HTTPBadRequest.status_code, + ) + + operations_status: list[dict[str, Any]] = [] + + def record( + op_path: str | Path, + *, + ok: bool, + error: str | None = None, + action: str | None = None, + extra: dict | None = None, + ) -> None: try: - if not path.exists(): - return web.json_response( - data={"error": f"Path '{path}' does not exist."}, status=web.HTTPNotFound.status_code - ) - - if path.is_dir(): - delete_dir(path) + if isinstance(op_path, Path): + rel: str = str(op_path.relative_to(rootPath)).strip("/") else: - path.unlink(missing_ok=True) + rel: str = str(op_path).strip("/") + except Exception: + rel = str(op_path) + entry: dict = { + "path": rel if rel else "/", + "status": ok, + "error": error, + } + if action: + entry["action"] = action + if extra: + norm_extra: dict = {} + for k, v in extra.items(): + if isinstance(v, Path): + try: + norm_extra[k] = str(v.relative_to(rootPath)).strip("/") or "/" + except Exception: + norm_extra[k] = str(v) + else: + norm_extra[k] = v + entry.update(norm_extra) + operations_status.append(entry) - LOG.info(f"Deleted '{path.relative_to(config.download_path)}'") - except OSError as e: - LOG.exception(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + # perform each action + for params in actions: + req_path: str = params.get("path") + if not req_path: + record("no_path", ok=False, error="Path is required.", extra={"item": params}) + continue - if "move" == action: - new_path = params.get("new_path") - if not new_path: - return web.json_response(data={"error": "New path is required."}, status=web.HTTPBadRequest.status_code) + action: str = params.get("action", "").lower() + if not action: + record(req_path, ok=False, error="Action is required.", 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(): - return web.json_response( - data={"error": f"New path '{new_path}' does not exist or is not a directory."}, - status=web.HTTPNotFound.status_code, - ) + req_path: str = "/" if not req_path else unquote_plus(req_path) + + test: Path = Path(config.download_path) + if req_path and "/" != req_path: + test = test.joinpath(req_path) + + if not test.exists(): + record(req_path, ok=False, error=f"path '{req_path}' does not exist.", action=action) + continue try: - path.rename(new_path.joinpath(path.name)) - except OSError as e: + path, status = get_file( + download_path=config.download_path, file=str(test.relative_to(config.download_path)) + ) + if web.HTTPOk.status_code != status: + record(req_path, ok=False, error="Invalid path.", action=action) + continue + if not path.is_relative_to(rootPath): + record(req_path, ok=False, error="Path outside download root.", action=action) + continue + except Exception as e: LOG.exception(e) - return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + record(req_path, ok=False, error=str(e), action=action) + continue - if "directory" == action: - new_dir = params.get("new_dir").lstrip("/").strip() - if not new_dir: - return web.json_response( - data={"error": "New directory name is required."}, status=web.HTTPBadRequest.status_code - ) + if "directory" != action: + if path == rootPath: + record(path, ok=False, error="Cannot operate on root directory.", action=action) + continue - new_path = path.joinpath(*new_dir.split("/")) - if new_path.exists(): - return web.json_response( - data={"error": f"Directory '{new_dir}' already exists."}, status=web.HTTPConflict.status_code - ) + if not path.is_relative_to(rootPath): + record(path, ok=False, error="Path outside download root.", action=action) + continue - try: - new_path.mkdir(parents=True, exist_ok=True) - LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'") - except OSError as e: - LOG.exception(e) - return web.json_response( - data={"error": str(e), "path": str(test)}, status=web.HTTPInternalServerError.status_code - ) + if "rename" == action: + new_name: str = params.get("new_name") + if not new_name: + record(path, ok=False, error="New name is required for rename action.", action=action) + continue - return web.Response(status=web.HTTPOk.status_code) + new_path: Path = path.parent.joinpath(new_name) + if new_path.exists(): + record( + new_path, ok=False, error="Destination already exists.", action=action, extra={"new_path": new_path} + ) + continue + + try: + path.rename(new_path) + LOG.info( + f"Renamed '{path.relative_to(config.download_path)}' to '{new_path.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}) + continue + else: + record(path, ok=True, action=action, extra={"new_path": new_path}) + + if "delete" == action: + try: + if not path.exists(): + record(path, ok=False, error="Path does not exist.", action=action) + continue + + if path.is_dir(): + delete_dir(path) + else: + path.unlink(missing_ok=True) + + 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) + continue + else: + record(path, ok=True, action=action, extra={"deleted": True}) + + 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) + 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 + + 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(): + record( + new_path, ok=False, error="Directory already exists.", action=action, extra={"created": new_path} + ) + continue + + try: + new_path.mkdir(parents=True, exist_ok=True) + LOG.info(f"Created directory '{new_path.relative_to(config.download_path)}'") + except OSError as e: + LOG.exception(e) + record(new_path, ok=False, error=str(e), action=action, extra={"created": new_path}) + continue + else: + record(new_path, ok=True, action=action, extra={"created": new_path}) + + return web.json_response(data=operations_status, status=web.HTTPOk.status_code) @route("POST", "api/file/download", "browser.download.prepare") diff --git a/ui/app/components/Dialog.vue b/ui/app/components/Dialog.vue index 2c4809ae..330a5789 100644 --- a/ui/app/components/Dialog.vue +++ b/ui/app/components/Dialog.vue @@ -71,7 +71,9 @@