feat: B3 uit voor veilige archive-downloads - cancel knop toegevoegd

This commit is contained in:
kodi
2026-03-14 14:39:57 +01:00
parent d463b3977d
commit 2981ac2796
24 changed files with 471 additions and 37 deletions
+9 -1
View File
@@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from starlette.background import BackgroundTask from starlette.background import BackgroundTask
from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, UploadResponse, ViewResponse from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, TaskDetailResponse, UploadResponse, ViewResponse
from backend.app.dependencies import get_archive_download_task_service, get_file_ops_service from backend.app.dependencies import get_archive_download_task_service, get_file_ops_service
from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.file_ops_service import FileOpsService from backend.app.services.file_ops_service import FileOpsService
@@ -100,6 +100,14 @@ async def archive_download(
) )
@router.post("/download/archive/{task_id}/cancel", response_model=TaskDetailResponse)
async def archive_cancel(
task_id: str,
service: ArchiveDownloadTaskService = Depends(get_archive_download_task_service),
) -> TaskDetailResponse:
return TaskDetailResponse(**service.cancel_archive_prepare_task(task_id=task_id))
@router.get("/video") @router.get("/video")
async def video( async def video(
path: str, path: str,
+1 -1
View File
@@ -6,7 +6,7 @@ from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
VALID_HISTORY_STATUSES = {"queued", "completed", "failed", "requested", "ready", "preflight_failed"} VALID_HISTORY_STATUSES = {"queued", "completed", "failed", "requested", "ready", "preflight_failed", "cancelled"}
VALID_HISTORY_OPERATIONS = {"mkdir", "rename", "delete", "copy", "move", "upload", "download"} VALID_HISTORY_OPERATIONS = {"mkdir", "rename", "delete", "copy", "move", "upload", "download"}
+59 -9
View File
@@ -6,7 +6,7 @@ from contextlib import contextmanager
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready"} VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready", "cancelled"}
VALID_OPERATIONS = {"copy", "move", "download"} VALID_OPERATIONS = {"copy", "move", "download"}
TASK_MIGRATION_COLUMNS: dict[str, str] = { TASK_MIGRATION_COLUMNS: dict[str, str] = {
"operation": "TEXT NOT NULL DEFAULT 'copy'", "operation": "TEXT NOT NULL DEFAULT 'copy'",
@@ -160,17 +160,18 @@ class TaskRepository:
done_items: int | None = None, done_items: int | None = None,
total_items: int | None = None, total_items: int | None = None,
current_item: str | None = None, current_item: str | None = None,
) -> None: ) -> bool:
started_at = self._now_iso() started_at = self._now_iso()
with self._connection() as conn: with self._connection() as conn:
conn.execute( cursor = conn.execute(
""" """
UPDATE tasks UPDATE tasks
SET status = ?, started_at = COALESCE(started_at, ?), done_items = ?, total_items = ?, current_item = ? SET status = ?, started_at = COALESCE(started_at, ?), done_items = ?, total_items = ?, current_item = ?
WHERE id = ? WHERE id = ? AND status = ?
""", """,
("preparing", started_at, done_items, total_items, current_item, task_id), ("preparing", started_at, done_items, total_items, current_item, task_id, "requested"),
) )
return cursor.rowcount > 0
def update_progress( def update_progress(
self, self,
@@ -215,17 +216,18 @@ class TaskRepository:
task_id: str, task_id: str,
done_items: int | None = None, done_items: int | None = None,
total_items: int | None = None, total_items: int | None = None,
) -> None: ) -> bool:
finished_at = self._now_iso() finished_at = self._now_iso()
with self._connection() as conn: with self._connection() as conn:
conn.execute( cursor = conn.execute(
""" """
UPDATE tasks UPDATE tasks
SET status = ?, finished_at = ?, done_items = ?, total_items = ?, current_item = NULL SET status = ?, finished_at = ?, done_items = ?, total_items = ?, current_item = NULL
WHERE id = ? WHERE id = ? AND status = ?
""", """,
("ready", finished_at, done_items, total_items, task_id), ("ready", finished_at, done_items, total_items, task_id, "preparing"),
) )
return cursor.rowcount > 0
def mark_failed( def mark_failed(
self, self,
@@ -260,6 +262,54 @@ class TaskRepository:
), ),
) )
def mark_failed_if_not_cancelled(
self,
task_id: str,
error_code: str,
error_message: str,
failed_item: str | None,
done_bytes: int | None,
total_bytes: int | None,
done_items: int | None = None,
total_items: int | None = None,
) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, error_code = ?, error_message = ?, failed_item = ?, done_bytes = ?, total_bytes = ?, done_items = ?, total_items = ?, current_item = NULL
WHERE id = ? AND status != ?
""",
(
"failed",
finished_at,
error_code,
error_message,
failed_item,
done_bytes,
total_bytes,
done_items,
total_items,
task_id,
"cancelled",
),
)
return cursor.rowcount > 0
def mark_cancelled(self, task_id: str) -> bool:
finished_at = self._now_iso()
with self._connection() as conn:
cursor = conn.execute(
"""
UPDATE tasks
SET status = ?, finished_at = ?, current_item = NULL
WHERE id = ? AND status IN (?, ?)
""",
("cancelled", finished_at, task_id, "requested", "preparing"),
)
return cursor.rowcount > 0
def _ensure_schema(self) -> None: def _ensure_schema(self) -> None:
db_path = Path(self._db_path) db_path = Path(self._db_path)
if db_path.parent and str(db_path.parent) not in {"", "."}: if db_path.parent and str(db_path.parent) not in {"", "."}:
@@ -17,6 +17,10 @@ from backend.app.tasks_runner import TaskRunner
ARCHIVE_DOWNLOAD_TTL_SECONDS = 30 * 60 ARCHIVE_DOWNLOAD_TTL_SECONDS = 30 * 60
class ArchivePrepareCancelled(Exception):
pass
class ArchiveDownloadTaskService: class ArchiveDownloadTaskService:
def __init__( def __init__(
self, self,
@@ -103,6 +107,13 @@ class ArchiveDownloadTaskService:
status_code=400, status_code=400,
details={"task_id": task_id}, details={"task_id": task_id},
) )
if task["status"] == "cancelled":
raise AppError(
code="download_cancelled",
message="Archive download was cancelled",
status_code=409,
details={"task_id": task_id},
)
if task["status"] != "ready": if task["status"] != "ready":
raise AppError( raise AppError(
code="download_not_ready", code="download_not_ready",
@@ -147,6 +158,58 @@ class ArchiveDownloadTaskService:
"content_type": "application/zip", "content_type": "application/zip",
} }
def cancel_archive_prepare_task(self, task_id: str) -> dict:
self.sweep_artifacts()
task = self._repository.get_task(task_id)
if not task:
raise AppError(
code="task_not_found",
message="Task was not found",
status_code=404,
details={"task_id": task_id},
)
if task["operation"] != "download":
raise AppError(
code="invalid_request",
message="Task is not an archive download",
status_code=400,
details={"task_id": task_id},
)
if task["status"] == "ready":
raise AppError(
code="download_not_cancellable",
message="Archive download is already ready",
status_code=409,
details={"task_id": task_id, "status": task["status"]},
)
if task["status"] in {"failed", "cancelled"}:
raise AppError(
code="download_not_cancellable",
message="Archive download cannot be cancelled",
status_code=409,
details={"task_id": task_id, "status": task["status"]},
)
if not self._repository.mark_cancelled(task_id):
current = self._repository.get_task(task_id)
current_status = current["status"] if current else task["status"]
raise AppError(
code="download_not_cancellable",
message="Archive download cannot be cancelled",
status_code=409,
details={"task_id": task_id, "status": current_status},
)
self._cleanup_task_artifacts(task_id)
self._update_history_cancelled(task_id)
cancelled_task = self._repository.get_task(task_id)
if not cancelled_task:
raise AppError(
code="task_not_found",
message="Task was not found",
status_code=404,
details={"task_id": task_id},
)
return cancelled_task
def sweep_artifacts(self) -> None: def sweep_artifacts(self) -> None:
self._artifact_root.mkdir(parents=True, exist_ok=True) self._artifact_root.mkdir(parents=True, exist_ok=True)
referenced_paths: set[Path] = set() referenced_paths: set[Path] = set()
@@ -177,37 +240,59 @@ class ArchiveDownloadTaskService:
total_items = len(target_paths) total_items = len(target_paths)
try: try:
self._repository.mark_preparing( self._raise_if_cancelled(task_id)
if not self._repository.mark_preparing(
task_id=task_id, task_id=task_id,
done_items=0, done_items=0,
total_items=total_items, total_items=total_items,
current_item=target_paths[0] if target_paths else None, current_item=target_paths[0] if target_paths else None,
) ):
self._raise_if_cancelled(task_id)
return
resolved_targets = [self._path_guard.resolve_existing_path(path) for path in target_paths] resolved_targets = [self._path_guard.resolve_existing_path(path) for path in target_paths]
self._raise_if_cancelled(task_id)
self._file_ops_service._validate_zip_download_archive_names(resolved_targets) self._file_ops_service._validate_zip_download_archive_names(resolved_targets)
self._file_ops_service._run_zip_download_preflight(resolved_targets) self._file_ops_service._run_zip_download_preflight(resolved_targets)
self._raise_if_cancelled(task_id)
with zipfile.ZipFile(partial_path, "w", compression=zipfile.ZIP_DEFLATED) as archive: with zipfile.ZipFile(partial_path, "w", compression=zipfile.ZIP_DEFLATED) as archive:
for resolved_target in resolved_targets: for index, resolved_target in enumerate(resolved_targets):
self._file_ops_service._write_download_target_to_zip(archive, resolved_target) self._raise_if_cancelled(task_id)
self._repository.update_progress(
task_id=task_id,
done_items=index,
total_items=total_items,
current_item=resolved_target.relative,
)
self._file_ops_service._write_download_target_to_zip(
archive,
resolved_target,
on_each_item=lambda: self._raise_if_cancelled(task_id),
)
self._raise_if_cancelled(task_id)
os.replace(partial_path, final_path) os.replace(partial_path, final_path)
self._raise_if_cancelled(task_id)
self._repository.upsert_artifact( self._repository.upsert_artifact(
task_id=task_id, task_id=task_id,
file_path=str(final_path), file_path=str(final_path),
file_name=archive_name, file_name=archive_name,
expires_at=self._expires_at_iso(), expires_at=self._expires_at_iso(),
) )
self._repository.mark_ready( if not self._repository.mark_ready(
task_id=task_id, task_id=task_id,
done_items=total_items, done_items=total_items,
total_items=total_items, total_items=total_items,
) ):
self._cleanup_task_artifacts(task_id)
self._raise_if_cancelled(task_id)
return
self._update_history_ready(task_id) self._update_history_ready(task_id)
except ArchivePrepareCancelled:
self._cleanup_task_artifacts(task_id)
except AppError as exc: except AppError as exc:
self._delete_artifact_record_and_file(task_id, str(partial_path)) self._cleanup_task_artifacts(task_id)
self._delete_artifact_record_and_file(task_id, str(final_path)) if self._repository.mark_failed_if_not_cancelled(
self._repository.mark_failed(
task_id=task_id, task_id=task_id,
error_code=exc.code, error_code=exc.code,
error_message=exc.message, error_message=exc.message,
@@ -216,12 +301,11 @@ class ArchiveDownloadTaskService:
total_bytes=None, total_bytes=None,
done_items=0, done_items=0,
total_items=total_items, total_items=total_items,
) ):
self._update_history_failed(task_id, exc.code, exc.message) self._update_history_failed(task_id, exc.code, exc.message)
except OSError as exc: except OSError as exc:
self._delete_artifact_record_and_file(task_id, str(partial_path)) self._cleanup_task_artifacts(task_id)
self._delete_artifact_record_and_file(task_id, str(final_path)) if self._repository.mark_failed_if_not_cancelled(
self._repository.mark_failed(
task_id=task_id, task_id=task_id,
error_code="io_error", error_code="io_error",
error_message=str(exc), error_message=str(exc),
@@ -230,9 +314,13 @@ class ArchiveDownloadTaskService:
total_bytes=None, total_bytes=None,
done_items=0, done_items=0,
total_items=total_items, total_items=total_items,
) ):
self._update_history_failed(task_id, "io_error", str(exc)) self._update_history_failed(task_id, "io_error", str(exc))
def _cleanup_task_artifacts(self, task_id: str) -> None:
self._delete_artifact_record_and_file(task_id, str(self._artifact_root / f"{task_id}.partial.zip"))
self._delete_artifact_record_and_file(task_id, str(self._artifact_root / f"{task_id}.zip"))
def _delete_artifact_record_and_file(self, task_id: str, file_path: str) -> None: def _delete_artifact_record_and_file(self, task_id: str, file_path: str) -> None:
self._repository.delete_artifact(task_id) self._repository.delete_artifact(task_id)
path = Path(file_path) path = Path(file_path)
@@ -254,6 +342,10 @@ class ArchiveDownloadTaskService:
error_message=error_message, error_message=error_message,
) )
def _update_history_cancelled(self, task_id: str) -> None:
if self._history_repository:
self._history_repository.update_entry(entry_id=task_id, status="cancelled")
def _record_history(self, **kwargs) -> None: def _record_history(self, **kwargs) -> None:
if self._history_repository: if self._history_repository:
self._history_repository.create_entry(**kwargs) self._history_repository.create_entry(**kwargs)
@@ -264,3 +356,8 @@ class ArchiveDownloadTaskService:
@staticmethod @staticmethod
def _is_expired(expires_at: str) -> bool: def _is_expired(expires_at: str) -> bool:
return datetime.now(timezone.utc) >= datetime.fromisoformat(expires_at.replace("Z", "+00:00")) return datetime.now(timezone.utc) >= datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
def _raise_if_cancelled(self, task_id: str) -> None:
task = self._repository.get_task(task_id)
if task and task["status"] == "cancelled":
raise ArchivePrepareCancelled()
@@ -1005,14 +1005,18 @@ class FileOpsService:
details={"reason": reason, **details}, details={"reason": reason, **details},
) )
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None: def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None:
root_name = resolved_target.absolute.name root_name = resolved_target.absolute.name
if resolved_target.absolute.is_file(): if resolved_target.absolute.is_file():
if on_each_item:
on_each_item()
archive.write(resolved_target.absolute, arcname=root_name) archive.write(resolved_target.absolute, arcname=root_name)
return return
archive.writestr(f"{root_name}/", b"") archive.writestr(f"{root_name}/", b"")
for child in sorted(resolved_target.absolute.rglob("*")): for child in sorted(resolved_target.absolute.rglob("*")):
if on_each_item:
on_each_item()
arcname = f"{root_name}/{child.relative_to(resolved_target.absolute).as_posix()}" arcname = f"{root_name}/{child.relative_to(resolved_target.absolute).as_posix()}"
if child.is_dir(): if child.is_dir():
archive.writestr(f"{arcname}/", b"") archive.writestr(f"{arcname}/", b"")
Binary file not shown.
@@ -37,11 +37,26 @@ class BlockingArchiveFileOpsService(FileOpsService):
class FailingArchiveFileOpsService(FileOpsService): class FailingArchiveFileOpsService(FileOpsService):
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None: def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None:
archive.writestr("partial.txt", b"partial") archive.writestr("partial.txt", b"partial")
raise OSError("forced archive failure") raise OSError("forced archive failure")
class BlockingArchiveBuildFileOpsService(FileOpsService):
def __init__(self, *args, entered: threading.Event, release: threading.Event, **kwargs):
super().__init__(*args, **kwargs)
self._entered = entered
self._release = release
def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None:
archive.writestr("partial.txt", b"partial")
self._entered.set()
self._release.wait(timeout=2.0)
if on_each_item:
on_each_item()
super()._write_download_target_to_zip(archive, resolved_target, on_each_item=on_each_item)
class DownloadApiGoldenTest(unittest.TestCase): class DownloadApiGoldenTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
@@ -221,6 +236,71 @@ class DownloadApiGoldenTest(unittest.TestCase):
self.assertEqual(task["error_code"], "io_error") self.assertEqual(task["error_code"], "io_error")
self.assertEqual(list(self.artifact_root.glob("*")), []) self.assertEqual(list(self.artifact_root.glob("*")), [])
def test_archive_cancel_during_preparing_sets_cancelled_and_removes_partial_artifact(self) -> None:
entered = threading.Event()
release = threading.Event()
file_ops_service = BlockingArchiveBuildFileOpsService(
path_guard=self.path_guard,
filesystem=self.filesystem,
history_repository=self.history_repo,
zip_download_preflight_limits=ZipDownloadPreflightLimits(),
entered=entered,
release=release,
)
self._override_services(file_ops_service=file_ops_service)
(self.root / "docs").mkdir()
(self.root / "docs" / "a.txt").write_text("a", encoding="utf-8")
created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]})
self.assertEqual(created.status_code, 202)
self.assertTrue(entered.wait(timeout=2.0))
response = self._request("POST", f"/api/files/download/archive/{created.json()['task_id']}/cancel")
release.set()
task = self._wait_for_task_status(created.json()["task_id"], {"cancelled"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["status"], "cancelled")
self.assertEqual(task["status"], "cancelled")
self.assertEqual(list(self.artifact_root.glob("*")), [])
def test_archive_retrieval_for_cancelled_task_rejected(self) -> None:
entered = threading.Event()
release = threading.Event()
file_ops_service = BlockingArchiveBuildFileOpsService(
path_guard=self.path_guard,
filesystem=self.filesystem,
history_repository=self.history_repo,
zip_download_preflight_limits=ZipDownloadPreflightLimits(),
entered=entered,
release=release,
)
self._override_services(file_ops_service=file_ops_service)
(self.root / "docs").mkdir()
(self.root / "docs" / "a.txt").write_text("a", encoding="utf-8")
created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]})
self.assertTrue(entered.wait(timeout=2.0))
cancel_response = self._request("POST", f"/api/files/download/archive/{created.json()['task_id']}/cancel")
release.set()
response = self._request("GET", f"/api/files/download/archive/{created.json()['task_id']}")
self.assertEqual(cancel_response.status_code, 200)
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "download_cancelled")
def test_archive_cancel_after_ready_rejected(self) -> None:
(self.root / "docs").mkdir()
(self.root / "docs" / "a.txt").write_text("a", encoding="utf-8")
created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]})
task = self._wait_for_task_status(created.json()["task_id"], {"ready"})
response = self._request("POST", f"/api/files/download/archive/{task['id']}/cancel")
self.assertEqual(response.status_code, 409)
self.assertEqual(response.json()["error"]["code"], "download_not_cancellable")
def test_expired_artifact_rejected_and_removed(self) -> None: def test_expired_artifact_rejected_and_removed(self) -> None:
(self.root / "docs").mkdir() (self.root / "docs").mkdir()
(self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8")
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio import asyncio
import sys import sys
import tempfile import tempfile
import threading
import time import time
import unittest import unittest
from pathlib import Path from pathlib import Path
@@ -11,12 +12,13 @@ import httpx
sys.path.insert(0, str(Path(__file__).resolve().parents[3])) sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
from backend.app.dependencies import get_copy_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service from backend.app.dependencies import get_archive_download_task_service, get_copy_task_service, get_file_ops_service, get_history_service, get_move_task_service, get_task_service
from backend.app.db.history_repository import HistoryRepository from backend.app.db.history_repository import HistoryRepository
from backend.app.db.task_repository import TaskRepository from backend.app.db.task_repository import TaskRepository
from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.fs.filesystem_adapter import FilesystemAdapter
from backend.app.main import app from backend.app.main import app
from backend.app.security.path_guard import PathGuard from backend.app.security.path_guard import PathGuard
from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService
from backend.app.services.copy_task_service import CopyTaskService from backend.app.services.copy_task_service import CopyTaskService
from backend.app.services.file_ops_service import FileOpsService from backend.app.services.file_ops_service import FileOpsService
from backend.app.services.history_service import HistoryService from backend.app.services.history_service import HistoryService
@@ -30,6 +32,21 @@ class FailingCopyFilesystemAdapter(FilesystemAdapter):
raise OSError('forced copy failure') raise OSError('forced copy failure')
class BlockingArchiveBuildFileOpsService(FileOpsService):
def __init__(self, *args, entered: threading.Event, release: threading.Event, **kwargs):
super().__init__(*args, **kwargs)
self._entered = entered
self._release = release
def _write_download_target_to_zip(self, archive, resolved_target, on_each_item=None) -> None:
archive.writestr("partial.txt", b"partial")
self._entered.set()
self._release.wait(timeout=2.0)
if on_each_item:
on_each_item()
super()._write_download_target_to_zip(archive, resolved_target, on_each_item=on_each_item)
class HistoryApiGoldenTest(unittest.TestCase): class HistoryApiGoldenTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
self.temp_dir = tempfile.TemporaryDirectory() self.temp_dir = tempfile.TemporaryDirectory()
@@ -38,6 +55,7 @@ class HistoryApiGoldenTest(unittest.TestCase):
self.root1.mkdir(parents=True, exist_ok=True) self.root1.mkdir(parents=True, exist_ok=True)
self.root2.mkdir(parents=True, exist_ok=True) self.root2.mkdir(parents=True, exist_ok=True)
db_path = str(Path(self.temp_dir.name) / 'tasks.db') db_path = str(Path(self.temp_dir.name) / 'tasks.db')
self.artifact_root = Path(self.temp_dir.name) / "archive_tmp"
self.task_repo = TaskRepository(db_path) self.task_repo = TaskRepository(db_path)
self.history_repo = HistoryRepository(db_path) self.history_repo = HistoryRepository(db_path)
self.path_guard = PathGuard({'storage1': str(self.root1), 'storage2': str(self.root2)}) self.path_guard = PathGuard({'storage1': str(self.root1), 'storage2': str(self.root2)})
@@ -47,9 +65,17 @@ class HistoryApiGoldenTest(unittest.TestCase):
app.dependency_overrides.clear() app.dependency_overrides.clear()
self.temp_dir.cleanup() self.temp_dir.cleanup()
def _set_services(self, filesystem: FilesystemAdapter) -> None: def _set_services(self, filesystem: FilesystemAdapter, file_ops_service: FileOpsService | None = None) -> None:
runner = TaskRunner(repository=self.task_repo, filesystem=filesystem, history_repository=self.history_repo) runner = TaskRunner(repository=self.task_repo, filesystem=filesystem, history_repository=self.history_repo)
file_ops_service = FileOpsService(path_guard=self.path_guard, filesystem=filesystem, history_repository=self.history_repo) file_ops_service = file_ops_service or FileOpsService(path_guard=self.path_guard, filesystem=filesystem, history_repository=self.history_repo)
archive_service = ArchiveDownloadTaskService(
path_guard=self.path_guard,
repository=self.task_repo,
runner=runner,
history_repository=self.history_repo,
file_ops_service=file_ops_service,
artifact_root=self.artifact_root,
)
copy_service = CopyTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) copy_service = CopyTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo)
move_service = MoveTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo) move_service = MoveTaskService(path_guard=self.path_guard, repository=self.task_repo, runner=runner, history_repository=self.history_repo)
task_service = TaskService(repository=self.task_repo) task_service = TaskService(repository=self.task_repo)
@@ -58,6 +84,9 @@ class HistoryApiGoldenTest(unittest.TestCase):
async def _override_file_ops_service() -> FileOpsService: async def _override_file_ops_service() -> FileOpsService:
return file_ops_service return file_ops_service
async def _override_archive_service() -> ArchiveDownloadTaskService:
return archive_service
async def _override_copy_service() -> CopyTaskService: async def _override_copy_service() -> CopyTaskService:
return copy_service return copy_service
@@ -71,6 +100,7 @@ class HistoryApiGoldenTest(unittest.TestCase):
return history_service return history_service
app.dependency_overrides[get_file_ops_service] = _override_file_ops_service app.dependency_overrides[get_file_ops_service] = _override_file_ops_service
app.dependency_overrides[get_archive_download_task_service] = _override_archive_service
app.dependency_overrides[get_copy_task_service] = _override_copy_service app.dependency_overrides[get_copy_task_service] = _override_copy_service
app.dependency_overrides[get_move_task_service] = _override_move_service app.dependency_overrides[get_move_task_service] = _override_move_service
app.dependency_overrides[get_task_service] = _override_task_service app.dependency_overrides[get_task_service] = _override_task_service
@@ -91,7 +121,7 @@ class HistoryApiGoldenTest(unittest.TestCase):
while time.time() < deadline: while time.time() < deadline:
response = self._request('GET', f'/api/tasks/{task_id}') response = self._request('GET', f'/api/tasks/{task_id}')
body = response.json() body = response.json()
if body['status'] in {'completed', 'failed', 'ready'}: if body['status'] in {'completed', 'failed', 'ready', 'cancelled'}:
return body return body
time.sleep(0.02) time.sleep(0.02)
self.fail('task did not reach terminal state in time') self.fail('task did not reach terminal state in time')
@@ -244,6 +274,38 @@ class HistoryApiGoldenTest(unittest.TestCase):
self.assertEqual(history[0]['error_code'], 'download_preflight_failed') self.assertEqual(history[0]['error_code'], 'download_preflight_failed')
self.assertEqual(history[0]['error_message'], 'Zip download preflight failed') self.assertEqual(history[0]['error_message'], 'Zip download preflight failed')
def test_download_cancellation_writes_cancelled_history_item(self) -> None:
entered = threading.Event()
release = threading.Event()
file_ops_service = BlockingArchiveBuildFileOpsService(
path_guard=self.path_guard,
filesystem=FilesystemAdapter(),
history_repository=self.history_repo,
entered=entered,
release=release,
)
self._set_services(FilesystemAdapter(), file_ops_service=file_ops_service)
(self.root1 / 'docs').mkdir()
(self.root1 / 'docs' / 'a.txt').write_text('A', encoding='utf-8')
response = self._request('POST', '/api/files/download/archive-prepare', {'paths': ['storage1/docs']})
self.assertEqual(response.status_code, 202)
self.assertTrue(entered.wait(timeout=2.0))
cancel = self._request('POST', f"/api/files/download/archive/{response.json()['task_id']}/cancel")
release.set()
self._wait_task(response.json()['task_id'])
history = self._request('GET', '/api/history').json()['items']
self.assertEqual(cancel.status_code, 200)
self.assertEqual(history[0]['operation'], 'download')
self.assertEqual(history[0]['status'], 'cancelled')
self.assertEqual(history[0]['source'], 'single_directory_zip')
self.assertEqual(history[0]['path'], 'storage1/docs')
self.assertEqual(history[0]['destination'], 'docs.zip')
self.assertEqual(history[0]['error_code'], None)
self.assertEqual(history[0]['error_message'], None)
def test_download_history_uses_server_certain_statuses_only(self) -> None: def test_download_history_uses_server_certain_statuses_only(self) -> None:
(self.root1 / 'report.txt').write_text('hello download', encoding='utf-8') (self.root1 / 'report.txt').write_text('hello download', encoding='utf-8')
@@ -251,5 +313,5 @@ class HistoryApiGoldenTest(unittest.TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
history = self._request('GET', '/api/history').json()['items'] history = self._request('GET', '/api/history').json()['items']
self.assertIn(history[0]['status'], {'requested', 'ready', 'preflight_failed', 'failed'}) self.assertIn(history[0]['status'], {'requested', 'ready', 'preflight_failed', 'failed', 'cancelled'})
self.assertNotIn(history[0]['status'], {'completed', 'downloaded', 'saved'}) self.assertNotIn(history[0]['status'], {'completed', 'downloaded', 'saved'})
@@ -263,6 +263,28 @@ class TasksApiGoldenTest(unittest.TestCase):
self.assertEqual(body["status"], "ready") self.assertEqual(body["status"], "ready")
self.assertEqual(body["destination"], "docs.zip") self.assertEqual(body["destination"], "docs.zip")
def test_get_task_detail_cancelled_archive_download(self) -> None:
self._insert_task(
task_id="task-download-cancelled",
operation="download",
status="cancelled",
source="storage1/docs",
destination="docs.zip",
created_at="2026-03-10T10:00:00Z",
started_at="2026-03-10T10:00:01Z",
finished_at="2026-03-10T10:00:03Z",
done_items=0,
total_items=1,
)
response = self._get("/api/tasks/task-download-cancelled")
self.assertEqual(response.status_code, 200)
body = response.json()
self.assertEqual(body["operation"], "download")
self.assertEqual(body["status"], "cancelled")
self.assertEqual(body["destination"], "docs.zip")
def test_get_task_not_found(self) -> None: def test_get_task_not_found(self) -> None:
response = self._get("/api/tasks/task-missing") response = self._get("/api/tasks/task-missing")
@@ -74,6 +74,7 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('id="download-modal-progress-bar"', body) self.assertIn('id="download-modal-progress-bar"', body)
self.assertIn('id="download-modal-count"', body) self.assertIn('id="download-modal-count"', body)
self.assertIn('id="download-modal-status"', body) self.assertIn('id="download-modal-status"', body)
self.assertIn('id="download-modal-cancel-btn"', body)
self.assertIn('id="download-modal-close-btn"', body) self.assertIn('id="download-modal-close-btn"', body)
self.assertIn('id="context-menu"', body) self.assertIn('id="context-menu"', body)
self.assertIn('id="context-menu-scope"', body) self.assertIn('id="context-menu-scope"', body)
@@ -231,11 +232,14 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('function openZipDownloadModal(selectedItems)', app_js) self.assertIn('function openZipDownloadModal(selectedItems)', app_js)
self.assertIn('function markZipDownloadReady(fileName)', app_js) self.assertIn('function markZipDownloadReady(fileName)', app_js)
self.assertIn('function markZipDownloadFailed(err)', app_js) self.assertIn('function markZipDownloadFailed(err)', app_js)
self.assertIn('function markZipDownloadCancelled()', app_js)
self.assertIn('function closeDownloadModal()', app_js) self.assertIn('function closeDownloadModal()', app_js)
self.assertIn('function zipDownloadRequestKey(paths)', app_js) self.assertIn('function zipDownloadRequestKey(paths)', app_js)
self.assertIn('async function createArchiveDownloadTask(paths)', app_js) self.assertIn('async function createArchiveDownloadTask(paths)', app_js)
self.assertIn('async function getTaskRequest(taskId)', app_js) self.assertIn('async function getTaskRequest(taskId)', app_js)
self.assertIn('async function cancelArchiveDownloadTask(taskId)', app_js)
self.assertIn('function startArchiveDownload(taskId, fileName)', app_js) self.assertIn('function startArchiveDownload(taskId, fileName)', app_js)
self.assertIn('async function requestArchiveDownloadCancel()', app_js)
self.assertIn('async function waitForArchiveDownloadReady(taskId)', app_js) self.assertIn('async function waitForArchiveDownloadReady(taskId)', app_js)
self.assertIn('function contextMenuElements()', app_js) self.assertIn('function contextMenuElements()', app_js)
self.assertIn('function openContextMenu(pane, entry, event)', app_js) self.assertIn('function openContextMenu(pane, entry, event)', app_js)
@@ -251,12 +255,15 @@ class UiSmokeGoldenTest(unittest.TestCase):
self.assertIn('statusText: "Download started"', app_js) self.assertIn('statusText: "Download started"', app_js)
self.assertIn('countText: "Browser download started"', app_js) self.assertIn('countText: "Browser download started"', app_js)
self.assertIn('countText: "Zip download failed"', app_js) self.assertIn('countText: "Zip download failed"', app_js)
self.assertIn('countText: "Zip download cancelled"', app_js)
self.assertIn('statusText: "Cancelling download..."', app_js)
self.assertIn('statusText: err.message || "Download failed"', app_js) self.assertIn('statusText: err.message || "Download failed"', app_js)
self.assertIn('downloadProgressState.requestKey === requestKey', app_js) self.assertIn('downloadProgressState.requestKey === requestKey', app_js)
self.assertIn('setStatus("Preparing download...");', app_js) self.assertIn('setStatus("Preparing download...");', app_js)
self.assertIn('"/api/files/download/archive-prepare"', app_js) self.assertIn('"/api/files/download/archive-prepare"', app_js)
self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/tasks/${encodeURIComponent(taskId)}`', app_js)
self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js) self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}`', app_js)
self.assertIn('`/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`', app_js)
self.assertIn('function applyContextMenuSelection()', app_js) self.assertIn('function applyContextMenuSelection()', app_js)
self.assertIn('function startContextMenuOpen()', app_js) self.assertIn('function startContextMenuOpen()', app_js)
self.assertIn('function startContextMenuEdit()', app_js) self.assertIn('function startContextMenuEdit()', app_js)
@@ -80,6 +80,21 @@ class TaskRepositoryTest(unittest.TestCase):
self.assertEqual(task["status"], "requested") self.assertEqual(task["status"], "requested")
self.assertEqual(artifact["file_name"], "docs.zip") self.assertEqual(artifact["file_name"], "docs.zip")
def test_mark_cancelled_transitions_requested_download_task(self) -> None:
created = self.repo.create_task(
operation="download",
source="storage1/docs",
destination="docs.zip",
status="requested",
)
changed = self.repo.mark_cancelled(created["id"])
task = self.repo.get_task(created["id"])
self.assertTrue(changed)
self.assertEqual(task["status"], "cancelled")
self.assertIsNotNone(task["finished_at"])
def test_migrates_legacy_tasks_schema_missing_source_destination(self) -> None: def test_migrates_legacy_tasks_schema_missing_source_destination(self) -> None:
legacy_db_path = Path(self.temp_dir.name) / "legacy.db" legacy_db_path = Path(self.temp_dir.name) / "legacy.db"
conn = sqlite3.connect(legacy_db_path) conn = sqlite3.connect(legacy_db_path)
+89 -1
View File
@@ -86,6 +86,8 @@ let downloadProgressState = {
archiveLabel: "", archiveLabel: "",
totalItems: 0, totalItems: 0,
requestKey: null, requestKey: null,
taskId: null,
cancelRequested: false,
}; };
let folderUploadPlanState = { let folderUploadPlanState = {
targetPane: "left", targetPane: "left",
@@ -336,6 +338,7 @@ function downloadModalElements() {
count: document.getElementById("download-modal-count"), count: document.getElementById("download-modal-count"),
progressBar: document.getElementById("download-modal-progress-bar"), progressBar: document.getElementById("download-modal-progress-bar"),
status: document.getElementById("download-modal-status"), status: document.getElementById("download-modal-status"),
cancelButton: document.getElementById("download-modal-cancel-btn"),
closeButton: document.getElementById("download-modal-close-btn"), closeButton: document.getElementById("download-modal-close-btn"),
}; };
} }
@@ -400,6 +403,8 @@ function updateDownloadModalDisplay(info) {
elements.count.textContent = info.countText || ""; elements.count.textContent = info.countText || "";
elements.status.textContent = info.statusText || ""; elements.status.textContent = info.statusText || "";
elements.progressBar.style.width = `${Math.max(0, Math.min(100, info.percent || 0))}%`; elements.progressBar.style.width = `${Math.max(0, Math.min(100, info.percent || 0))}%`;
elements.cancelButton.disabled = !!info.cancelDisabled;
elements.cancelButton.classList.toggle("hidden", !info.cancelVisible);
elements.closeButton.disabled = !!info.active; elements.closeButton.disabled = !!info.active;
elements.closeButton.classList.toggle("hidden", !!info.active); elements.closeButton.classList.toggle("hidden", !!info.active);
} }
@@ -410,6 +415,8 @@ function openZipDownloadModal(selectedItems) {
downloadProgressState.archiveLabel = "ZIP archive"; downloadProgressState.archiveLabel = "ZIP archive";
downloadProgressState.totalItems = selectedItems.length; downloadProgressState.totalItems = selectedItems.length;
downloadProgressState.requestKey = zipDownloadRequestKey(requestPaths); downloadProgressState.requestKey = zipDownloadRequestKey(requestPaths);
downloadProgressState.taskId = null;
downloadProgressState.cancelRequested = false;
setDownloadModalVisible(true); setDownloadModalVisible(true);
updateDownloadModalDisplay({ updateDownloadModalDisplay({
active: true, active: true,
@@ -418,6 +425,8 @@ function openZipDownloadModal(selectedItems) {
countText: "Preparing zip download", countText: "Preparing zip download",
statusText: "Preparing download...", statusText: "Preparing download...",
percent: 20, percent: 20,
cancelVisible: true,
cancelDisabled: true,
}); });
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (!downloadProgressState.active) { if (!downloadProgressState.active) {
@@ -430,12 +439,15 @@ function openZipDownloadModal(selectedItems) {
countText: "Zip preflight and packaging", countText: "Zip preflight and packaging",
statusText: "Preparing download...", statusText: "Preparing download...",
percent: 55, percent: 55,
cancelVisible: true,
cancelDisabled: !downloadProgressState.taskId || downloadProgressState.cancelRequested,
}); });
}); });
} }
function markZipDownloadReady(fileName) { function markZipDownloadReady(fileName) {
downloadProgressState.active = false; downloadProgressState.active = false;
downloadProgressState.cancelRequested = false;
downloadProgressState.archiveLabel = fileName || "ZIP archive"; downloadProgressState.archiveLabel = fileName || "ZIP archive";
updateDownloadModalDisplay({ updateDownloadModalDisplay({
active: false, active: false,
@@ -444,12 +456,14 @@ function markZipDownloadReady(fileName) {
countText: "Browser download started", countText: "Browser download started",
statusText: "Download started", statusText: "Download started",
percent: 100, percent: 100,
cancelVisible: false,
}); });
window.setTimeout(closeDownloadModal, 480); window.setTimeout(closeDownloadModal, 480);
} }
function markZipDownloadFailed(err) { function markZipDownloadFailed(err) {
downloadProgressState.active = false; downloadProgressState.active = false;
downloadProgressState.cancelRequested = false;
updateDownloadModalDisplay({ updateDownloadModalDisplay({
active: false, active: false,
targetText: "Preparing download...", targetText: "Preparing download...",
@@ -457,6 +471,21 @@ function markZipDownloadFailed(err) {
countText: "Zip download failed", countText: "Zip download failed",
statusText: err.message || "Download failed", statusText: err.message || "Download failed",
percent: 0, percent: 0,
cancelVisible: false,
});
}
function markZipDownloadCancelled() {
downloadProgressState.active = false;
downloadProgressState.cancelRequested = false;
updateDownloadModalDisplay({
active: false,
targetText: "Download cancelled",
currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: "Zip download cancelled",
statusText: "Download cancelled",
percent: 0,
cancelVisible: false,
}); });
} }
@@ -469,8 +498,10 @@ function updateZipDownloadTaskProgress(task) {
targetText: "Preparing download...", targetText: "Preparing download...",
currentFileText: task.current_item ? `Current: ${task.current_item}` : `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`, currentFileText: task.current_item ? `Current: ${task.current_item}` : `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: task.total_items ? `${task.done_items || 0}/${task.total_items} top-level items` : "Preparing zip download", countText: task.total_items ? `${task.done_items || 0}/${task.total_items} top-level items` : "Preparing zip download",
statusText: task.status === "ready" ? "Download started" : "Preparing download...", statusText: downloadProgressState.cancelRequested ? "Cancelling download..." : task.status === "ready" ? "Download started" : "Preparing download...",
percent: task.status === "ready" ? 100 : 55, percent: task.status === "ready" ? 100 : 55,
cancelVisible: true,
cancelDisabled: !downloadProgressState.taskId || downloadProgressState.cancelRequested,
}); });
} }
@@ -484,6 +515,11 @@ async function waitForArchiveDownloadReady(taskId) {
if (task.status === "ready") { if (task.status === "ready") {
return task; return task;
} }
if (task.status === "cancelled") {
const err = new Error("Archive download was cancelled");
err.code = "download_cancelled";
throw err;
}
if (task.status === "failed") { if (task.status === "failed") {
const err = new Error(task.error_message || "Archive download failed"); const err = new Error(task.error_message || "Archive download failed");
err.code = task.error_code || null; err.code = task.error_code || null;
@@ -501,6 +537,8 @@ function closeDownloadModal() {
downloadProgressState.archiveLabel = ""; downloadProgressState.archiveLabel = "";
downloadProgressState.totalItems = 0; downloadProgressState.totalItems = 0;
downloadProgressState.requestKey = null; downloadProgressState.requestKey = null;
downloadProgressState.taskId = null;
downloadProgressState.cancelRequested = false;
updateDownloadModalDisplay({ updateDownloadModalDisplay({
active: false, active: false,
targetText: "", targetText: "",
@@ -508,6 +546,7 @@ function closeDownloadModal() {
countText: "", countText: "",
statusText: "", statusText: "",
percent: 0, percent: 0,
cancelVisible: false,
}); });
setDownloadModalVisible(false); setDownloadModalVisible(false);
} }
@@ -679,6 +718,13 @@ async function startDownloadSelected() {
const selected = selectedItems[0]; const selected = selectedItems[0];
if (zipDownload) { if (zipDownload) {
const created = await createArchiveDownloadTask(selectedPaths); const created = await createArchiveDownloadTask(selectedPaths);
downloadProgressState.taskId = created.task_id;
updateZipDownloadTaskProgress({
status: "preparing",
current_item: null,
done_items: 0,
total_items: selectedItems.length,
});
const task = await waitForArchiveDownloadReady(created.task_id); const task = await waitForArchiveDownloadReady(created.task_id);
startArchiveDownload(task.id, task.destination); startArchiveDownload(task.id, task.destination);
markZipDownloadReady(task.destination); markZipDownloadReady(task.destination);
@@ -697,8 +743,13 @@ async function startDownloadSelected() {
setStatus(`Download started: ${anchor.download}`); setStatus(`Download started: ${anchor.download}`);
} catch (err) { } catch (err) {
if (zipDownload) { if (zipDownload) {
if (err.code === "download_cancelled") {
markZipDownloadCancelled();
setStatus("Download cancelled");
} else {
markZipDownloadFailed(err); markZipDownloadFailed(err);
setStatus("Download failed"); setStatus("Download failed");
}
} else { } else {
setActionError("Download", err); setActionError("Download", err);
} }
@@ -1004,6 +1055,10 @@ async function getTaskRequest(taskId) {
return apiRequest("GET", `/api/tasks/${encodeURIComponent(taskId)}`); return apiRequest("GET", `/api/tasks/${encodeURIComponent(taskId)}`);
} }
async function cancelArchiveDownloadTask(taskId) {
return apiRequest("POST", `/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`);
}
function startArchiveDownload(taskId, fileName) { function startArchiveDownload(taskId, fileName) {
const anchor = document.createElement("a"); const anchor = document.createElement("a");
anchor.href = `/api/files/download/archive/${encodeURIComponent(taskId)}`; anchor.href = `/api/files/download/archive/${encodeURIComponent(taskId)}`;
@@ -1013,6 +1068,31 @@ function startArchiveDownload(taskId, fileName) {
anchor.remove(); anchor.remove();
} }
async function requestArchiveDownloadCancel() {
if (!downloadProgressState.active || !downloadProgressState.taskId || downloadProgressState.cancelRequested) {
return;
}
downloadProgressState.cancelRequested = true;
updateDownloadModalDisplay({
active: true,
targetText: "Preparing download...",
currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: "Zip download cancellation requested",
statusText: "Cancelling download...",
percent: 55,
cancelVisible: true,
cancelDisabled: true,
});
try {
await cancelArchiveDownloadTask(downloadProgressState.taskId);
} catch (err) {
if (err.code !== "download_not_cancellable") {
downloadProgressState.cancelRequested = false;
throw err;
}
}
}
async function uploadFileRequest(targetPath, file, overwrite = false) { async function uploadFileRequest(targetPath, file, overwrite = false) {
const formData = new FormData(); const formData = new FormData();
formData.append("target_path", targetPath); formData.append("target_path", targetPath);
@@ -4164,6 +4244,14 @@ function setupEvents() {
}; };
} }
const downloadModal = downloadModalElements(); const downloadModal = downloadModalElements();
if (downloadModal.cancelButton) {
downloadModal.cancelButton.onclick = () => {
requestArchiveDownloadCancel().catch((err) => {
markZipDownloadFailed(err);
setStatus("Download failed");
});
};
}
if (downloadModal.closeButton) { if (downloadModal.closeButton) {
downloadModal.closeButton.onclick = closeDownloadModal; downloadModal.closeButton.onclick = closeDownloadModal;
} }
+1
View File
@@ -129,6 +129,7 @@
<div id="download-modal-count" class="download-modal-count"></div> <div id="download-modal-count" class="download-modal-count"></div>
<div id="download-modal-status" class="popup-meta"></div> <div id="download-modal-status" class="popup-meta"></div>
<div class="popup-actions"> <div class="popup-actions">
<button id="download-modal-cancel-btn" type="button" class="hidden">Cancel</button>
<button id="download-modal-close-btn" type="button">Close</button> <button id="download-modal-close-btn" type="button">Close</button>
</div> </div>
</div> </div>