diff --git a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc index 6953a9a..c80d222 100644 Binary files a/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc and b/webui/backend/app/api/__pycache__/routes_files.cpython-313.pyc differ diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index f40b28b..ad9c2dc 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -61,6 +61,19 @@ async def info( return service.info(path=path) +@router.get("/download") +async def download( + path: str, + service: FileOpsService = Depends(get_file_ops_service), +) -> StreamingResponse: + prepared = service.prepare_download(path=path) + return StreamingResponse( + prepared["content"], + headers=prepared["headers"], + media_type=prepared["content_type"], + ) + + @router.get("/video") async def video( path: str, diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 4ff0c43..1e16a31 100644 Binary files a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc and b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc differ diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 7aa8637..8b560a8 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -353,6 +353,32 @@ class FileOpsService: height=metadata["height"], ) + def prepare_download(self, path: str) -> dict: + resolved_target = self._path_guard.resolve_existing_path(path) + + if resolved_target.absolute.is_dir(): + raise AppError( + code="type_conflict", + message="Source must be a file", + status_code=409, + details={"path": resolved_target.relative}, + ) + if not resolved_target.absolute.is_file(): + raise AppError( + code="type_conflict", + message="Unsupported path type for download", + status_code=409, + details={"path": resolved_target.relative}, + ) + + return { + "content": self._filesystem.stream_file(resolved_target.absolute), + "headers": { + "Content-Disposition": f'attachment; filename="{resolved_target.absolute.name}"', + }, + "content_type": self._content_type_for(resolved_target.absolute) or "application/octet-stream", + } + def save(self, path: str, content: str, expected_modified: str) -> SaveResponse: resolved_target = self._path_guard.resolve_existing_path(path) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 320bdc4..6a2df30 100644 Binary files a/webui/backend/data/tasks.db and b/webui/backend/data/tasks.db differ diff --git a/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc new file mode 100644 index 0000000..815dd44 Binary files /dev/null and b/webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc index 63cb2b1..1317c6b 100644 Binary files a/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc and b/webui/backend/tests/golden/__pycache__/test_ui_smoke_golden.cpython-313.pyc differ diff --git a/webui/backend/tests/golden/test_api_download_golden.py b/webui/backend/tests/golden/test_api_download_golden.py new file mode 100644 index 0000000..3f487e1 --- /dev/null +++ b/webui/backend/tests/golden/test_api_download_golden.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_file_ops_service +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.file_ops_service import FileOpsService + + +class DownloadApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter()) + + async def _override_file_ops_service() -> FileOpsService: + return service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _get(self, url: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get(url) + + return asyncio.run(_run()) + + def test_download_success_for_allowed_file(self) -> None: + src = self.root / "report.txt" + src.write_text("hello download", encoding="utf-8") + + response = self._get("/api/files/download?path=storage1/report.txt") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"hello download") + self.assertIn('attachment; filename="report.txt"', response.headers.get("content-disposition", "")) + self.assertEqual(response.headers.get("content-type"), "text/plain; charset=utf-8") + + def test_download_directory_type_conflict(self) -> None: + (self.root / "docs").mkdir() + + response = self._get("/api/files/download?path=storage1/docs") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_download_path_not_found(self) -> None: + response = self._get("/api/files/download?path=storage1/missing.txt") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_download_invalid_root_alias(self) -> None: + response = self._get("/api/files/download?path=unknown/file.txt") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_download_traversal_blocked(self) -> None: + response = self._get("/api/files/download?path=storage1/../etc/passwd") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 5b67d96..f39a314 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -73,6 +73,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="context-menu-target"', body) self.assertIn('id="context-menu-open-btn"', body) self.assertIn('id="context-menu-edit-btn"', body) + self.assertIn('id="context-menu-download-btn"', body) self.assertIn('id="context-menu-rename-btn"', body) self.assertIn('id="context-menu-copy-btn"', body) self.assertIn('id="context-menu-move-btn"', body) @@ -214,9 +215,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function openContextMenu(pane, entry, event)', app_js) self.assertIn('function closeContextMenu()', app_js) self.assertIn('function isOpenableSelection(item)', app_js) + self.assertIn('async function downloadFileRequest(path)', app_js) self.assertIn('function applyContextMenuSelection()', app_js) self.assertIn('function startContextMenuOpen()', app_js) self.assertIn('function startContextMenuEdit()', app_js) + self.assertIn('function startContextMenuDownload()', app_js) self.assertIn('function startContextMenuRename()', app_js) self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuMove()', app_js) @@ -236,6 +239,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('const editableSingle = items.length === 1 && isEditableSelection(items[0]);', app_js) self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");', app_js) self.assertIn('elements.editButton.disabled = !editableSingle;', app_js) + self.assertIn('const downloadableSingle = items.length === 1 && items[0].kind === "file";', app_js) + self.assertIn('elements.downloadButton.classList.toggle("hidden", !downloadableSingle);', app_js) + self.assertIn('elements.downloadButton.disabled = !downloadableSingle;', app_js) self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js) self.assertIn('elements.copyButton.classList.remove("hidden");', app_js) self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js) @@ -244,6 +250,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js) self.assertIn('openCurrentDirectory();', app_js) self.assertIn('openEditor();', app_js) + self.assertIn('downloadFileRequest(selected.path);', app_js) + self.assertIn('anchor.download = selected.name;', app_js) self.assertIn('openRenamePopup();', app_js) self.assertIn('startCopySelected();', app_js) self.assertIn('openF6Flow();', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index b06523d..8f7fac8 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -328,6 +328,7 @@ function contextMenuElements() { target: document.getElementById("context-menu-target"), openButton: document.getElementById("context-menu-open-btn"), editButton: document.getElementById("context-menu-edit-btn"), + downloadButton: document.getElementById("context-menu-download-btn"), renameButton: document.getElementById("context-menu-rename-btn"), copyButton: document.getElementById("context-menu-copy-btn"), moveButton: document.getElementById("context-menu-move-btn"), @@ -383,12 +384,15 @@ function openContextMenu(pane, entry, event) { const isMulti = items.length > 1; const openableSingle = items.length === 1 && isOpenableSelection(items[0]); const editableSingle = items.length === 1 && isEditableSelection(items[0]); + const downloadableSingle = items.length === 1 && items[0].kind === "file"; elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; elements.openButton.classList.toggle("hidden", isMulti); elements.openButton.disabled = !openableSingle; elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file"); elements.editButton.disabled = !editableSingle; + elements.downloadButton.classList.toggle("hidden", !downloadableSingle); + elements.downloadButton.disabled = !downloadableSingle; elements.renameButton.classList.toggle("hidden", isMulti); elements.copyButton.classList.remove("hidden"); elements.copyButton.disabled = items.length === 0; @@ -490,6 +494,40 @@ function startContextMenuEdit() { openEditor(); } +async function startDownloadSelected() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { + return; + } + const selected = selectedItems[0]; + try { + const blob = await downloadFileRequest(selected.path); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = selected.name; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + setStatus(`Download started: ${selected.name}`); + } catch (err) { + setActionError("Download", err); + } +} + +function startContextMenuDownload() { + if (contextMenuElements().downloadButton?.disabled) { + return; + } + if (!applyContextMenuSelection()) { + closeContextMenu(); + return; + } + closeContextMenu(); + startDownloadSelected(); +} + function startContextMenuProperties() { if (contextMenuElements().propertiesButton?.disabled) { return; @@ -744,6 +782,15 @@ function createApiError(response, data) { return err; } +async function downloadFileRequest(path) { + const response = await fetch(`/api/files/download?${new URLSearchParams({ path }).toString()}`); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw createApiError(response, data); + } + return response.blob(); +} + async function uploadFileRequest(targetPath, file, overwrite = false) { const formData = new FormData(); formData.append("target_path", targetPath); @@ -3836,6 +3883,9 @@ function setupEvents() { if (contextMenu.editButton) { contextMenu.editButton.onclick = startContextMenuEdit; } + if (contextMenu.downloadButton) { + contextMenu.downloadButton.onclick = startContextMenuDownload; + } if (contextMenu.copyButton) { contextMenu.copyButton.onclick = startContextMenuCopy; } diff --git a/webui/html/index.html b/webui/html/index.html index b28f6f6..656a9c0 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -124,6 +124,7 @@
+