From 2981ac2796fe3e866121c83261ec8c3ff84329ad Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 14:39:57 +0100 Subject: [PATCH] feat: B3 uit voor veilige archive-downloads - cancel knop toegevoegd --- .../__pycache__/routes_files.cpython-313.pyc | Bin 7862 -> 8349 bytes webui/backend/app/api/routes_files.py | 10 +- .../history_repository.cpython-313.pyc | Bin 7813 -> 7824 bytes .../task_repository.cpython-313.pyc | Bin 17729 -> 19687 bytes webui/backend/app/db/history_repository.py | 2 +- webui/backend/app/db/task_repository.py | 68 +++++++-- ...hive_download_task_service.cpython-313.pyc | Bin 13138 -> 16974 bytes .../file_ops_service.cpython-313.pyc | Bin 42790 -> 42881 bytes .../services/archive_download_task_service.py | 129 +++++++++++++++--- .../backend/app/services/file_ops_service.py | 6 +- .../14e942d8-7921-42c4-bf84-4a0ebbae390c.zip | Bin 0 -> 13228 bytes webui/backend/data/tasks.db | Bin 200704 -> 212992 bytes .../test_api_download_golden.cpython-313.pyc | Bin 20454 -> 26158 bytes .../test_api_history_golden.cpython-313.pyc | Bin 20390 -> 24738 bytes .../test_api_tasks_golden.cpython-313.pyc | Bin 11032 -> 11835 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 39191 -> 40925 bytes .../tests/golden/test_api_download_golden.py | 82 ++++++++++- .../tests/golden/test_api_history_golden.py | 72 +++++++++- .../tests/golden/test_api_tasks_golden.py | 22 +++ .../tests/golden/test_ui_smoke_golden.py | 7 + .../test_task_repository.cpython-313.pyc | Bin 5358 -> 6156 bytes .../tests/unit/test_task_repository.py | 15 ++ webui/html/app.js | 94 ++++++++++++- webui/html/index.html | 1 + 24 files changed, 471 insertions(+), 37 deletions(-) create mode 100644 webui/backend/data/archive_tmp/14e942d8-7921-42c4-bf84-4a0ebbae390c.zip 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 45e1530cbdf8e5dd4df6687cce1a0ef018fa99b4..551a980ebea8bb44a781ac1195cffcef2b7403f0 100644 GIT binary patch delta 2661 zcmZ`)Urd`-6u-AEr7g4+XerRrKeUB)9e)RGW&hm3xs_#RyTGzggQfj|Zl$fag=Nb& z&^;J4HNhL>9@K10Ox&weAC~B|QL}XMLGwN6;$F5FgJFsKpz)sj6Y^?$B8ce6+M;gij(Vs^d{>Wo zqdw{rx?!w6>Zg7$ifP*sF%nY*(?Be6c5L;j2Yc9U>=-n+q6@kUco0(XhjxHl(JLjf z#F|KLL=)4sqZlylj$Try;&uX;tzad#y{nJZs}Q?(h~5x$vfuIEbDlx0WF64Ck@`pa ziCq;h#N$m=)~V>EDdvJ|iLF#`TGQg#OB^b87})G%O{reT0BKPn_6WpNO;1<3GEKQI zGDw^%Mi;D*jcR?2;HK~p@u>XWJ5=mt$8|xwPlfIg(H?7Unpo@Vf1b#G;#VPhcZe6* z=;Y>(*<4UKiIAWwL?47;D>|PT3A{`>kdtZ(NtLH+Ev z>f`zVr~wu>JoJS@-2)=XMP9>&*GqSSp-TwXYV5-ycFK4ihuIItmY`ttfNvY47px#W z%x!eBpsAiS_nF=a^@C%83t2atN=vkpt9cz4Uj8WA;N;Ok_RMts0A!F2vspSVB~yhE zmUT-h=~75bMe6+k6alnK0EHg`bA;Wkxf%u-uu_@XY)GT91fg*6#q zpIVZpb5zR3sYK7Pk-BeP_pn@D;9D}CO46f1JO*L}#3*aCcJ+*d3T4tL7x~J?$<~dS zo7GIR%hn0p&MwzEBRz?DCLyI0u)K}Oz?B;a;q_sz_>mWVh^9(+3PMEFize_{ExS|O zygZ7BZ~*Po&f<{vG$kB<5Am)wb47M4k*7D(mzFdQ3*nf}k}Ky%wrrpkGM(a)=J=cU zbMXwVpywu3>wZ)-al86w??~$-@5pzr6ul!w?>M&=brb)H$#|>9$$qpx7!boyOXHb2 zX?k9YCnc)1?=%lq?jawJvf*X+px$*v^!EgyYPbmLlsV`CL%GXaW>BtLN+qQ%eVt>n z%--;;{UD&X?Utd@*P+Y_xVP_?HkX{G68BwauQr~*=h&^rB|O9GY(96ATeog)RcawV zC((KCxXDi20REBf3Wqn?gYDbl#=`vK!c2yrYI>1VZn5)rpxm{8pp5QOHr(XdEvgfB zFL2Pd8KoJpg7}QxXgawpnoR^hk2z+VS` z6NMW<*5(VebPdd8vuQd6coSN#Osqt00hU48vK*kqsec2s%@P%4D+tpP?akt@-;73f+;LXX1&AQYP6Q&*i$4QjV)qCXtfz)Co)% zn{U~V+t{6!@N#MEvx^0Z%DP;Z_bLDw@n6sjnz*OMu@X0~xJFNb;|z#N5OCndArgl` zjJ6n7W$ey#?>c{xxVL4)!G&ycF)baY*AS{ka7X38V9{2$?S$?xet~V3_pi1-(L|kZ zI*aDM;<51+XAw{QsYl`dVn6w5_xM_s4o~7=%+@t6D38sZie*iY^v%z!!M3hNnn}E- R0e#JKf2!>q)?MXE{|Afd_GSP8 delta 2127 zcmZvcO>7%Q6o7ZUj%z#qOJaK+$FaS3oy7Tb^9M-@C23JnVl^t+=0cZRPP|Dracpbs zq$r?76$iu#!W>b11QiJhDS&!HoDo#2IDl3P5<-F_R25W!P?;*i8v?o2~KiI&MAbGa&mEwh>nhfXD-59gTj#H4s#pU4Kx8I=z~)Ygk+J<3`<@U z)6-yhpWH_N;bTHZYBMpt2GcHw$**V(MM>DwEnJWSCbEx`5;Wxv%3YFc$|Ct?Yl4%b zq+;n5MkK++_cwSxB)7Bbqf)1d8E7!%nI%FiS1kUJG$wVKu)#)?<`6$ zPzOMqr=n`1A|A)RU>IbA>^VkIANkC&hx$pz*{MeigYQ&C2CNyPIw9gi=ey|5 zSy$Dff_tc1wNeqMk6vXQka2RY74m=E8cMK^o(C(Vd7akbNia_mo9nFsz|u&ws-@L( z+JadW3us=#H1N~pQ&$R2k?&pqp;_|CJ#W)tB6>t0tW>iX%C}3!a=w7GWYKdE#mHmN z2D(IKZ?gR*NO5ANi=aYVc#eu{ z^I|k#9W=EC{Xnvz>r@&yI!`L97Lqez;^$A*`wD{a;M z6|7Y9Sjp|E1*M#;*07S_(nDT>;8up|`;`L2ad*dq@ll7?!ZP63sJ=$-bo>-}0q_|R zMhA46HNV(n?EV?cTEz!4KTSnc=uPNkl$A$&Vy+!RO z_o#(2c$qrhX5rrjCeRY$gS$v3d%;xVbcmz2zP+_v%Bw}J(6IMe*pER7^9Ud4v!5X! z39)|G^$cb38i*4^2E%$o+$FyYbNg(!+Q7!Ptea|@4J);6T*B@2C2hkD={SL46<+;K ze$N?xFZi7pz88(BGpm0z%jI$r!?lf_+fMTD`CTK>BI@t-Nt%)@G zVc>QDuA`_{Yd2{Vub;>Q_1WR6FVk(5iGaqIDd=g~sb>wbGRJ-v-`p_UFo%%|_EgVQ zuf#^AXJQw{DS#yioRJ_1t&9^#_#06RE$3G@l~Q3iU#ScilnPaq(u%57aTp@I$lu{f z6d|Ks1N-%-3E4BvdfU&Z>$gYDY4s#T`^fma~;GD5b(*cBhL;lJBI8q z-RGWA56A8@%|5+VE^HT-OZW|HL-b!QSm^=ygGUG*af#o!g&(+uN8G{@m!ZF>Ru0wp Yr<82SKbd|Qi2PzXyc&~`^)}7%KQ*wUP5=M^ diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index 4af2200..8ad4fae 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile from fastapi.responses import StreamingResponse 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.services.archive_download_task_service import ArchiveDownloadTaskService 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") async def video( path: str, diff --git a/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/history_repository.cpython-313.pyc index fc5a4d6275bcd1ff1c7424fdf0a658e251c59625..c232b7698c9d0205562915052532e2caf1db9c4d 100644 GIT binary patch delta 1047 zcmZuwzi-n}5YBT_C*&6elsF`I8v;pjMFWL`pua$L1XT#wD6OK{C6?TToE<7Eq)Zhf zRBvEF>`X`~0wgB>gv=Hau))9rLj??Q_nbN);o-}@@9w_4d-t-v@OUBpJ(Y@w@N?_0 zESB9#_gQr3gC^ISb6#=B94KaG|onB;Gxi)_62BU)mK3TD9sfzB)YezOz2 z5`VsOiind0GX!S|&LQYA(8!)7m?iitACF#o-D+A+!!*|J)$EQ53rIRKgyP_X@!n*j zA*q3+7h5;odZT9Ba314y88rSPl~pOzi$H7BfkhIL0}+(?n7Sa;ZRm`ECs;WSX=N5Ck0#Bw&nKF;E~t zjM{sjdB<3uKT~q7z+Wp9Dpm9&CA5k~7$<@RJ&k|L9OB8`n{pnHI`bliRd;pf56LLp zkEYA;@*?_IuFOikBZrf0w1lkhr^O8X*Ya1p*N>BRFL=+sB{-5el z3oy}(P)j=dVvtG%t`HZta5?1Nirbds-lAK(jm{fLVYy{Q@`8ha7cjIV;%2uFI&Ir> z%`?^Ot*)ED?g61S&yekivquSm>b-$LR?l>7s+0o;fH delta 1101 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 delta 3256 zcmai0Z){sv6@S<7`PuQm*ootRabl-g(!97!v&>DiG=J=Lr0v>eTQ8%7w!C_d+q!k^ z?Y@_Vl|dry(~zjP9!&xXn6_^jV{2v5RE>Qo)dUD3g%5zR1X{lgAt7ey0MSVm_uOa4 z?z(Ev((j&k{@-)YJJXF@ZLA+n z!nhLi&;jafn0CNhax6jzX-9+MYU=VhLOaB@T@9hTDWvyd3aKILZb&>Rp~FqNwbGr` z(~x`NFLE3*if5gUP;Z0jqZ;)g%{u8Q?G%)s@4zpp0N+7CP+c@dy9CuuchPP^1?l}X zD5#zjA=(p@_Fz5v!@?&bbc}{jw#|zQUYPTCwwN)_+)X2QDE87Qi=UCT@#=&kxTX7lQ%VZB~Dw{+m zCdSeT`w(V0)Z{Y-Q?oM+SyM2E6+pPlaHe7Bki>_js$0G%ZR&%Zp(&6}2M0D4VaVn= zi)9e991Mj+!{SunV=~=}<~&_6^JjCV5}V^XP4f(=YYMBZ8F$&g)V*MfPjh)IWv!zv zXb2L0k!S)H(2LQ*0Yi(2r63Y|4PJxS{4~^Bmy}7Db35v_d%eYb(I@$X;>?K8*~(ZnqOI_jPFJE}!3e;;AkFS3mJO zyuSa+k}vs3Z{iQ$#I2idZu)vSOXO?BmF=vSqsK^iyEvM&ExOoOcZTa5&Q?5r2H@yT zlXt2!y}u(+-)Wx}dEa5l)ma#!wSy$SW;lG-*<*bZ-d`a95Qhz^?!T0bH8wIBXD2x` zkB8ZOu)4A&k(+P8wa|Ug-=DRm?eN>^Jl69{qn@5iiKlruN2R)oLSKEsu_B|Qd(Up8=7ooI?9c#bK+IT3|I;|BXDti z2XR!djK3o9zVkA$S&S3VbIdHB&gD(rtmw@N9Zm?H7Kb9t(NUVXbrZ8Kb~V zAk#7UQ@WGPRR5O#CK+AjGEHfvE1a|E(D*QiwtEd1C&$R6@c!gUWr<7RES~HG9!4tQgIEoI&AHge=_Hw_6p(WDuGeCC97T88;!r@aSwL zB@RwLT3M^P`4G~vjurO&rp-DO^kVRjBR>z`2rthLlRUgTJ4VLfquJZeVJ_hF6X5T2 zBjHQ!s+<<3ti|B9*|1Wql+RWd=SxKW5=y$DnmH8Ols1Vq^LRm|#&OR<_Te69;~>py zI@?c_F)rp+4nb*Q>Jd?sHT3cggy#|Phs2b~pfTxr*hcnKcZ4f}zBO5|kMKf)d|=O_aX5j`$V_ zO-84#Hm6@;xoxP3bUbaAVjhV|Vbr>39(-%-jp#NW+}Y;h^MYd~tX#X=G{r0w)e{3z zP+}^I4SrE7eE(K{E$3Frtw8PdSi|tby=YWHiEb5?u*bu_>p(3}iWr5rx{PN_{8~Ra zw!cy?7xE@-98j`%#eUoJljX|=chiDjY0(U#g&K_^^3nR>g9DT6yD+~6go6l25GcY^ z2*(jlA>8{sZokJcb7nEWQZUa}mc`#5Yb^3PJ|)$9 zblsBcIn!jtr&rB_q3hOdZQZ^>^oD@9+_++cpCA0y9>3l9VIWwy^R=!@J&C#lDW?=l z);o}LN#TLI8!36tW=g)ONBXh70w+Q WBB$WxBa4AGJ9(V^OA-si-Txof%c~y% diff --git a/webui/backend/app/db/history_repository.py b/webui/backend/app/db/history_repository.py index 38c9363..209ef8f 100644 --- a/webui/backend/app/db/history_repository.py +++ b/webui/backend/app/db/history_repository.py @@ -6,7 +6,7 @@ from contextlib import contextmanager from datetime import datetime, timezone 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"} diff --git a/webui/backend/app/db/task_repository.py b/webui/backend/app/db/task_repository.py index 1cbb0c0..65c5372 100644 --- a/webui/backend/app/db/task_repository.py +++ b/webui/backend/app/db/task_repository.py @@ -6,7 +6,7 @@ from contextlib import contextmanager from datetime import datetime, timezone 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"} TASK_MIGRATION_COLUMNS: dict[str, str] = { "operation": "TEXT NOT NULL DEFAULT 'copy'", @@ -160,17 +160,18 @@ class TaskRepository: done_items: int | None = None, total_items: int | None = None, current_item: str | None = None, - ) -> None: + ) -> bool: started_at = self._now_iso() with self._connection() as conn: - conn.execute( + cursor = conn.execute( """ UPDATE tasks 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( self, @@ -215,17 +216,18 @@ class TaskRepository: task_id: str, done_items: int | None = None, total_items: int | None = None, - ) -> None: + ) -> bool: finished_at = self._now_iso() with self._connection() as conn: - conn.execute( + cursor = conn.execute( """ UPDATE tasks 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( 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: db_path = Path(self._db_path) if db_path.parent and str(db_path.parent) not in {"", "."}: diff --git a/webui/backend/app/services/__pycache__/archive_download_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/archive_download_task_service.cpython-313.pyc index eb1ff9bb505c97489e488d0278fb31bd26c31290..b4b25d92641eb227d9f1511f63abe45274685b8f 100644 GIT binary patch delta 6839 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 D+_>a} diff --git a/webui/backend/app/services/archive_download_task_service.py b/webui/backend/app/services/archive_download_task_service.py index bef67b0..904c3b1 100644 --- a/webui/backend/app/services/archive_download_task_service.py +++ b/webui/backend/app/services/archive_download_task_service.py @@ -17,6 +17,10 @@ from backend.app.tasks_runner import TaskRunner ARCHIVE_DOWNLOAD_TTL_SECONDS = 30 * 60 +class ArchivePrepareCancelled(Exception): + pass + + class ArchiveDownloadTaskService: def __init__( self, @@ -103,6 +107,13 @@ class ArchiveDownloadTaskService: status_code=400, 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": raise AppError( code="download_not_ready", @@ -147,6 +158,58 @@ class ArchiveDownloadTaskService: "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: self._artifact_root.mkdir(parents=True, exist_ok=True) referenced_paths: set[Path] = set() @@ -177,37 +240,59 @@ class ArchiveDownloadTaskService: total_items = len(target_paths) try: - self._repository.mark_preparing( + self._raise_if_cancelled(task_id) + if not self._repository.mark_preparing( task_id=task_id, done_items=0, total_items=total_items, 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] + self._raise_if_cancelled(task_id) self._file_ops_service._validate_zip_download_archive_names(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: - for resolved_target in resolved_targets: - self._file_ops_service._write_download_target_to_zip(archive, resolved_target) + for index, resolved_target in enumerate(resolved_targets): + 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) + self._raise_if_cancelled(task_id) self._repository.upsert_artifact( task_id=task_id, file_path=str(final_path), file_name=archive_name, expires_at=self._expires_at_iso(), ) - self._repository.mark_ready( + if not self._repository.mark_ready( task_id=task_id, done_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) + except ArchivePrepareCancelled: + self._cleanup_task_artifacts(task_id) except AppError as exc: - self._delete_artifact_record_and_file(task_id, str(partial_path)) - self._delete_artifact_record_and_file(task_id, str(final_path)) - self._repository.mark_failed( + self._cleanup_task_artifacts(task_id) + if self._repository.mark_failed_if_not_cancelled( task_id=task_id, error_code=exc.code, error_message=exc.message, @@ -216,12 +301,11 @@ class ArchiveDownloadTaskService: total_bytes=None, done_items=0, 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: - self._delete_artifact_record_and_file(task_id, str(partial_path)) - self._delete_artifact_record_and_file(task_id, str(final_path)) - self._repository.mark_failed( + self._cleanup_task_artifacts(task_id) + if self._repository.mark_failed_if_not_cancelled( task_id=task_id, error_code="io_error", error_message=str(exc), @@ -230,8 +314,12 @@ class ArchiveDownloadTaskService: total_bytes=None, done_items=0, 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: self._repository.delete_artifact(task_id) @@ -254,6 +342,10 @@ class ArchiveDownloadTaskService: 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: if self._history_repository: self._history_repository.create_entry(**kwargs) @@ -264,3 +356,8 @@ class ArchiveDownloadTaskService: @staticmethod def _is_expired(expires_at: str) -> bool: 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() diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index d999a48..59d9086 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -1005,14 +1005,18 @@ class FileOpsService: 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 if resolved_target.absolute.is_file(): + if on_each_item: + on_each_item() archive.write(resolved_target.absolute, arcname=root_name) return archive.writestr(f"{root_name}/", b"") 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()}" if child.is_dir(): archive.writestr(f"{arcname}/", b"") diff --git a/webui/backend/data/archive_tmp/14e942d8-7921-42c4-bf84-4a0ebbae390c.zip b/webui/backend/data/archive_tmp/14e942d8-7921-42c4-bf84-4a0ebbae390c.zip new file mode 100644 index 0000000000000000000000000000000000000000..2937009412cd2cf2f95378aa78b8abb540062e01 GIT binary patch literal 13228 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 Ly~4yyW7+=$#0C*u delta 1371 zcmXw34NO&K7(U;7{{O)c2|*;u01^3-{9Wor?uE<6i=Zv>2OZ!nNv`Ouqyn3|v{V@8 zYxzg!bpFOPJ!W;&kj?b++&9dB5*@pXYtg_noh)$!uyf zng>R2YO9lQ9k8JhmyZX z+vR4Z***5IBhl@fZ6S@H4tTH?QY5aIR+2D$kEI_z`P3FhR&c8icSmROmeoZ9CoJr; zSSlOXGHF0UyvXPFUs6+BQ?<0Fy0WUSwywVN-MZ?U>Us4GophUBof|WeJIm?0u#P40 z&$zQfDfd9m!T1mhff{nwhS*V&m9=I^O>x#QWADGsZlc`Bl4H$DW}I=?IBje(78^bz zQGclas(+21KGq~Ko4bj>^syo0$W(qh!{j`cB93^`K*It^0dB7|<_0@s$t=vx;%;+j zMA$K4x5y;}Z%0^85$*^Vh3|zO(h_N%v`0E6 zJ(7CmG})GC%lUGP{GPmD?o?iqpUBshd5TXNt~4lLDDBEwHBNcfsQjr0)Esrb`jy(G zcB$W~w=~dR*AldMv{G%mwo>cSj%&AdNgu3_)feg0^iTDj`j7hWdc;UEG~*59Eu-G} z+}LfLG_IPK@z6{)o6TkBEYoA|Gq0Ja&BvD4%CP!bbFHV=AJ!S`khRfTZXdQc+aKH2 z_He(QWyjh#?Q^V_t!8cPF1y4|Kp1wxS_r|LFdkk3g|FZz@(U^rd~fQDuXOm+oi zoKps)-K{avBq=RtY+laT-0TV#i|2xnfv1C{x-STExZXuegOG@`U6l2JoCt`KS~h7s83qS|5@ zh9inW!eBAPxM&bQ_fz<<9}4i0pL*ZsqPcz|H~Pu*fvTMj=Q2|t8cMauEE1&9Qiy@piBV56yBy-2ta7-NAZGdt73n`z508!j 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 index e6d8e5eb063adbb502605f82e8c01de81d841cae..5c368f57957a28c809f29ce29ca1e3f6af1682d1 100644 GIT binary patch delta 6179 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 Iib4qf7n=J+hyVZp delta 4732 zcmbVPYj9h|6~24*y4G8kWJ{JGlAn?7SRwC+lh{cKK_bNQjex|CuN7Hwv?$yOiQHe@HK^9~& zRzgd9b=@Ae&FW^myWM57&}*#~dL{U`Eh9Kw5W1__C1-`$UCp*NxsU&)GUtrCs4H4Z zOQUY;j(Vsk>ZM*jp)pn#EvMz2n_|AGpZYnMVinN<4RBr(tBh9BD$dQZ>SztE;oK4n zMng2jxiwZBt)q3E+hX<62HL>6J=Pd)>Yz=$;)pGXhH02{XRJBeLR*4@PpB1SSA`&# z_6C;exc&ld<*jaL_4FFMcd`&z`#F&eZG&Ri%dBD@F)&&D1&ipOCzb4y?h{hQ{;aQa zR>62pr)DXw21XlMz;JU{2pheMqGl7>bW%xVvoyVXJeyJ#h1R239YPSnSAGW&DSK;QjBuRw_`P*EmSCwpYELKxChJun?-2H7j?IWx*-a=C0o zK18TV_Q`(PG*y#8UgApDqn$GcFb8K&U{20l zz+i7dsZwC2oVjH;^$Z%q6`F^Aw8A5P7-D@Ezj42a2C;{?F36v;43bVa7|2VU6#XhL z408v?=AQCGuOQ6n$1UTfC%_)G?Fbm##95>5#ooBkUBg0M?ixHBUX~Sqr+hdl3<_Zr z>$FGMo%V9#V)xsZ<`#nxT>_vPM)nP)DQ(0CTvIE_Ce(cjO^s!!1?$$mX*HXnhYC$j zY%-4}vU`=m@dO>vY>Jx7!av=gPNr09gZ{8c@zgcbz-|R4RoDdQpXNEPe=_o-asA9=L%J>=CdS+Q;E^>u`uDYs%RF>#p143(#Gy` zEnI+Jm&7%S#RLQbfL#6|wDw7TC4J^TOP`f#uC+vD&82H}B|R3_T3XHigbbM^m)W zL1>H}_qR3RdGg#4CYp0kdN`$I#?)zd!Lyu%vox0tlGTisZ8jF<*}dfza4u)dmGZS9 zav2@-A0G;n!XMl<=d3mVq%uw(nmxI7B(pzt&BUWV#nJ(T=^Y4sRB;Kbzrq-VR;`SU z`ODZb-%2*&w^}yNl@8Bd!%+-{qPtJ&kF)q$q{7cg#Z9?QP~{nKO5@sd)>_aQS|KoS zk13vTk-KJb&n)hp#W90~ofI8`;(pL|2sle&`*9A6gDH&42^8h21K~yl6M_+;1OYP- zl>ovv9#MtkycJuwA>4-0gPBJ=^M81KQU(t(9qg4o#spw&WJ^s&dMx1R@uOwtNfAyigc4?V+SBduhQ)vW~;{#dt&>|kZ}vcB^T zM<*MpzX8(R>G~I8(o{o`Ot2#j)qb4dY$0XRqrj)rC407^nLN!~t;Ar^}(r+Ss3*lY>P-GqZ{etQc*Yn%hd>jE6q$dDoR`Q02$lm;9 z*g#;ZBh3d0S|5Lxb2_18<3}!fzx+9Zx3NtF2DpDb^qHxKS1QIKa%(>}aHyoM0CtXJCnY7OfD; z8TP{B&Rk)8LG;6yanRQguDhN-nHf7&SZxvorw4~k6y0AlAE4=MO39`UX6d{WIfetc zALt8Lr%t6w>cO3&X8}|_07e-&=y#X&k_Xx4C7*$=e_!erTkca3(QO zQ1CFWjT^36)b!{e+%jl&!57`rzR2G+*|sd~KY!Go!ch+bK%n3W6^xm6H25+rM9VyX z+0lL%c{+c-y$Zqw-+X!Zy0jRWFeGqtjdadCiBIcTbbEk3*-?}GFZTVnxGLZG8M8c7 zW6D&VYSh z7%ksp6xxVm> zv0-Nu0=Ciy?2-zXKL5nwH$P`8bWKB!9iRkHlYGqG z(4rakK;p@AX-$vg!(JMO4%&>+g3yYv0bw&j2LeAx9()X|k0AU2;R%H25Pk*kdx8EA z>01aF5H2G86=5MlJp#UbXb>S%gb)xFUu{Q(E27!3=!#D?1g}T|LpS+kHG8SIbLrJa z$q*-nu{bOo(aegnXFNMjQ}6+g7co5u0H6JbRJszYUuBzjRkjQxMIY9T=}`Dm{sw4ep1vj@o{8q%ok;FWjSjRW#>U!466sO+ z#*_ORj85!?cb(#8uk@9|=jnTWEu@rLlxCxQrmIUSHF~fb;*xhNHsWR9P^!E5n;5Si zP1+7`hm8r)kMGIcebS`%k<7sOaB3Yjz$+Br?dtacKGG3FE(ookt@(R`9Pazjd@Hn9>6LxrH?BVk}c2+4&3MGaOG1(#q)=Mtn; zBBcm1BKis{3kwnwY?Z~t#Ku}NG|I-(nU72r19yi=#lQ`J{hsdb-MgKeA8VnX!Jx#E zRcn4QmkPU~1624y$`@RX!${{auOmFAM|D2UtqNEmnquI;}PBhI1&=JlQw9xK2KWJ2Y<7ut>a<;A`YKyjCQ) zlp7$p9`?cdoaT{q|5*t>C1o%&n}~nGR9MCGRa z!0OmWv)nMOMyu0lHSt4oXyiMpH%N^i(Ks6AJ}qfOHV@t;>YvbzuBp+k4$FUfPycBS zH*zJU!S_T$nLJQ8aN~~IYt{eGGO9v&eh$KE@0tG}k`Tu^Z^*kF+)xbci}Ic*598(G zvFtFG0V%Hvl`pX>*l7*gc^N*>#@+4wTZFQ(G(SdLaB3la73c@f(_p$V(M75)8kZ)j zOjMmHN%fpWF$ymiRuVm0uhV!;<0*}2AQTo+%sp2KAbwnOFBfM~V4LiWgnNc*;aAX6 eQ|LWBQ5*4F0+M!8`?9(xt7DE<-}>-NRel3!Z^z*P delta 415 zcmdlTGb4=eGcPX}0}z~u+nOn)x{>c0qs9v$cPc{+LlI*zLlILjV-a((U@(&uLl#RB z3y{SOX0ZkfO_pIwP8JSk2l2C*ir9g29AG&PAd3^s;smm|z$`8xiyO@12C{g-EFK_> z7tG=XviQI(J|K%9%;FCgC=y6#)fCzMhN(ev@=AG;%?&bfYywrh#U=SgiRr0^`YHLz z#Z{BvDakTva!rmGlHaVO+{w84h81BqW;nRz7&H41+Dd8tJ< zAkl6RVGC#3!CCeo7FeSLoaG2-Il)=tAXX=k&=j1!UMo(Z6vVFr5%nOVeX^3aDI?$J z2yGt5&6zq(yh1lw#D#^CYlg&iNv(^LT3;AIybpep H<@IC$u*G5R 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 18ff6c443f1cb818ebeeefe5b8e5868a2bf4ea24..56b1a57f57aa0fd58bdc90a216787baa4d196881 100644 GIT binary patch delta 2060 zcmb7DYfKzf6yDhv!(-WPyWI!Rad~97g&kO1G)2 zKa^LiAvd-SMx&`Q#59Ud)I@(+lg3AXRA?rtHj!T@CjMxNG-9+?@7$T4jf*vLv%BXz z-~GOG&zwDb=|kfWpBuBcY_<#oe9f>Q@mFR($o|okQROxmUNumLZVcQzlq0#!H4ac{ zae|wT4$$FrO0&+l&7i|u48F1zgHK!4!woHd=B+~Fwl*XNf)Kvr8@Iw6!EZ zN7!o)VM@>1%L1dxV8FKg`~T7SPX{GC7^-%H_ZqIFnCo<)cvOwj&BiPYEH^m8-KOg( z4!Bf7T#MPweaK*UBVKx=88;%K^3>2MZM5FW0)y2q>@Mi2F2e39-==(puA^tw(cb!!EUlG5J_}?J^md5{%_&*T;0P>anMbg!$7ckR9$_%=yA2a=hB4KUD zdJIX3C81G;fE=)3iUmo{$#Y8*T9Ys>3GpOMPr?icFEpTHnMpQ_vLvpsC1EyYO|W(< zO@U_Et^<70Ut-!tr6;>@w(&mC*H~e^Nj6yb894JDJS7Yot zlcO;XP(SFlWWy{MwC503$_`f#WFQG>rgS{nS*gcX5$+LO_MR&bnS z{op*^5+|AH86iZBObH>84+}&n!Z9LO9|?`|(_CZtv@jlK*j^_3jDM05$D)B)Ns3{V z6P4p`?%63WD*8o;TiHOYB1NWd{*JnFSU6!J^(LYs6A@Finyic%;m5glW`YaE%2V!$ zs6C_@)d%(mVh2*>rx{)>crqMG(RXtUdq%rWxy@uqP^UV zc!cn26Xk_bg4G5V3gc8VUWHiFmZ z?BMFG9efYtHw!3QTfQ!p@h)&}!DDT@HZ9}r;M)ZcxB(S6UbcfzF4#fW+&f?!x_F2c zPH`%^Qe}D3p4PKQ4aoQ)z?Qs7(+_pe%_EIO&)>pIPpq9hCFAsWc-AYndE1(+Z>^t} zaYoOz79K8omHJZG;zoLW-Br4R%Xpr{-WxQ5T$yJYxAJNnDl^Gd7 ztf&4JC_bpC2Uc5TyiQN|uk^|IAw3PQ!h45mw0uc;b4OZllpN$;^hzZcCoo(vTB^5c%c!(`={V*dO2eA4%lK_iVBHhAZ$Rnh KF*|s0IrncecC{{NXHM5P?bgfJRT|-%27N`T^^1gLu2V1^{ZhJXZBl1TWPY-Gsp;IzSJm$}%dF;v@SY!K2#on)aF(?*eg zKE1=3+vKAp-YRNN_lw=}UNoq0?;gsaEDNaq(Ky1z71a0QfW(5Mjjn-eCqOz$D z%IhT6t*Z>Q9}SY3W0^FRbet!HogX-~6Y9Iy1Jj>Ly1MUV*q><|dPdig+`$Jl^j;ER zSsP_n1c(exgJWEP*5OQeI3$pn;WY$xAQF7}V+y>#F7(A+h0%bDOXsm;n7_!!U0Y*b z74NXr7n4=I-O}Rd85IXC^+vVg5=;HDJu0rWv?$u7;xbFO#ITAhk~$K()v01%ybZXM>Xgw oo|6k_<-#S(&_tV)VjUOGzGU3 None: + def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target, on_each_item=None) -> None: archive.writestr("partial.txt", b"partial") 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): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -221,6 +236,71 @@ class DownloadApiGoldenTest(unittest.TestCase): self.assertEqual(task["error_code"], "io_error") 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: (self.root / "docs").mkdir() (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") diff --git a/webui/backend/tests/golden/test_api_history_golden.py b/webui/backend/tests/golden/test_api_history_golden.py index 7653069..899bb98 100644 --- a/webui/backend/tests/golden/test_api_history_golden.py +++ b/webui/backend/tests/golden/test_api_history_golden.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio import sys import tempfile +import threading import time import unittest from pathlib import Path @@ -11,12 +12,13 @@ import httpx 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.task_repository import TaskRepository 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.archive_download_task_service import ArchiveDownloadTaskService from backend.app.services.copy_task_service import CopyTaskService from backend.app.services.file_ops_service import FileOpsService from backend.app.services.history_service import HistoryService @@ -30,6 +32,21 @@ class FailingCopyFilesystemAdapter(FilesystemAdapter): 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): def setUp(self) -> None: self.temp_dir = tempfile.TemporaryDirectory() @@ -38,6 +55,7 @@ class HistoryApiGoldenTest(unittest.TestCase): self.root1.mkdir(parents=True, exist_ok=True) self.root2.mkdir(parents=True, exist_ok=True) 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.history_repo = HistoryRepository(db_path) self.path_guard = PathGuard({'storage1': str(self.root1), 'storage2': str(self.root2)}) @@ -47,9 +65,17 @@ class HistoryApiGoldenTest(unittest.TestCase): app.dependency_overrides.clear() 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) - 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) 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) @@ -58,6 +84,9 @@ class HistoryApiGoldenTest(unittest.TestCase): async def _override_file_ops_service() -> FileOpsService: return file_ops_service + async def _override_archive_service() -> ArchiveDownloadTaskService: + return archive_service + async def _override_copy_service() -> CopyTaskService: return copy_service @@ -71,6 +100,7 @@ class HistoryApiGoldenTest(unittest.TestCase): return history_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_move_task_service] = _override_move_service app.dependency_overrides[get_task_service] = _override_task_service @@ -91,7 +121,7 @@ class HistoryApiGoldenTest(unittest.TestCase): while time.time() < deadline: response = self._request('GET', f'/api/tasks/{task_id}') body = response.json() - if body['status'] in {'completed', 'failed', 'ready'}: + if body['status'] in {'completed', 'failed', 'ready', 'cancelled'}: return body time.sleep(0.02) 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_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: (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) 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'}) diff --git a/webui/backend/tests/golden/test_api_tasks_golden.py b/webui/backend/tests/golden/test_api_tasks_golden.py index 88840e2..bd14b72 100644 --- a/webui/backend/tests/golden/test_api_tasks_golden.py +++ b/webui/backend/tests/golden/test_api_tasks_golden.py @@ -263,6 +263,28 @@ class TasksApiGoldenTest(unittest.TestCase): self.assertEqual(body["status"], "ready") 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: response = self._get("/api/tasks/task-missing") diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 3084fec..9e25d32 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -74,6 +74,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="download-modal-progress-bar"', body) self.assertIn('id="download-modal-count"', 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="context-menu"', 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 markZipDownloadReady(fileName)', app_js) self.assertIn('function markZipDownloadFailed(err)', app_js) + self.assertIn('function markZipDownloadCancelled()', app_js) self.assertIn('function closeDownloadModal()', app_js) self.assertIn('function zipDownloadRequestKey(paths)', app_js) self.assertIn('async function createArchiveDownloadTask(paths)', 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('async function requestArchiveDownloadCancel()', app_js) self.assertIn('async function waitForArchiveDownloadReady(taskId)', app_js) self.assertIn('function contextMenuElements()', 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('countText: "Browser download started"', 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('downloadProgressState.requestKey === requestKey', app_js) self.assertIn('setStatus("Preparing download...");', app_js) self.assertIn('"/api/files/download/archive-prepare"', 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)}/cancel`', app_js) self.assertIn('function applyContextMenuSelection()', app_js) self.assertIn('function startContextMenuOpen()', app_js) self.assertIn('function startContextMenuEdit()', app_js) diff --git a/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc b/webui/backend/tests/unit/__pycache__/test_task_repository.cpython-313.pyc index 0cb995bd7aa8b891252ca2b28f40f34695523bb6..8de61b3eb3dc649019f9b367c481c44e516a0370 100644 GIT binary patch delta 800 zcmb7=Pe>F|9LMMF%sT&f*qw!SMRO(1X8n_EZE7VUB9WwpV+0b7)6TR`>Wsb_3B3qG zq#;;5Di0m>rl$%K&z(Ch7Ru5g=)MRp1fF_t0-aC0{+{QwG! z*PE!ZNrrkR+yihw63fw8|*hOgTBh?Sbd4B-x%NQC^d9_=W@GMIY)y5bRpgbhR|4i z3p_^Ecm+u4S7KPag+GS(h)kf%Y96#AM{Q)QSvlJM`y6_uwgVmQtE4jzPv#JP$H+bTyQmkN3(%!Sev^H~OqG;?%r=HEOw6o?U$_`q6~3@Zum&@JG2v$A z`ywUGYV%!d@(M8>M)%2A#X41kfQoOi 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: legacy_db_path = Path(self.temp_dir.name) / "legacy.db" conn = sqlite3.connect(legacy_db_path) diff --git a/webui/html/app.js b/webui/html/app.js index 0960de0..68638a8 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -86,6 +86,8 @@ let downloadProgressState = { archiveLabel: "", totalItems: 0, requestKey: null, + taskId: null, + cancelRequested: false, }; let folderUploadPlanState = { targetPane: "left", @@ -336,6 +338,7 @@ function downloadModalElements() { count: document.getElementById("download-modal-count"), progressBar: document.getElementById("download-modal-progress-bar"), status: document.getElementById("download-modal-status"), + cancelButton: document.getElementById("download-modal-cancel-btn"), closeButton: document.getElementById("download-modal-close-btn"), }; } @@ -400,6 +403,8 @@ function updateDownloadModalDisplay(info) { elements.count.textContent = info.countText || ""; elements.status.textContent = info.statusText || ""; 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.classList.toggle("hidden", !!info.active); } @@ -410,6 +415,8 @@ function openZipDownloadModal(selectedItems) { downloadProgressState.archiveLabel = "ZIP archive"; downloadProgressState.totalItems = selectedItems.length; downloadProgressState.requestKey = zipDownloadRequestKey(requestPaths); + downloadProgressState.taskId = null; + downloadProgressState.cancelRequested = false; setDownloadModalVisible(true); updateDownloadModalDisplay({ active: true, @@ -418,6 +425,8 @@ function openZipDownloadModal(selectedItems) { countText: "Preparing zip download", statusText: "Preparing download...", percent: 20, + cancelVisible: true, + cancelDisabled: true, }); requestAnimationFrame(() => { if (!downloadProgressState.active) { @@ -430,12 +439,15 @@ function openZipDownloadModal(selectedItems) { countText: "Zip preflight and packaging", statusText: "Preparing download...", percent: 55, + cancelVisible: true, + cancelDisabled: !downloadProgressState.taskId || downloadProgressState.cancelRequested, }); }); } function markZipDownloadReady(fileName) { downloadProgressState.active = false; + downloadProgressState.cancelRequested = false; downloadProgressState.archiveLabel = fileName || "ZIP archive"; updateDownloadModalDisplay({ active: false, @@ -444,12 +456,14 @@ function markZipDownloadReady(fileName) { countText: "Browser download started", statusText: "Download started", percent: 100, + cancelVisible: false, }); window.setTimeout(closeDownloadModal, 480); } function markZipDownloadFailed(err) { downloadProgressState.active = false; + downloadProgressState.cancelRequested = false; updateDownloadModalDisplay({ active: false, targetText: "Preparing download...", @@ -457,6 +471,21 @@ function markZipDownloadFailed(err) { countText: "Zip download failed", statusText: err.message || "Download failed", 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...", 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", - 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, + cancelVisible: true, + cancelDisabled: !downloadProgressState.taskId || downloadProgressState.cancelRequested, }); } @@ -484,6 +515,11 @@ async function waitForArchiveDownloadReady(taskId) { if (task.status === "ready") { return task; } + if (task.status === "cancelled") { + const err = new Error("Archive download was cancelled"); + err.code = "download_cancelled"; + throw err; + } if (task.status === "failed") { const err = new Error(task.error_message || "Archive download failed"); err.code = task.error_code || null; @@ -501,6 +537,8 @@ function closeDownloadModal() { downloadProgressState.archiveLabel = ""; downloadProgressState.totalItems = 0; downloadProgressState.requestKey = null; + downloadProgressState.taskId = null; + downloadProgressState.cancelRequested = false; updateDownloadModalDisplay({ active: false, targetText: "", @@ -508,6 +546,7 @@ function closeDownloadModal() { countText: "", statusText: "", percent: 0, + cancelVisible: false, }); setDownloadModalVisible(false); } @@ -679,6 +718,13 @@ async function startDownloadSelected() { const selected = selectedItems[0]; if (zipDownload) { 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); startArchiveDownload(task.id, task.destination); markZipDownloadReady(task.destination); @@ -697,8 +743,13 @@ async function startDownloadSelected() { setStatus(`Download started: ${anchor.download}`); } catch (err) { if (zipDownload) { - markZipDownloadFailed(err); - setStatus("Download failed"); + if (err.code === "download_cancelled") { + markZipDownloadCancelled(); + setStatus("Download cancelled"); + } else { + markZipDownloadFailed(err); + setStatus("Download failed"); + } } else { setActionError("Download", err); } @@ -1004,6 +1055,10 @@ async function getTaskRequest(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) { const anchor = document.createElement("a"); anchor.href = `/api/files/download/archive/${encodeURIComponent(taskId)}`; @@ -1013,6 +1068,31 @@ function startArchiveDownload(taskId, fileName) { 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) { const formData = new FormData(); formData.append("target_path", targetPath); @@ -4164,6 +4244,14 @@ function setupEvents() { }; } const downloadModal = downloadModalElements(); + if (downloadModal.cancelButton) { + downloadModal.cancelButton.onclick = () => { + requestArchiveDownloadCancel().catch((err) => { + markZipDownloadFailed(err); + setStatus("Download failed"); + }); + }; + } if (downloadModal.closeButton) { downloadModal.closeButton.onclick = closeDownloadModal; } diff --git a/webui/html/index.html b/webui/html/index.html index 5165c86..2823fc4 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -129,6 +129,7 @@