From d463b3977d5e6bd172b28860f47f5f2808ab8207 Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 14:24:52 +0100 Subject: [PATCH] feat: B2 uit voor veilige archive-downloads --- .../__pycache__/dependencies.cpython-313.pyc | Bin 5479 -> 6548 bytes .../__pycache__/tasks_runner.cpython-313.pyc | Bin 11269 -> 11582 bytes .../__pycache__/routes_files.cpython-313.pyc | Bin 6676 -> 7862 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 9984 -> 10184 bytes webui/backend/app/api/routes_files.py | 26 +- webui/backend/app/api/schemas.py | 4 + .../task_repository.cpython-313.pyc | Bin 12745 -> 17729 bytes webui/backend/app/db/task_repository.py | 110 +++++- webui/backend/app/dependencies.py | 23 ++ ...hive_download_task_service.cpython-313.pyc | Bin 0 -> 13138 bytes .../file_ops_service.cpython-313.pyc | Bin 42649 -> 42790 bytes .../services/archive_download_task_service.py | 266 ++++++++++++++ .../backend/app/services/file_ops_service.py | 36 +- webui/backend/app/tasks_runner.py | 7 + .../test_api_download_golden.cpython-313.pyc | Bin 18744 -> 20454 bytes .../test_api_history_golden.cpython-313.pyc | Bin 19998 -> 20390 bytes .../test_api_tasks_golden.cpython-313.pyc | Bin 10213 -> 11032 bytes .../tests/golden/test_api_download_golden.py | 341 +++++++++--------- .../tests/golden/test_api_history_golden.py | 19 +- .../tests/golden/test_api_tasks_golden.py | 22 ++ .../tests/golden/test_ui_smoke_golden.py | 12 +- .../test_task_repository.cpython-313.pyc | Bin 4346 -> 5358 bytes .../tests/unit/test_task_repository.py | 21 ++ webui/html/app.js | 62 +++- 24 files changed, 754 insertions(+), 195 deletions(-) create mode 100644 webui/backend/app/services/__pycache__/archive_download_task_service.cpython-313.pyc create mode 100644 webui/backend/app/services/archive_download_task_service.py diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index bfdf51492d0b5b4ba1dc8809a499cf397af916ee..55cedd40fad106c6f30f82e2d37feb567667920d 100644 GIT binary patch literal 6548 zcmc&&%~Ko66`#=;LIQ*(zOnfhwy;4q24f7^#`b1iGd8=+WNTwcQ(F?x<77&l_`vl=zK@k*D79H zK?pJ&kV8@fG)Q3xv-hCfC`BN`a7d0yP0++}gWN2&KnufRIVQD2E5nWQ87U5Nh9h!9 zYJ)b0qjI~{0UZoC$(>RcbTQm4cS}9c!*Gk-E1iY249DbiQXlj&+$#4=12Dkw8F^3| zf+2?E@~|`lBMc|xq%;bn47bVWr3-L@;dVJCjltM}@TGr9P&$;(Sy8#DboB^d1*WT# zf{PB?C8gU%yX2saD?KjSxPvyK^txyh4q93{>!PI{w9Cpl7wxixc17uP(XKdXlS;pf zHmO`y2B-(FDpSfJLsQDMGQ`lda!na#=$bO4j4(8#Tvw6|T~}t6QHExf8_IcxZYXog z1%~F7c_qcr{Ijvt!dsGBN;IRnVzH#>^g^kq;cMdtY-RJg{JM%SflqV#ddg>p6;;;@ z#b=t?^i0*WTJ;S_#P5_!&o^`Md|54*w1QrOSJ->zenBJX>WuS6T)i#7%xR9v+c^0b zg$-4ErRnPC;%cs}tAHyu(oR0!%E2nW#P~-Q6GW{+>Fa$)zx9+qCH?rF`6n!{xsV>p|3SJXWvLa)nP z#i9ytMN0_{o4H?Wh2N<8swuMvIlf;I#G`ox-wGKT%Pwy4MInQ9>-t<+t_BL;Lr+DV z6-R`WZwW=z1f`Om&21ENnyRJz(13Jkq2?Sm+oBofde!Jl1LojMb!DrNUdiR3tHsrH zu3S#9s%3;~F<(%%iSjG6h0LLhs>wbh-M}DD?7>^3=C$zm?&NoUukY_i+umH*8=9%$ zFM53^aQz6Wf{RxSZ5FQ`251gw$;ThVTIc*q9x(fwh{!WOJ6qKoW^GK#O97 z8;PDZqJ2AoJ~q|$) zHJUa~4S9{r^h4Nz0R$@x1Wu>3!wL^InIH~fSFMNQhpPvJjo%73elublf*G;>(~O^# zb1DGcxDK;;=C?qChY@OQM~~S~!XhUSUlbTMiaF%r?fZ#QBXMCrkunk&_j|7xy;lz# z0!^WJLLeAA$viQ{nEwL;C-a;^XLPmM$2d!3^IX&9&Uh`8m3c0{-ZCrk+(bnj#{j`+ zGvQ%Ew$fBq-z=v>EKHkzO$VSIGXpRiK-EecFH|#B&VgFgPdH^aNsF(O1HG`8%j;Q6 z06;SP2yg8wHjWV8@<;uf`+IGvN?Xc^jqSxIE3ru;6Z&! z{*KJ(CayS)Vb>|mA7e^rIKB~K#=j;~4E322M@E^#H8BXa;)7)u%R)TH5oa-oRRCC= zu$lmF;|v+^BQmbH$mxEx>-!~RWNLTu3uEz7CHi;ruuL6~g$bRTFVHp!W?rLtCIl$7}i-WmzTVzhfdF=!-) z>$}2xjqyA(>gJhJE93bu2tEwYnVL6nAIMNTWLF)#Xy&xq=o9lP#AIT8T;>c%dv6+3J<3+3fO|R3qS#tg0JIXe{(tL5e9YNqx*Y z$+x_LYtZS6tJTueO84AUC7kvJ-r6!YmIjktgDj-Q`i$6s71H**&+m68jZSRGjn4Fb z|FqFRgK@4WavTvldv|-MjNWTT$IM}SltN!L7<#W!!1{V|aBExI(2kEEErLxvFS$kVIZoGT39@xiu*u_H zyU2~0uK)X7LZ=>|tw&5E6gaG{lf`=-l||QDA$*!67b2ezJ4H16&F>PCin%5@N>%UPke4KElzj@piaY_il2|NG?>OH+KRz z-@_78T|4}Mq6X3y3YZRWjvVH33YMicc3iF{uME!kJGdWX0zbOF0*lMtC-hIr40=N^xi;K+(u!ArA9Me{TpEd|3;X|mx^nJXFz$$ zs&9v!)YX-Vnj6~32q5{GgPwuh4;Y6rTA82ZS zIFMG&e&=GEn%{zg{%XQe&C=g3X3{;1m8+V~GB-8Sg*jY*th>}D?VF=@N1n_!ElY5ygyn9L7<{txeeU eiQ?>WID{z98c}?TWKq0DO-B3|wogS?#Qqx^+e;S! delta 1883 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 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 feb4169319f59ea63404e2e388ae313490a186ec..45e1530cbdf8e5dd4df6687cce1a0ef018fa99b4 100644 GIT binary patch delta 3063 zcmZ`*O>7%Q6yEjP>;J#R&X1iqw)5*av70m@HKa|_RFpbUm24~2vZ>|7o5V>Phgmx* zK{PE0p^6LbAOsRfz4n4Yg~APx5QmC`(+ag(38=&Y_2w!C!~w*-S=+IlW~DsOzW2TF z{mh%$d@ywTux-a`H8JFQ`TSSuzg*XBO`1{NwVq4kaS!suy~yil1V-SeTH>v!HSR+` z`mUd9i~EtEt_@Rxcn}5sjK~EU!6=wwENU0q@d1t7Y8G9BC8iOzK}PglE4bbLg4M$; zX_wexh)EvQS%liehFvJW!8O*~Ymi+B7rn2IBTHYDs-ey^iU{IGOHhE)sP~* zxyo<4DjUrd|Hrn*4hZfV)*i8aT2MJErO+UHA&THq>%7jnMtzKr5e9_D8iw9|7@EYo zs*;Wm3e7dH;eA{^aSm2FlgV%$p&ffhXs=<2?!(Yg%ixZU2%R;~2ljF9dYW^0jq|`h z&OLja-6KM%#(A(ftG!}V)y6g+62djmgA0A382(bb*<@AR6KHNRy)K zNzUS^?y9NobW&cPL{c(~KhyP0Ym~r(luaa6d?JH#$t7)wRXoKo$BJG+E#Nv?1i}B`YNf|BfZvUD(4v+2-5DDG} zu4Xx#%;x08TqY&WK@z10TJbi+usfr8!V$n3gKJA*V{8XA!p*ZG%}I2G074qYI|sug zl}D%;gia6|BbYsVqS#C0R>Y!H1U60nWJu`|bD1fa7)$+qb7b2b`TUiyXTF%pn&NErgu+?(KqPerc-a?e&^Cwh(Af1tuN}(IAKQcw-0aIhre0K+lTY^ z=ka6v5T3H$9)kT;EGW6UASD(_77|i(eTnFI6DCRQDvsg9ww5tk%yB@`EVb%Db%Fw> zN=>zDs1@CMIwfV$IbvSGAK89&KL@~Z5EZS#@7V)g>X?CLZ^_(RY91wsZ5`jWAGe}2 zWVy*l(-)Ep66yUiE8-6w0pEF|uI`nNLeMNRyp4Z$yuhBpKIbNZf9&iG?u8q(i@6K4 ztI6~Vl8DRu_@NVALUq^GQ!tLduWRe2nT``XS^&{OMAdz=2wD)IVpIKzE!rR_=xCQ( z$x7jol+B@4R7YMyJfe{LDhNu$tE<9pdy3r-b`yf@Md9QuD&8@(nam1;|5lKR*{Tez zvNnO2LEXM3K+)@D8CK;KRb(?D`3hu>-LY%mG_IuO?5iY+nJN~DPv@mrrnMZV1y;1o zMwNz6Jxz#)*|k&u%7kgdjAbV1smfH0M7Ky~qW9_^03~ogg`fda8NxrfzSAS<6Kcj+ z+`cgnEWJc1gR{xGWob1PNv^F$iXSaGQhO$=zGK)^AlM~NN8#ykc8Hmw-vPxidLfg_tw=}EMWSQL zpPbU-@rG7UmiwD1O7c&-{*4iTVb0#lHk{CGd9Qf$)}DN9Dt}hI?9H(4G z^`0*1wCpkVfi+m*VDZq}t!fH9!#6$AgKmdoH0RlZ2G#`|qcObex#G!NLigC-Dk3Ax kKd?Fq9Qma*2`phe-t4;dV^f^fZjk){2NHmB=l}o! delta 2274 zcmaJ>OK%%h6rQoijvsLn#~wfOaBL@W;<$Mr5~ZPOqE@IhX(_Z^NUS*Lj+5AvxMTU~-gCb1yzaU8 z+l!yh_Z)^o0Rg`7)#v)1@ROc|>tpH3c3@y^p`uH@nri8?piI5)N*z12=h8g3|Sp@?0myk#l6g;d{dA zJ7$+i29KQ|%|dhqJ&|cL$o3*I-nMx1CB{9&<3yZH?#W*tTa+46^?`2kfag6&9`NmR48GdPP%>nyH$stLr!K zFm!{a*dJXZp0U|tt;Qr@CNP3Z6g_O#_v3gDw-YGRAWRpCbefK$V)97DSjs;wjqsn4U(%EC@@&JZTn|rUW7lpB-@yAt-X} z&%oX)=4ScI2356^UeAk`bYIsVnkB84s!zqgbL>V0(%E z6MD#g>HBUOaaK1iR<|^DTPv0{N-v-)Xx5AMhN*5EpjrkEy$Av-S;sY3tg`RJL#upw z8I?f+A1Y+2eNOXnyBAJ|B23okE*1lQp zTB%HLg6)0wR^$!;A)d_Rv7E?0iF-UgM~M)K&p#Vw8A2E#795xpwjVo#%c z0PmN_v!~(y`gUV?qgvD}^c|q=u{$zSzLY<)JA8q?78{x3S@Qhf1<>gUyNg;BAF&T( zYx}%8eW>FdO2W$WQmx*gRT_p(-b*>8zYRha+(ugLp)>3~Xb(^vUCV9OX>)!z45LC{ zL3}{iZw+jF#fL2Kvybi`qWGx3hpXchZDbsg-WH_8-g{!-^H+4UeixFs*X9M|^I2&P z$P4zL5s)Ka60m9K{OB2c855ee0;*bWK!-I|We16GhOER!aq|PMTAG1pW~QXoV5wC% zbO=-#itgQP-#?)eITcJCk6X9?E43qkoTNK3?LYCbgh{V_2|AOgA{>|4fgr#SX2i9EA L)L|k@I`IAn33qiQ diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index 90d63395bb5c4250b78fa8bca19d785517a52c38..abc3728d270446e6c1d19ae95c5ec811f0cf3053 100644 GIT binary patch delta 2262 zcmZvde{54#6vun_LFD$P_~YkzisJg~UHh2via=(F9{+#Ni(#{NeqcHwQ$P^t1Es z`Ek!Z_uPBOVprD`O}X7poBF%n_eG+)eU)6LQv`o+1x}joy{{7 z8AFEYo+G5yLRCxOX!W92`8`J=my^CPDWq0=MCjCSU$51}q@KRCM@2n{erR7ue>rrm z0g471b?Bmj73Um_Y0#yI8=(N;I%QYbZ@cMqk9Wy3)=Wofq#XsmBF@W^G)4!K@vR0; zR#t0Guxg^O3PPf3I`C`?JN*dmYlafwSr*`nqc*eU+s>FuJL_B|+UZcFN^6C_m7XoE z6|FO&-Y>L5bwCNUKzJ0YLs_B{3q43qMuAxX*UYyRBG3jQ0&oUPd4ltEW;1Ne3TR)f)MPo*|pr`J3Y(M9igI&>aj(=M340>puh zz#w+XxgH`gKt&}fv7R%&RkJ{K;vy{xWt8lt2b~5)AW0vWHVA|5ek(P#5h&WIzpO&^ z({NcrseUeN*0y6<@)R5e7@@`GZ6e98N6Rw}yVY1MQ@Ame+7jLwj-`@@+y=LPzzP7# z1k%**)%~n4d8!(V8HEPeO~<^;#3;}7z&kAVQ>tQ#%I~v^MzI%36=z@y>{mZ?9)@c6C!{xk zT>zFU#}s061J(H!s$6<~Ev0+ZNY-hl<5D%~Ks+t?(kH$;`qfwNe-|Ey0L&`i0|?;! zod+NSN2uCQ;sB>`)xWW1LXE|&5?Yhe^VO@oEOOz-;CK}Hkaveu1}5lob&EK{fgai$ z>BT^K`KPeMdb0L(+#DK6Y?8Zhc}0&y0l>m^DX5aTJ#VhM=_(#yX_e*pbWZCi7gcSChzf}PO`tMv;1w`x37$|rS6DmAn< zE{D3(#@0SL4qx9H_yVw#=qW4RGc!T%>s5uC82Ou`(%q-M7|30}G$SuQ4LQtzqp z`fe*_ri9nOLS0uWx#O|rM|hI}n3+f%CnRtqFJP77Jjc3K6W8x3owYLS=E5gmanC^N zfIL16T@l0x^JJ zg0+zNV&z*CS0CR>eBbaz!Y5bq(c_cE-OkNBVGAm2ZYS;z-W@J4SLGtLM1#)DDxy$7 Z^CZQiU%Q0x9__ksQ~&NeC_3w>e*qLM7-0Ya delta 2090 zcmZvde@s(X6vw>?kMar#wLod<4`>Vi00$cZfx%EBV{DXQ)D4+JfkrE(-CkekKpZlS zZi{5FvpN4DBL+j_G!a~kF;TO`EnDJWZplU}fBMh<*s@@^<;#AOm8EWwzw!MygHLm&vn?qrDT`ZHQc{oX+1mO|^+sokh|<2aMa3qa zG#eQex3JM-Y9X~L2hDa2Iway7gJC_M8V!yHs8e+*B@jwzS=k~=*!xtPlN!@&goE_- zZlw%z8U38@6=f`UI;yC};1zc2QXNV;+&AT>~{ z(JdM{(bvYcM9q+ZdKl}8nmJKop(-_HmK(7%ng{UGw?=ni3rwI1h6~^lT4*uzt48+5 zj>aw&@5)JWr`^I2s|E6i59oz*B> zxtN^ny$Yv&osMQ#DIL%|Xg1p;I{s6vi7cBe&lTK-0AN217r+H~adcJA{fZup8G!X( zNJZJcr>blT*Of8LEuf>6g?U_-m4#t4flur!BEtv zbyK-HD19%O>y!}uhI!5)Oa}dJZW2Mx)tNV1aY72dQDc835?Wi-5PZ9VL;_f0AVPoT zIjt<`{;!PHadE&2>dN0EG@fZ8KPFC7X~AyMO~HaHagvLQAqjAr?iSREm~?HXT8l|c zQoE(DcpNe?DgRG!80R)D8STHM+7qWW&Z+NO{_#%3lTYh7toMNtU=-jbYH!k`!Wucf z$y#q3lf!W}s{1wBZoPh8J4uJFp1ia0J_mdRd<=X-A6PA`^`5~1Fik&MiRLU;8n^Z5 zUX~+r;~^&IqZQj5d2I4FVSbH(&w1HXFoDYyE2e34D>Xxbe++ zoLK|1Xcqt$XI2Yr%e#d!-ej^? zf$t1HqGS^75`-~c;v7ui4$V3}VooM$rc_tH^}ZY%HHCsvy=QkMGBoVhhF;SH!-up9 z#1+mX4uGHbA;5*tbAqcbi}44HC1yv2>k6I$2+?#2*;)qU*;f;RSGas{@qE(*IC8Pi zcGh#7%-7L*c%xf2beQa_j1rGE*@OH=(bjzuFTpi<0uLxy(kHHQ&8j<3ERoAyAb5r9 zbvOY_)aT9@*SWqax8J!8f$K}&Q`!NH?Ev25ZM^{#Sf-txZQ=$8cRFx3Mw<5&h{tr# zQ)HZg8*UY?ox^Uz1RhhC*Om^;x7AB8c(;$+An~`b3|2W%3GjCcA0w>+Xa-sUzQXyQ z?!qHqfqk&}PQrD;7XiQS{EG1l!u_7;OKvdqhCJw3B>4n+?|5%`ja+die3DHaH@rL? Z8H|MjP1FkGN%qq3==ah`{{jN8@8|#k diff --git a/webui/backend/app/api/routes_files.py b/webui/backend/app/api/routes_files.py index db2655d..4af2200 100644 --- a/webui/backend/app/api/routes_files.py +++ b/webui/backend/app/api/routes_files.py @@ -4,8 +4,9 @@ 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 DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, UploadResponse, ViewResponse -from backend.app.dependencies import get_file_ops_service +from backend.app.api.schemas import ArchivePrepareRequest, DeleteRequest, DeleteResponse, FileInfoResponse, MkdirRequest, MkdirResponse, RenameRequest, RenameResponse, SaveRequest, SaveResponse, TaskCreateResponse, 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 router = APIRouter(prefix="/files") @@ -78,6 +79,27 @@ async def download( return response +@router.post("/download/archive-prepare", response_model=TaskCreateResponse, status_code=202) +async def archive_prepare( + request: ArchivePrepareRequest, + service: ArchiveDownloadTaskService = Depends(get_archive_download_task_service), +) -> TaskCreateResponse: + return service.create_archive_prepare_task(paths=request.paths) + + +@router.get("/download/archive/{task_id}") +async def archive_download( + task_id: str, + service: ArchiveDownloadTaskService = Depends(get_archive_download_task_service), +) -> StreamingResponse: + prepared = service.prepare_ready_archive_download(task_id=task_id) + return StreamingResponse( + prepared["content"], + headers=prepared["headers"], + media_type=prepared["content_type"], + ) + + @router.get("/video") async def video( path: str, diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index bdd982e..6db03cd 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -88,6 +88,10 @@ class SaveResponse(BaseModel): modified: str +class ArchivePrepareRequest(BaseModel): + paths: list[str] + + class FileInfoResponse(BaseModel): name: str path: str diff --git a/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc b/webui/backend/app/db/__pycache__/task_repository.cpython-313.pyc index 7ea4c5f3edd39774b46cc2b0d0b3a7ae9c5f15b7..aff9cd268f5a4bd6c34ef0af260a1134dd5173b2 100644 GIT binary patch delta 5607 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 diff --git a/webui/backend/app/db/task_repository.py b/webui/backend/app/db/task_repository.py index 4fce547..1cbb0c0 100644 --- a/webui/backend/app/db/task_repository.py +++ b/webui/backend/app/db/task_repository.py @@ -6,8 +6,8 @@ from contextlib import contextmanager from datetime import datetime, timezone from pathlib import Path -VALID_STATUSES = {"queued", "running", "completed", "failed"} -VALID_OPERATIONS = {"copy", "move"} +VALID_STATUSES = {"queued", "running", "completed", "failed", "requested", "preparing", "ready"} +VALID_OPERATIONS = {"copy", "move", "download"} TASK_MIGRATION_COLUMNS: dict[str, str] = { "operation": "TEXT NOT NULL DEFAULT 'copy'", "status": "TEXT NOT NULL DEFAULT 'queued'", @@ -32,9 +32,18 @@ class TaskRepository: self._db_path = db_path self._ensure_schema() - def create_task(self, operation: str, source: str, destination: str, task_id: str | None = None) -> dict: + def create_task( + self, + operation: str, + source: str, + destination: str, + task_id: str | None = None, + status: str = "queued", + ) -> dict: if operation not in VALID_OPERATIONS: raise ValueError("invalid operation") + if status not in VALID_STATUSES: + raise ValueError("invalid status") task_id = task_id or str(uuid.uuid4()) created_at = self._now_iso() @@ -52,7 +61,7 @@ class TaskRepository: ( task_id, operation, - "queued", + status, source, destination, None, @@ -145,6 +154,24 @@ class TaskRepository: ("running", started_at, done_bytes, total_bytes, done_items, total_items, current_item, task_id), ) + def mark_preparing( + self, + task_id: str, + done_items: int | None = None, + total_items: int | None = None, + current_item: str | None = None, + ) -> None: + started_at = self._now_iso() + with self._connection() as conn: + conn.execute( + """ + UPDATE tasks + SET status = ?, started_at = COALESCE(started_at, ?), done_items = ?, total_items = ?, current_item = ? + WHERE id = ? + """, + ("preparing", started_at, done_items, total_items, current_item, task_id), + ) + def update_progress( self, task_id: str, @@ -183,6 +210,23 @@ class TaskRepository: ("completed", finished_at, done_bytes, total_bytes, done_items, total_items, task_id), ) + def mark_ready( + self, + task_id: str, + done_items: int | None = None, + total_items: int | None = None, + ) -> None: + finished_at = self._now_iso() + with self._connection() as conn: + conn.execute( + """ + UPDATE tasks + SET status = ?, finished_at = ?, done_items = ?, total_items = ?, current_item = NULL + WHERE id = ? + """, + ("ready", finished_at, done_items, total_items, task_id), + ) + def mark_failed( self, task_id: str, @@ -244,14 +288,62 @@ class TaskRepository: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS task_artifacts ( + task_id TEXT PRIMARY KEY, + file_path TEXT NOT NULL, + file_name TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + ) + """ + ) conn.execute( """ CREATE INDEX IF NOT EXISTS idx_tasks_created_at_desc ON tasks(created_at DESC) """ ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_task_artifacts_expires_at + ON task_artifacts(expires_at ASC) + """ + ) self._migrate_tasks_columns(conn) + def upsert_artifact(self, *, task_id: str, file_path: str, file_name: str, expires_at: str) -> dict: + created_at = self._now_iso() + with self._connection() as conn: + conn.execute( + """ + INSERT INTO task_artifacts (task_id, file_path, file_name, expires_at, created_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(task_id) DO UPDATE SET + file_path = excluded.file_path, + file_name = excluded.file_name, + expires_at = excluded.expires_at + """, + (task_id, file_path, file_name, expires_at, created_at), + ) + row = conn.execute("SELECT * FROM task_artifacts WHERE task_id = ?", (task_id,)).fetchone() + return self._artifact_to_dict(row) + + def get_artifact(self, task_id: str) -> dict | None: + with self._connection() as conn: + row = conn.execute("SELECT * FROM task_artifacts WHERE task_id = ?", (task_id,)).fetchone() + return self._artifact_to_dict(row) if row else None + + def list_artifacts(self) -> list[dict]: + with self._connection() as conn: + rows = conn.execute("SELECT * FROM task_artifacts ORDER BY created_at ASC").fetchall() + return [self._artifact_to_dict(row) for row in rows] + + def delete_artifact(self, task_id: str) -> None: + with self._connection() as conn: + conn.execute("DELETE FROM task_artifacts WHERE task_id = ?", (task_id,)) + def _migrate_tasks_columns(self, conn: sqlite3.Connection) -> None: rows = conn.execute("PRAGMA table_info(tasks)").fetchall() existing_columns = {row["name"] for row in rows} @@ -298,6 +390,16 @@ class TaskRepository: "finished_at": row["finished_at"], } + @staticmethod + def _artifact_to_dict(row: sqlite3.Row) -> dict: + return { + "task_id": row["task_id"], + "file_path": row["file_path"], + "file_name": row["file_name"], + "expires_at": row["expires_at"], + "created_at": row["created_at"], + } + @staticmethod def _now_iso() -> str: return datetime.now(tz=timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py index 8f36d33..c0a8ac6 100644 --- a/webui/backend/app/dependencies.py +++ b/webui/backend/app/dependencies.py @@ -1,6 +1,7 @@ from __future__ import annotations from functools import lru_cache +from pathlib import Path from backend.app.config import Settings, get_settings from backend.app.db.bookmark_repository import BookmarkRepository @@ -12,6 +13,7 @@ from backend.app.security.path_guard import PathGuard from backend.app.services.bookmark_service import BookmarkService from backend.app.services.browse_service import BrowseService from backend.app.services.copy_task_service import CopyTaskService +from backend.app.services.archive_download_task_service import ArchiveDownloadTaskService from backend.app.services.file_ops_service import FileOpsService from backend.app.services.history_service import HistoryService from backend.app.services.move_task_service import MoveTaskService @@ -64,6 +66,12 @@ def get_task_runner() -> TaskRunner: ) +@lru_cache(maxsize=1) +def get_archive_artifact_root() -> str: + settings: Settings = get_settings() + return str(Path(settings.task_db_path).resolve().parent / "archive_tmp") + + async def get_browse_service() -> BrowseService: return BrowseService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter()) @@ -76,6 +84,21 @@ async def get_file_ops_service() -> FileOpsService: ) +async def get_archive_download_task_service() -> ArchiveDownloadTaskService: + return ArchiveDownloadTaskService( + path_guard=get_path_guard(), + repository=get_task_repository(), + runner=get_task_runner(), + history_repository=get_history_repository(), + file_ops_service=FileOpsService( + path_guard=get_path_guard(), + filesystem=get_filesystem_adapter(), + history_repository=get_history_repository(), + ), + artifact_root=Path(get_archive_artifact_root()), + ) + + async def get_task_service() -> TaskService: return TaskService(repository=get_task_repository()) 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 new file mode 100644 index 0000000000000000000000000000000000000000..eb1ff9bb505c97489e488d0278fb31bd26c31290 GIT binary patch literal 13138 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 IQvQAUFQyPqEdT%j delta 3321 zcmZ`)3s98T75?wP5B9y_vVgpH0YP3as8NEv6oeRGM6R0>5XnYRSJwsaq9AJU(S#aP zl^mU^DL&F_TRU|+^4HqwB&}%eByB`?oJ8E(A+=3XCmCxJQ)4>Q={{#A|ZG=D!<1?Rf*CD^#;N;w1BD+Gc7Ho z8iAtdOgf8dA6e-5YBr5}w9@m{Q&jh8WuSAYUicX4Txt-=M2o0VATxcM7E{wBTML~> z&4RZkaA*-Qnj@<~F{`G~5?G+Km6g&MWaePy{KI0orj$;Z#MuRBo5aN-2YoslDTULz zDpCe_ba_4HbSheHkXa(vSJ1>sK1p;wO+tnx(*-nHpcGn3Qv^!o9#u4Tl81wHmDDkb za|%1>Brc80G|fd|P#2xv87ATu8p*aHO@G_RtT*PC|%caX`p3vrT?Q)vWv29$PPiL?h>*SnT zWu2jPhOR*+Nk=3q!52popfJU(3>UQ!_)&3Oq1LT(tKAwJRjl^QjrQ=-NRnGOhlDZJ z&8wFrPZ4rduYs%e@$kK@Ok#)E9SL^9al?qU-%uOlhWT-Jb^R-IWH?&vFdD)c+%h%7 zhDzT$dPU7zkF zWak8v<_yoRxt_H8knST>^bJ#X(3CwAp9&Ys9q|Q{^F{LTcB${1`}tCI!dH?`KV`%e z4doT-V6AAWsJ2LhwrSOB^O z*+|G)Af8XjIpvOLA0g+Jz#2axL(1)zorK(0UR?VRnZH`q-kIbR==6RF|9M`oeV>=L zS`U4GyIJKXHfhm3enHf3H3Sc4tNogK3Zs69N>!(a!8lv2CUQ5N(y~%XGD=R#4ei_Q z{DvFioKmAXa*HBU&mo7!JP(CMa*&bU9+s^(gp2{Nf1R(j zd9ByidWMLr#J_IA=YeXHtjy}L5EHsEo?l{(1eR`@n%ck%&QS~7@u_47xkV0Pqp)|& zBH~ou-%>;}$6W8Af47|P>{jJF^SU+8O>L}|t-|d$L3U>a$yU~PzDvk3%-r?{nWg+; z809k9b++v4Crk5Svp-_^j+VTZR!xwv;!w(r6~%Q5Y4 zM4c!C@T*syWE*_&YHrqEByjs|2cqp<EP$z9N_oC z+r?{#9FTthgA6SEDVP*CtWkyr)_ zQHvkZY!vBSo!rlEbGpbY+n%>RX*-JD969XwQN5M zaYMD?vR1C~7@=xXN;eV^h1Nis{bNP;wkLK}lT6DCS{1WyY z-A=w%{(ICUlL0XHJGjj2&)~AQKT-P*W*UIDad@!bo-%+ezFd56Zt<^g^*(Q)qcPCI znz->`zteb!vrHV^0DRt`S0_Av&)v9(xO-t8#u2QRSrH3%(YSAfi+Jf!C1PC!?=i>I z7tvq`k^+gy0%f5>py^}ig)zGUxOgnH=l?p31cTf!M-C^$vLg6Vxj2&{HEx>?VO&x9 z#9fM^y@bgHD?)_3#eOGP=sxbUiyE$t7bc2@caA&4)vZE}5yfLRqB?c>IUY|kv1Wm_ z;R{=c5yS&B4bk}ibQAPqmPCW`@u{4!qbEY4!Q*=M6A7UAL}t}^E;U=p-MX#(oyO*+?Z z69vwF_+i o5o0fUm7)DoW#6f4LN3F#)9K`z^5yAFVy(53ye}n`YzDXaAH7^WCIA2c diff --git a/webui/backend/app/services/archive_download_task_service.py b/webui/backend/app/services/archive_download_task_service.py new file mode 100644 index 0000000..bef67b0 --- /dev/null +++ b/webui/backend/app/services/archive_download_task_service.py @@ -0,0 +1,266 @@ +from __future__ import annotations + +import os +import uuid +import zipfile +from datetime import datetime, timedelta, timezone +from pathlib import Path + +from backend.app.api.errors import AppError +from backend.app.api.schemas import TaskCreateResponse +from backend.app.db.history_repository import HistoryRepository +from backend.app.db.task_repository import TaskRepository +from backend.app.security.path_guard import PathGuard +from backend.app.services.file_ops_service import FileOpsService +from backend.app.tasks_runner import TaskRunner + +ARCHIVE_DOWNLOAD_TTL_SECONDS = 30 * 60 + + +class ArchiveDownloadTaskService: + def __init__( + self, + path_guard: PathGuard, + repository: TaskRepository, + runner: TaskRunner, + history_repository: HistoryRepository | None, + file_ops_service: FileOpsService, + artifact_root: Path, + artifact_ttl_seconds: int = ARCHIVE_DOWNLOAD_TTL_SECONDS, + ): + self._path_guard = path_guard + self._repository = repository + self._runner = runner + self._history_repository = history_repository + self._file_ops_service = file_ops_service + self._artifact_root = artifact_root + self._artifact_ttl_seconds = artifact_ttl_seconds + self._artifact_root.mkdir(parents=True, exist_ok=True) + self.sweep_artifacts() + + def create_archive_prepare_task(self, paths: list[str]) -> TaskCreateResponse: + if not paths: + raise AppError( + code="invalid_request", + message="At least one path is required", + status_code=400, + ) + + self.sweep_artifacts() + resolved_targets = [self._path_guard.resolve_existing_path(path) for path in paths] + mode = self._file_ops_service._download_mode_from_resolved_targets(resolved_targets) + if mode == "single_file": + raise AppError( + code="invalid_request", + message="Single file downloads must use direct download", + status_code=400, + ) + + summary = self._file_ops_service._summarize_download_targets([target.relative for target in resolved_targets]) + archive_name = self._file_ops_service._download_name_for_targets(resolved_targets) + task_id = str(uuid.uuid4()) + task = self._repository.create_task( + operation="download", + source=summary, + destination=archive_name, + task_id=task_id, + status="requested", + ) + self._record_history( + entry_id=task_id, + operation="download", + status="requested", + source=mode, + destination=archive_name, + path=summary, + ) + target_paths = [target.relative for target in resolved_targets] + self._runner.enqueue_archive_prepare( + lambda: self._run_archive_prepare_task( + task_id=task_id, + target_paths=target_paths, + archive_name=archive_name, + history_mode=mode, + history_path=summary, + ) + ) + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + + def prepare_ready_archive_download(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_ready", + message="Archive download is not ready", + status_code=409, + details={"task_id": task_id, "status": task["status"]}, + ) + + artifact = self._repository.get_artifact(task_id) + if not artifact: + raise AppError( + code="archive_not_found", + message="Prepared archive was not found", + status_code=404, + details={"task_id": task_id}, + ) + if self._is_expired(artifact["expires_at"]): + self._delete_artifact_record_and_file(task_id, artifact["file_path"]) + raise AppError( + code="archive_expired", + message="Prepared archive expired", + status_code=410, + details={"task_id": task_id}, + ) + + artifact_path = Path(artifact["file_path"]) + if not artifact_path.exists(): + self._repository.delete_artifact(task_id) + raise AppError( + code="archive_not_found", + message="Prepared archive was not found", + status_code=404, + details={"task_id": task_id}, + ) + + return { + "content": self._file_ops_service._filesystem.stream_file(artifact_path), + "headers": { + "Content-Disposition": f'attachment; filename="{artifact["file_name"]}"', + "Content-Length": str(int(artifact_path.stat().st_size)), + }, + "content_type": "application/zip", + } + + def sweep_artifacts(self) -> None: + self._artifact_root.mkdir(parents=True, exist_ok=True) + referenced_paths: set[Path] = set() + for artifact in self._repository.list_artifacts(): + artifact_path = Path(artifact["file_path"]) + referenced_paths.add(artifact_path) + if self._is_expired(artifact["expires_at"]) or not artifact_path.exists(): + self._delete_artifact_record_and_file(artifact["task_id"], artifact["file_path"]) + + for candidate in self._artifact_root.iterdir(): + if candidate.is_file() and candidate not in referenced_paths: + try: + candidate.unlink() + except FileNotFoundError: + pass + + def _run_archive_prepare_task( + self, + *, + task_id: str, + target_paths: list[str], + archive_name: str, + history_mode: str, + history_path: str, + ) -> None: + partial_path = self._artifact_root / f"{task_id}.partial.zip" + final_path = self._artifact_root / f"{task_id}.zip" + total_items = len(target_paths) + + try: + 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, + ) + resolved_targets = [self._path_guard.resolve_existing_path(path) for path in target_paths] + self._file_ops_service._validate_zip_download_archive_names(resolved_targets) + self._file_ops_service._run_zip_download_preflight(resolved_targets) + + 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) + + os.replace(partial_path, final_path) + 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( + task_id=task_id, + done_items=total_items, + total_items=total_items, + ) + self._update_history_ready(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( + task_id=task_id, + error_code=exc.code, + error_message=exc.message, + failed_item=history_path, + done_bytes=None, + total_bytes=None, + done_items=0, + total_items=total_items, + ) + 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( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=history_path, + done_bytes=None, + total_bytes=None, + done_items=0, + total_items=total_items, + ) + self._update_history_failed(task_id, "io_error", str(exc)) + + def _delete_artifact_record_and_file(self, task_id: str, file_path: str) -> None: + self._repository.delete_artifact(task_id) + path = Path(file_path) + try: + path.unlink() + except FileNotFoundError: + pass + + def _update_history_ready(self, task_id: str) -> None: + if self._history_repository: + self._history_repository.update_entry(entry_id=task_id, status="ready") + + def _update_history_failed(self, task_id: str, error_code: str, error_message: str) -> None: + if self._history_repository: + self._history_repository.update_entry( + entry_id=task_id, + status="failed", + error_code=error_code, + error_message=error_message, + ) + + def _record_history(self, **kwargs) -> None: + if self._history_repository: + self._history_repository.create_entry(**kwargs) + + def _expires_at_iso(self) -> str: + return (datetime.now(timezone.utc) + timedelta(seconds=self._artifact_ttl_seconds)).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + @staticmethod + def _is_expired(expires_at: str) -> bool: + return datetime.now(timezone.utc) >= datetime.fromisoformat(expires_at.replace("Z", "+00:00")) diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index bccfe9c..d999a48 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -411,6 +411,14 @@ class FileOpsService: history_mode = self._download_mode_from_resolved_targets(resolved_targets) history_path = self._summarize_download_targets([target.relative for target in resolved_targets]) history_download_name = self._download_name_for_targets(resolved_targets) + + if history_mode != "single_file": + raise AppError( + code="invalid_request", + message="Archive downloads must be prepared first", + status_code=400, + ) + history_entry_id = self._record_download_status( status="requested", mode=history_mode, @@ -418,10 +426,7 @@ class FileOpsService: download_name=history_download_name, ) - if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_file(): - prepared = self._prepare_single_file_download(resolved_targets[0]) - else: - prepared = self._prepare_zip_download(resolved_targets, history_download_name) + prepared = self._prepare_single_file_download(resolved_targets[0]) self._record_download_status( status="ready", @@ -757,16 +762,7 @@ class FileOpsService: } def _prepare_zip_download(self, resolved_targets: list, download_name: str) -> dict: - archive_names: set[str] = set() - for resolved_target in resolved_targets: - archive_name = resolved_target.absolute.name - if archive_name in archive_names: - raise AppError( - code="invalid_request", - message="Selected items must have distinct top-level names", - status_code=400, - ) - archive_names.add(archive_name) + self._validate_zip_download_archive_names(resolved_targets) self._run_zip_download_preflight(resolved_targets) buffer = BytesIO() @@ -786,6 +782,18 @@ class FileOpsService: "content_type": "application/zip", } + def _validate_zip_download_archive_names(self, resolved_targets: list) -> None: + archive_names: set[str] = set() + for resolved_target in resolved_targets: + archive_name = resolved_target.absolute.name + if archive_name in archive_names: + raise AppError( + code="invalid_request", + message="Selected items must have distinct top-level names", + status_code=400, + ) + archive_names.add(archive_name) + def _download_name_for_targets(self, resolved_targets: list) -> str: if len(resolved_targets) == 1 and resolved_targets[0].absolute.is_file(): return resolved_targets[0].absolute.name diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index f269d48..e536593 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -69,6 +69,13 @@ class TaskRunner: ) thread.start() + def enqueue_archive_prepare(self, worker) -> None: + thread = threading.Thread( + target=worker, + daemon=True, + ) + thread.start() + def _run_copy_file(self, task_id: str, source: str, destination: str, total_bytes: int) -> None: self._repository.mark_running( task_id=task_id, 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 d6c6b74d2a013439a01bda7c43bd56e7989b9f70..e6d8e5eb063adbb502605f82e8c01de81d841cae 100644 GIT binary patch literal 20454 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 literal 18744 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 Wvku>vj(_g>7f!%Vo}-@jhW`hUfA~ZI diff --git a/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_history_golden.cpython-313.pyc index 2edd15101bae1a910133f71517f88c3381a26b75..377e497a3b4fc0e77ac447f2df0fdae68a436afd 100644 GIT binary patch delta 2363 zcmbVOdu)?c6z}b8T}#((-6o^!9*mB4W9@)VUKQGn$9QZRG$;XM)2ts|S=+C>Uk9wo z93~oKq6i+18;C>_$k1riiVs9^AyJG8Db7doMgQ|3Vn}8{iHj!QbH8pK%RuAz$FKLE zd+xdCp5Hm=_Knxc`7@;8j@fKfu`e%hF%n(ze!=55lZH)Y9TkG1!E~=}8PWPCEWY$e zMLQu!(?8kU)Z_?MRcA~uFg^Mr!ZDbtZYVsC!xIR72rs8wYDzWTT;Tu;xc)I5sS!pH zJP1PwM-WCCq%2ZWq`|PT+#MGVg(8%`#H1HAv;sAbrbm|ysL467EcK94*s*k>Zyvj{ zo(l0`Oi)Y@+}(=}5iuZ8DpF@YU4aUhVccyk zo#zVl^YktEb&X!}6Fy$K%FAh-FVcpPcqkqfgCXU-r_r#Tfozf@@o-cK;JxT5G`CbX zA_w#o1E)Ug0SZzGIWKtfjHL%#t`MW1EwOYYcb){_qoYQ@!JqFY&7@TWCA_>oyDN_lt7}DESai&^zf1>RK8JU= z+ONzX;nIbzn4s*dR)tm0@-A5$5__bSE{Gh+#5ae(sahCcX&rV4X-{wDpwN_{LLx{7 zS%=9cO_^uX8Lj*m_P%CE&y8kDHkzy+gdSEOgMm~e!Rv)qGW97#3tD$Eq0^wmmY3FL zG&Lu{Gnq=qpmEK4btQp`HEq_ijI%4Vb5Ca9fy~~%Om}p`8iW1~<#4UJ3|iK{3s)Wa zWQ5Vs3W+8&xYnIm;5YhB{snGzmXKD9ITyIys$H|`DNwf(_;X#A(c&-6DnXH}6p156 z;PX_GV(4t+)UeUe%;+6uWGm*xlyh8k-zh~b39Q3vzoKE|8WlMWMxebbWyEe46BPb- z<@l5JiC!@&N~yd^EUc_{^O~d?4BSlyDeDY>IU}=r6gRNxfAg9j8;B+&3YqZhW8JH# zYZK4s6h4SO*%Xu{fhKneVc{^NGQR$`yzkg9sguBXM;R>M(BYWjG%?8*$VN5$*+(|v z&rY&geMhA#*`l7satMw&Y+&#<=PK_nMtKcP);k#GdqLAs0)KcNP+g_fVkN?jrd7lQ ze|r`~y~_?>j}Fc@)o9HA0*JdDtm;ntsz@Q&+uRI0IS#eC&4rvMrP_?}g{w9zS*XxF zOR|VdY$zF4c2#hR{XU8-yHW)5q=pPWIq!5w|Szp^9jf8vI;~8W2i_JPOxm#JF+e7YF7wMiL_v+x*w&mm*ux+m)qv^-C huT&Eq^mo2e{*;F7CL^nVv@N@7TlTw(PiZZ)^bZ)FZTJ8H delta 2048 zcma)-draF^6vw%JQ7EN^@?05EUR%qf0xK}afVN``j1U-^Ted8r`?=?yUY{aYUM0!D8jXo@?Dy#4ZE>{nX7bY(jfzcW$x=o%bql6Cc`C<4$hiDy zvs*<*p(KCK@Dg*NFQc3RVzsB7!{K?90h9~!4r{u)lRG?%2JU|bN0^#kLTNxbi86{Z z!6ae|g(d3i6Dl1+;kaL<^enU9P}3ar7?-aW4XemC$k@_w2;HN4=H(6g1_kjVVGfU5 zDypLbdMT@&!d#OWo;>6W_j`j<*xM@&2mO38IxWAoWsjPzvSjbHKaakTO{?jVhEA*; z0fMC^hII_5VX8D;H;&+!wuqE}ZQ8S?l$p zG83CFW;g%Z17ChxKDBLzz~RaSRf-Cl%JZSGW<=#w!4EY~qK9&OzNTB(od93kib*28 zU7KezXj$IwB(6s0_?Ig;b-$`xF(-4gNil2C3|4zKB)ZMYq$_q}aagQTjbG{sMYKM= zf+%stTpt$3atX{<7ue88c@P_Lp|f~eej)dva(iAY(pWaG)jAJGEXxdFPpBdy4%y-r zEc}u1o7#{KoZEg~_a}=Si-VoJ3*bn7PJH^@*3JcU7d+FP4M&_kkhi0a(`#F$mj))A z5ze{}fvKTZA?bm7yIIq%XEYc`xa!uz&034e$YK2lnK2fb5n_-Tp}Z~|g6{OLZY4H@ zXUKHovuD5jxV|`BR!1ZeD`QYlknUSY8r2Q;OJOM#i5Ca^l&_uq{Y6|1bJ2m-pb~t! zJJ$dzLH~>Ch|8hoLUK*RflK4FATLsQcw z-2%%N%7TYYUPM2zU=FOH_rs56X`px2aQyEg{uYKE5P7NFsZQWUXk}Hq*icLi@UuIQ zB*FQrY}o3_V53jgMvH5sCXa)CkvtBgQs0@JZx&cQg`Aqtzp?1jJ&B4ahbenF@;d-EV z!*j)Hth}9a*PhqO=sjHGQ`Y3 z8jaeJ?-(=S|5P+_wZ{p5+v*{hof=}w1zv2;Cs-YsaJO}gEXkfWJ2CL4U4p(Zz{uX_ zy3;s(1?4rA8z}Ff%%I#w`5ff|%0razQGSBOy;5qDwwWyD7_|qGfW*=6Q{bZuHl@&)Q_#?u;Kdll zgE%qPqk7?@32r=k@?t!QjVPOX;-K+j0)d3YlQTnTjB&F0_WS>T9{ zakR!^#aCp9NF7d00)H#Ijl4>gbKh2D%U7BEcq8vuumk5-2ZwMhZv zmv7zzQfM}K9q4GeIrzGTw=`+EYj_QLq?bT9-bza>$f06HZp!0)Oh6mHD0&q+)1;GW zC1X2MWr2nsM#FSk2)&Em*{vm&BLIzidE^T(0~W1?V`#IrJ8c_a@mf3-Cd0(M$%=nk zD^K<6#hZm(QPX>2TGMCi+-_XA(L}{no-RTylkSh}#Z#1^IC0l}Hcq^DA7$+!T zH2M@qheRi^ysM0knJJV$Vz-~z$US7Iw$Fcd|B0sO8XKx|8IGV?DYcUHe+(mRZgmLk{O7ZEsE)vO@F&7wws||@`!zA zNHj%hJ;?C#2IJs`b(sl>PX-qTKkXU*>`$OMIeuuWCUq}fT;Yz=-NYIvRs+?Er>%>j z6|Vp9T)rmtEKYvm28~kNGr$S}`S)&S?&D72ag5(WwlJ;SQj*0&8V-@{JORBuYUB!c zawmw=FF&B`pBpZzf!FR})do62ca`XsS%pja$ a!E^#S6+Fe@UC6u*nV*Vi? None: + super()._run_zip_download_preflight(resolved_targets) + self._gate.wait(timeout=2.0) + + +class FailingArchiveFileOpsService(FileOpsService): + def _write_download_target_to_zip(self, archive: zipfile.ZipFile, resolved_target) -> None: + archive.writestr("partial.txt", b"partial") + raise OSError("forced archive failure") class DownloadApiGoldenTest(unittest.TestCase): @@ -24,56 +47,122 @@ class DownloadApiGoldenTest(unittest.TestCase): self.temp_dir = tempfile.TemporaryDirectory() self.root = Path(self.temp_dir.name) / "root" self.root.mkdir(parents=True, exist_ok=True) + self.db_path = str(Path(self.temp_dir.name) / "tasks.db") + self.artifact_root = Path(self.temp_dir.name) / "archive_tmp" self.path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)}) self.filesystem = FilesystemAdapter() - self._override_service() + self.task_repo = TaskRepository(self.db_path) + self.history_repo = HistoryRepository(self.db_path) + self._override_services() def tearDown(self) -> None: app.dependency_overrides.clear() self.temp_dir.cleanup() - def _get(self, url: str) -> httpx.Response: + def _override_services( + self, + *, + file_ops_service: FileOpsService | None = None, + artifact_ttl_seconds: int = 1800, + ) -> None: + file_ops_service = file_ops_service or FileOpsService( + path_guard=self.path_guard, + filesystem=self.filesystem, + history_repository=self.history_repo, + zip_download_preflight_limits=ZipDownloadPreflightLimits(), + ) + runner = TaskRunner(repository=self.task_repo, filesystem=self.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, + artifact_ttl_seconds=artifact_ttl_seconds, + ) + task_service = TaskService(repository=self.task_repo) + + async def _override_file_ops_service() -> FileOpsService: + return file_ops_service + + async def _override_archive_service() -> ArchiveDownloadTaskService: + return archive_service + + async def _override_task_service() -> TaskService: + return task_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_task_service] = _override_task_service + + def _request(self, method: str, url: str, payload: dict | None = None) -> httpx.Response: async def _run() -> httpx.Response: transport = httpx.ASGITransport(app=app) async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: - return await client.get(url) + if method == "GET": + return await client.get(url) + return await client.post(url, json=payload) return asyncio.run(_run()) - def _override_service( - self, - *, - limits: ZipDownloadPreflightLimits | None = None, - monotonic=None, - ) -> None: - service = FileOpsService( - path_guard=self.path_guard, - filesystem=self.filesystem, - zip_download_preflight_limits=limits or ZipDownloadPreflightLimits(), - monotonic=monotonic, - ) - - async def _override_file_ops_service() -> FileOpsService: - return service - - app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + def _wait_for_task_status(self, task_id: str, statuses: set[str], timeout_s: float = 2.0) -> dict: + deadline = time.time() + timeout_s + while time.time() < deadline: + response = self._request("GET", f"/api/tasks/{task_id}") + body = response.json() + if body["status"] in statuses: + return body + time.sleep(0.02) + self.fail("task did not reach expected status in time") def test_download_success_for_allowed_file(self) -> None: src = self.root / "report.txt" src.write_text("hello download", encoding="utf-8") - response = self._get("/api/files/download?path=storage1/report.txt") + response = self._request("GET", "/api/files/download?path=storage1/report.txt") self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"hello download") self.assertIn('attachment; filename="report.txt"', response.headers.get("content-disposition", "")) self.assertEqual(response.headers.get("content-type"), "text/plain; charset=utf-8") - def test_download_single_directory_as_zip(self) -> None: + def test_archive_prepare_single_directory_ends_ready(self) -> None: (self.root / "docs").mkdir() (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") - response = self._get("/api/files/download?path=storage1/docs") + created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]}) + + self.assertEqual(created.status_code, 202) + task = self._wait_for_task_status(created.json()["task_id"], {"ready"}) + self.assertEqual(task["operation"], "download") + self.assertEqual(task["status"], "ready") + self.assertEqual(task["destination"], "docs.zip") + + def test_archive_prepare_multi_mixed_selection_ends_ready(self) -> None: + (self.root / "readme.txt").write_text("R", encoding="utf-8") + (self.root / "photos").mkdir() + (self.root / "photos" / "img.txt").write_text("P", encoding="utf-8") + + created = self._request( + "POST", + "/api/files/download/archive-prepare", + {"paths": ["storage1/readme.txt", "storage1/photos"]}, + ) + + self.assertEqual(created.status_code, 202) + task = self._wait_for_task_status(created.json()["task_id"], {"ready"}) + self.assertEqual(task["status"], "ready") + self.assertEqual(task["source"], "storage1/readme.txt, storage1/photos") + self.assertRegex(task["destination"], r'^kodidownload-\d{8}-\d{6}\.zip$') + + def test_archive_retrieval_from_ready_task_works(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("GET", f"/api/files/download/archive/{task['id']}") self.assertEqual(response.status_code, 200) self.assertIn('attachment; filename="docs.zip"', response.headers.get("content-disposition", "")) @@ -82,167 +171,93 @@ class DownloadApiGoldenTest(unittest.TestCase): self.assertIn("docs/a.txt", archive.namelist()) self.assertEqual(archive.read("docs/a.txt"), b"a") - def test_download_multi_file_selection_as_zip(self) -> None: - (self.root / "a.txt").write_text("A", encoding="utf-8") - (self.root / "b.txt").write_text("B", encoding="utf-8") - - response = self._get("/api/files/download?path=storage1/a.txt&path=storage1/b.txt") - - self.assertEqual(response.status_code, 200) - self.assertRegex( - response.headers.get("content-disposition", ""), - r'attachment; filename="kodidownload-\d{8}-\d{6}\.zip"', + def test_archive_retrieval_before_ready_rejected(self) -> None: + gate = threading.Event() + file_ops_service = BlockingArchiveFileOpsService( + path_guard=self.path_guard, + filesystem=self.filesystem, + history_repository=self.history_repo, + zip_download_preflight_limits=ZipDownloadPreflightLimits(), + gate=gate, ) - with zipfile.ZipFile(BytesIO(response.content)) as archive: - self.assertIn("a.txt", archive.namelist()) - self.assertIn("b.txt", archive.namelist()) - self.assertEqual(archive.read("a.txt"), b"A") - self.assertEqual(archive.read("b.txt"), b"B") - - def test_download_multi_directory_selection_as_zip(self) -> None: - (self.root / "dir1" / "sub").mkdir(parents=True) - (self.root / "dir2").mkdir() - (self.root / "dir1" / "sub" / "a.txt").write_text("A", encoding="utf-8") - (self.root / "dir2" / "b.txt").write_text("B", encoding="utf-8") - - response = self._get("/api/files/download?path=storage1/dir1&path=storage1/dir2") - - self.assertEqual(response.status_code, 200) - self.assertRegex( - response.headers.get("content-disposition", ""), - r'attachment; filename="kodidownload-\d{8}-\d{6}\.zip"', - ) - with zipfile.ZipFile(BytesIO(response.content)) as archive: - self.assertIn("dir1/", archive.namelist()) - self.assertIn("dir1/sub/", archive.namelist()) - self.assertIn("dir1/sub/a.txt", archive.namelist()) - self.assertIn("dir2/b.txt", archive.namelist()) - - def test_download_mixed_file_and_directory_selection_as_zip(self) -> None: - (self.root / "readme.txt").write_text("R", encoding="utf-8") - (self.root / "photos" / "nested").mkdir(parents=True) - (self.root / "photos" / "nested" / "img.txt").write_text("P", encoding="utf-8") - - response = self._get("/api/files/download?path=storage1/readme.txt&path=storage1/photos") - - self.assertEqual(response.status_code, 200) - self.assertRegex( - response.headers.get("content-disposition", ""), - r'attachment; filename="kodidownload-\d{8}-\d{6}\.zip"', - ) - with zipfile.ZipFile(BytesIO(response.content)) as archive: - self.assertIn("readme.txt", archive.namelist()) - self.assertIn("photos/", archive.namelist()) - self.assertIn("photos/nested/img.txt", archive.namelist()) - - def test_download_zip_rejected_when_max_items_exceeded(self) -> None: + self._override_services(file_ops_service=file_ops_service) (self.root / "docs").mkdir() - (self.root / "docs" / "a.txt").write_text("A", encoding="utf-8") - (self.root / "docs" / "b.txt").write_text("B", encoding="utf-8") - (self.root / "docs" / "c.txt").write_text("C", encoding="utf-8") - self._override_service( - limits=ZipDownloadPreflightLimits( - max_items=3, - max_total_input_bytes=1024, - max_individual_file_bytes=1024, - scan_timeout_seconds=10.0, - ) - ) + (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"], {"requested", "preparing"}) - response = self._get("/api/files/download?path=storage1/docs") + response = self._request("GET", f"/api/files/download/archive/{task['id']}") + gate.set() self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") - self.assertEqual(response.json()["error"]["message"], "Zip download preflight failed") - self.assertEqual(response.json()["error"]["details"]["reason"], "max_items_exceeded") + self.assertEqual(response.json()["error"]["code"], "download_not_ready") - def test_download_zip_rejected_when_max_total_input_bytes_exceeded(self) -> None: - (self.root / "a.txt").write_text("AAAA", encoding="utf-8") - (self.root / "b.txt").write_text("BBBB", encoding="utf-8") - self._override_service( - limits=ZipDownloadPreflightLimits( - max_items=10, - max_total_input_bytes=7, - max_individual_file_bytes=1024, - scan_timeout_seconds=10.0, - ) - ) - - response = self._get("/api/files/download?path=storage1/a.txt&path=storage1/b.txt") - - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") - self.assertEqual(response.json()["error"]["details"]["reason"], "max_total_input_bytes_exceeded") - - def test_download_zip_rejected_when_individual_file_too_large(self) -> None: - (self.root / "docs").mkdir() - (self.root / "docs" / "large.bin").write_bytes(b"123456") - self._override_service( - limits=ZipDownloadPreflightLimits( - max_items=10, - max_total_input_bytes=1024, - max_individual_file_bytes=5, - scan_timeout_seconds=10.0, - ) - ) - - response = self._get("/api/files/download?path=storage1/docs") - - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") - self.assertEqual(response.json()["error"]["details"]["reason"], "max_individual_file_size_exceeded") - self.assertEqual(response.json()["error"]["details"]["path"], "storage1/docs/large.bin") - - def test_download_directory_with_symlink_rejected(self) -> None: + def test_archive_preflight_failure_sets_failed_and_error_code(self) -> None: target = self.root / "real.txt" target.write_text("x", encoding="utf-8") (self.root / "docs").mkdir() (self.root / "docs" / "link.txt").symlink_to(target) - response = self._get("/api/files/download?path=storage1/docs") + created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]}) + task = self._wait_for_task_status(created.json()["task_id"], {"failed"}) - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") - self.assertEqual(response.json()["error"]["details"]["reason"], "symlink_detected") - self.assertEqual(response.json()["error"]["details"]["path"], "storage1/docs/link.txt") + self.assertEqual(task["status"], "failed") + self.assertEqual(task["error_code"], "download_preflight_failed") - def test_download_zip_preflight_timeout_rejected_cleanly(self) -> None: - (self.root / "a.txt").write_text("A", encoding="utf-8") - (self.root / "b.txt").write_text("B", encoding="utf-8") - ticks = iter([0.0, 11.0, 11.0, 11.0]) - self._override_service( - limits=ZipDownloadPreflightLimits( - max_items=10, - max_total_input_bytes=1024, - max_individual_file_bytes=1024, - scan_timeout_seconds=10.0, - ), - monotonic=lambda: next(ticks), + def test_archive_failure_removes_partial_artifact(self) -> None: + file_ops_service = FailingArchiveFileOpsService( + path_guard=self.path_guard, + filesystem=self.filesystem, + history_repository=self.history_repo, + zip_download_preflight_limits=ZipDownloadPreflightLimits(), + ) + 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"]}) + task = self._wait_for_task_status(created.json()["task_id"], {"failed"}) + + self.assertEqual(task["error_code"], "io_error") + self.assertEqual(list(self.artifact_root.glob("*")), []) + + def test_expired_artifact_rejected_and_removed(self) -> None: + (self.root / "docs").mkdir() + (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") + self._override_services(artifact_ttl_seconds=1) + created = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/docs"]}) + task = self._wait_for_task_status(created.json()["task_id"], {"ready"}) + artifact = self.task_repo.get_artifact(task["id"]) + self.task_repo.upsert_artifact( + task_id=task["id"], + file_path=artifact["file_path"], + file_name=artifact["file_name"], + expires_at="2000-01-01T00:00:00Z", ) - response = self._get("/api/files/download?path=storage1/a.txt&path=storage1/b.txt") + response = self._request("GET", f"/api/files/download/archive/{task['id']}") - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"]["code"], "download_preflight_failed") - self.assertEqual(response.json()["error"]["details"]["reason"], "preflight_timeout") + self.assertEqual(response.status_code, 410) + self.assertEqual(response.json()["error"]["code"], "archive_expired") + self.assertIsNone(self.task_repo.get_artifact(task["id"])) + self.assertFalse(Path(artifact["file_path"]).exists()) - def test_download_path_not_found(self) -> None: - response = self._get("/api/files/download?path=storage1/missing.txt") + def test_archive_prepare_rejects_single_file(self) -> None: + (self.root / "report.txt").write_text("hello download", encoding="utf-8") - self.assertEqual(response.status_code, 404) - self.assertEqual(response.json()["error"]["code"], "path_not_found") + response = self._request("POST", "/api/files/download/archive-prepare", {"paths": ["storage1/report.txt"]}) - def test_download_invalid_root_alias(self) -> None: - response = self._get("/api/files/download?path=unknown/file.txt") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + def test_direct_archive_download_route_rejected(self) -> None: + (self.root / "docs").mkdir() + (self.root / "docs" / "a.txt").write_text("a", encoding="utf-8") - def test_download_traversal_blocked(self) -> None: - response = self._get("/api/files/download?path=storage1/../etc/passwd") + response = self._request("GET", "/api/files/download?path=storage1/docs") - self.assertEqual(response.status_code, 403) - self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") if __name__ == "__main__": diff --git a/webui/backend/tests/golden/test_api_history_golden.py b/webui/backend/tests/golden/test_api_history_golden.py index 32f0d73..7653069 100644 --- a/webui/backend/tests/golden/test_api_history_golden.py +++ b/webui/backend/tests/golden/test_api_history_golden.py @@ -91,7 +91,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'}: + if body['status'] in {'completed', 'failed', 'ready'}: return body time.sleep(0.02) self.fail('task did not reach terminal state in time') @@ -198,9 +198,10 @@ class HistoryApiGoldenTest(unittest.TestCase): (self.root1 / 'docs').mkdir() (self.root1 / 'docs' / 'a.txt').write_text('A', encoding='utf-8') - response = self._request('GET', '/api/files/download?path=storage1/docs') + response = self._request('POST', '/api/files/download/archive-prepare', {'paths': ['storage1/docs']}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 202) + self._wait_task(response.json()['task_id']) history = self._request('GET', '/api/history').json()['items'] self.assertEqual(history[0]['operation'], 'download') self.assertEqual(history[0]['status'], 'ready') @@ -213,9 +214,10 @@ class HistoryApiGoldenTest(unittest.TestCase): (self.root1 / 'photos').mkdir() (self.root1 / 'photos' / 'img.txt').write_text('P', encoding='utf-8') - response = self._request('GET', '/api/files/download?path=storage1/readme.txt&path=storage1/photos') + response = self._request('POST', '/api/files/download/archive-prepare', {'paths': ['storage1/readme.txt', 'storage1/photos']}) - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 202) + self._wait_task(response.json()['task_id']) history = self._request('GET', '/api/history').json()['items'] self.assertEqual(history[0]['operation'], 'download') self.assertEqual(history[0]['status'], 'ready') @@ -229,12 +231,13 @@ class HistoryApiGoldenTest(unittest.TestCase): (self.root1 / 'docs').mkdir() (self.root1 / 'docs' / 'link.txt').symlink_to(target) - response = self._request('GET', '/api/files/download?path=storage1/docs') + response = self._request('POST', '/api/files/download/archive-prepare', {'paths': ['storage1/docs']}) - self.assertEqual(response.status_code, 409) + self.assertEqual(response.status_code, 202) + self._wait_task(response.json()['task_id']) history = self._request('GET', '/api/history').json()['items'] self.assertEqual(history[0]['operation'], 'download') - self.assertEqual(history[0]['status'], 'preflight_failed') + self.assertEqual(history[0]['status'], 'failed') self.assertEqual(history[0]['source'], 'single_directory_zip') self.assertEqual(history[0]['path'], 'storage1/docs') self.assertEqual(history[0]['destination'], 'docs.zip') diff --git a/webui/backend/tests/golden/test_api_tasks_golden.py b/webui/backend/tests/golden/test_api_tasks_golden.py index a2cbd1b..88840e2 100644 --- a/webui/backend/tests/golden/test_api_tasks_golden.py +++ b/webui/backend/tests/golden/test_api_tasks_golden.py @@ -241,6 +241,28 @@ class TasksApiGoldenTest(unittest.TestCase): self.assertEqual(body["error_code"], "io_error") self.assertEqual(body["error_message"], "write failed") + def test_get_task_detail_ready_archive_download(self) -> None: + self._insert_task( + task_id="task-download-ready", + operation="download", + status="ready", + 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:05Z", + done_items=1, + total_items=1, + ) + + response = self._get("/api/tasks/task-download-ready") + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["operation"], "download") + self.assertEqual(body["status"], "ready") + 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 6682215..3084fec 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -233,6 +233,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function markZipDownloadFailed(err)', 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('function startArchiveDownload(taskId, fileName)', 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) self.assertIn('function closeContextMenu()', app_js) @@ -250,6 +254,9 @@ class UiSmokeGoldenTest(unittest.TestCase): 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('function applyContextMenuSelection()', app_js) self.assertIn('function startContextMenuOpen()', app_js) self.assertIn('function startContextMenuEdit()', app_js) @@ -284,7 +291,10 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js) self.assertIn('openCurrentDirectory();', app_js) self.assertIn('openEditor();', app_js) - self.assertIn('downloadFileRequest(selectedItems.map((item) => item.path));', app_js) + self.assertIn('const created = await createArchiveDownloadTask(selectedPaths);', app_js) + self.assertIn('const task = await waitForArchiveDownloadReady(created.task_id);', app_js) + self.assertIn('startArchiveDownload(task.id, task.destination);', app_js) + self.assertIn('const { blob, fileName } = await downloadFileRequest(selectedPaths);', app_js) self.assertIn('anchor.download = fileName || selected.name;', app_js) self.assertIn('openRenamePopup();', app_js) self.assertIn('startCopySelected();', 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 580570f2b3362a7f7618518e64fa432524480c44..0cb995bd7aa8b891252ca2b28f40f34695523bb6 100644 GIT binary patch delta 1096 zcmbVJOH9;I6n$^nPG1Z2kxrNaB4H363JeqoDxa`W5)%_Yp(2SfG|fzb3iIK$2+U?& z8CW4{6x5*BP zl99{}(pH9PupT1eh#DZ1?#cSdL^Q!wvy<^qO>uRc z&|==uu&Jon)Th;y>7W_StWvcuBK4w2;4W8!#)2!sRwdH7+s)A7utAfwg&Z7l>mw$) zw{qZi_)?y>vZR}{%F)XxhN+s1WaVu7c##{o$bREmK4&d+;o^U)jFLT!wca|EucleJi8pcs_ja`cG zq!{d8%Nh>@0nGgn!NkDA c(@}MqS@H%8PrGlEZ>#?YW(F3iB5|N00FDP!U;qFB diff --git a/webui/backend/tests/unit/test_task_repository.py b/webui/backend/tests/unit/test_task_repository.py index 22d443a..14d6e4e 100644 --- a/webui/backend/tests/unit/test_task_repository.py +++ b/webui/backend/tests/unit/test_task_repository.py @@ -59,6 +59,27 @@ class TaskRepositoryTest(unittest.TestCase): } ) + def test_create_download_task_with_requested_status_and_artifact(self) -> None: + created = self.repo.create_task( + operation="download", + source="storage1/docs", + destination="docs.zip", + status="requested", + ) + self.repo.upsert_artifact( + task_id=created["id"], + file_path="/tmp/archive.zip", + file_name="docs.zip", + expires_at="2026-03-10T10:30:00Z", + ) + + task = self.repo.get_task(created["id"]) + artifact = self.repo.get_artifact(created["id"]) + + self.assertEqual(task["operation"], "download") + self.assertEqual(task["status"], "requested") + self.assertEqual(artifact["file_name"], "docs.zip") + 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 804eab8..0960de0 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -460,6 +460,40 @@ function markZipDownloadFailed(err) { }); } +function updateZipDownloadTaskProgress(task) { + if (!downloadProgressState.active) { + return; + } + updateDownloadModalDisplay({ + active: true, + 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...", + percent: task.status === "ready" ? 100 : 55, + }); +} + +function sleep(ms) { + return new Promise((resolve) => window.setTimeout(resolve, ms)); +} + +async function waitForArchiveDownloadReady(taskId) { + while (true) { + const task = await getTaskRequest(taskId); + if (task.status === "ready") { + return task; + } + if (task.status === "failed") { + const err = new Error(task.error_message || "Archive download failed"); + err.code = task.error_code || null; + throw err; + } + updateZipDownloadTaskProgress(task); + await sleep(250); + } +} + function closeDownloadModal() { if (downloadProgressState.active) { return; @@ -643,15 +677,20 @@ async function startDownloadSelected() { } try { const selected = selectedItems[0]; + if (zipDownload) { + const created = await createArchiveDownloadTask(selectedPaths); + const task = await waitForArchiveDownloadReady(created.task_id); + startArchiveDownload(task.id, task.destination); + markZipDownloadReady(task.destination); + setStatus(`Download started: ${task.destination}`); + return; + } const { blob, fileName } = await downloadFileRequest(selectedPaths); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = fileName || selected.name; document.body.append(anchor); - if (zipDownload) { - markZipDownloadReady(anchor.download); - } anchor.click(); anchor.remove(); URL.revokeObjectURL(url); @@ -957,6 +996,23 @@ async function downloadFileRequest(paths) { }; } +async function createArchiveDownloadTask(paths) { + return apiRequest("POST", "/api/files/download/archive-prepare", { paths }); +} + +async function getTaskRequest(taskId) { + return apiRequest("GET", `/api/tasks/${encodeURIComponent(taskId)}`); +} + +function startArchiveDownload(taskId, fileName) { + const anchor = document.createElement("a"); + anchor.href = `/api/files/download/archive/${encodeURIComponent(taskId)}`; + anchor.download = fileName || ""; + document.body.append(anchor); + anchor.click(); + anchor.remove(); +} + async function uploadFileRequest(targetPath, file, overwrite = false) { const formData = new FormData(); formData.append("target_path", targetPath);