From 610a648fd15c54a357acf462d3d637e4eb88e4f4 Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 12:31:11 +0100 Subject: [PATCH] feat: download - fase 01 --- .../__pycache__/routes_files.cpython-313.pyc | Bin 5924 -> 6330 bytes webui/backend/app/api/routes_files.py | 13 +++ .../file_ops_service.cpython-313.pyc | Bin 26054 -> 27125 bytes .../backend/app/services/file_ops_service.py | 26 ++++++ webui/backend/data/tasks.db | Bin 188416 -> 200704 bytes .../test_api_download_golden.cpython-313.pyc | Bin 0 -> 6810 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 34765 -> 35565 bytes .../tests/golden/test_api_download_golden.py | 84 ++++++++++++++++++ .../tests/golden/test_ui_smoke_golden.py | 8 ++ webui/html/app.js | 50 +++++++++++ webui/html/index.html | 1 + 11 files changed, 182 insertions(+) create mode 100644 webui/backend/tests/golden/__pycache__/test_api_download_golden.cpython-313.pyc create mode 100644 webui/backend/tests/golden/test_api_download_golden.py 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 6953a9ad0004994437e173795ac327dc8b1208a0..c80d22240ade0cef0878e3fe599b977a97a34041 100644 GIT binary patch delta 924 zcmah{Pe>GD6rXQ)*7oo0>gw(qvCi7+HkyW#p(dLK=Hj}Mtky-^Mt7_v z&?%4?)S;rFLk3-1x^xLTbf^d=C|Ejl?G^}~I`rPGhIwh=`^|g5_j}*_-uu3%-LHG( zZLilQ;I}sVDu36%E{ECtdi)b>TmO|}?QoiBlU zp{~r~eR}muOzo~DrCW6#=doPrdU2to<>o|q;%J07o*8HLz+9O~V?xWg+VAH*V6 zS3%F^wXAugtgB+xU*>vQ<9c>Col}<^HXfq`DU{&Yk>m^;x{9Lks73$H7)FS!0i*NdRSCjBaZg*}=D7hJHXYkSWp^FcoS9KevW-c1pxl&HI zj>u0Ut=jk~{%XV!TJX)?%m(3?J7VWl2qK=KJ&8sd20YbP-j60W)Uex7Id?g0m|WKi zR!ckqS#QvuLe&f`dB^O0gf@BaHCR6+_h z?Pw09_m+Y~4J~f#XaP#-sGMn#S$d3Ay#yx+h6(7VI8C3^h^?VnGuRNe(HC~!)KH<6 oTU^i+d=@!|%XnLP5a<)7)MplkXt0|#!?|E%=id|NoqO@0rh&Z%&1NsH)#XtL^#F zoC)_rDH!V&_Fb4W-Dbl4ue8G*K*g-3!Tfb>owlHfg!LxZQiyYT! zjEe+ICj2=`Cc9XqomKoATEy$YAdZE*a0wrWv#@~g!>OSXsn>8XyaY=)9Jx>0vq*k? z;h$DV?OR)|8|_;2j<`Z%6Td~cM2o(JY5Ww;<=y+QktEVwED;VKEXusg*hYx$WY;;3 z5_GS~a2#N0j3i?nU&qcqbX^I`b>HBP71kyhR~Z^(;OA(VK>gflxXwem%KG1(+>?D= zddSIh)x9JCZ-uwxnSgt>INS98^LPkya} StreamingResponse: + prepared = service.prepare_download(path=path) + return StreamingResponse( + prepared["content"], + headers=prepared["headers"], + media_type=prepared["content_type"], + ) + + @router.get("/video") async def video( path: str, diff --git a/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/file_ops_service.cpython-313.pyc index 4ff0c43a39786cefcc48a5da7c95631329798e38..1e16a310fbe92d3f898dd220827315c8327d19eb 100644 GIT binary patch delta 1927 zcmah}du&rx9KPq?_HONETgM*lx~?nTpp?CMC^FgH*%g)9bHD-oK`Ul zVGv_{Bo5^l{xOn>|AFyADv9yICINha*t+1P2#f?{@Daw-C=xxty9b+5xi|UlcfRxe z9{t^Wdg3IxHAFIRWMmixem%SLaBy+(P-dUh_dXGXy@FR@gbGZggh{lB+Nop}x^>-z z>1Z+4A!mB(pn8qTbQX0|dEAl4S0yxUe3i~ur8IqfWuUXEL5nfcIn<~zg}SJsu^GHl z*<3nf+?>HJ8QMlBXPFwy;w(#JCT})8s>|uirt@evDi7ih;TOyCqBe7ymaAzw)3iKI z%blj>Yg*nk%}m89cYdX87w+9Kpfh)sQ8Pw#i_9|av{Xv>JX0Ahrv>BQ0$!_vTBp^r za;=gUPSXmhjoG?HcU8Pr-(L>$nmq9Wfo4}Vyt85i#929Tdu}dNHxx^0G#&P@%!iro zTtlWn4U44tqy@4}d9Y@!-C9fLj!j7Li-O<~sF5m_QmNpc0ltPp;@neX4f?~|LOo&C z-XukPFPlo+5>WjC7G=Bn%B{cxPe~su!X0JI=);5zTV$`GMPkLs zD-kG%sKmkHaIg~oOqn7q5K&pcH_>sbcomOdABoZcdpg(|sQjnF&p+(wD39GKd}p-} zTjwR=XHOTYf#gFrok_6R;Nog?(}-zy!d*9*HC*4CtZyBzZ%@{@C+apQHv1B;j!{Xp zWRA*0(X3(H!lZ5Cdv(K$8j_0|hHQ<)w(dl8_ccW}8Ak=#U}ST6CAehIkB=<4t0Oz& z7Zwlc_3QwuAJ%+L4nTjd8TQvMB_GAl*G6Hi@l1l z2LXr14sw7mmvxb!Amw=`esKAANyZfHq!!lVv0Ddm`7dGYD|p${C@Nz7oagG4sFnX0 zQPsFwEEnONXB&(*-XNdA<)&q%DehXgkB|*;eEns|1uS<7;UdDr9HM;W!q^LA1Mo$2 z8EJ)E&ByL%Jq2esR1q&2HV!INvceHs0KaZ@89Mmq%YNjkA-nlmc!TELFX0&&qHf}c zyYw&l8a8(w#Fj@U=&F`_vIU-R`JLQ^U7KQLSN!&-S|b06#=B76>Lho-+gd^P#$Rf6 zO5`lO-S!xH9rWG>^2g};0BXEt1@Cg2((_L_7~v0o7Gi7=cRz)lUbAux>2ZW~c+)#y zK7sTzcrmv~KFVpz2`Af}yx|HlT3l#9kadv8La)d0&GhhYZ05_ zEozh08q>x(i22aln-zO1(i3P4hGOLo4(;wDFsYAQ-$KJ-gwGL9BYcDKE$mQjr2|M$ zAtVvbAgn=XL|BV(6ppBc{I#c48#x5Osq^&~z47J)an}>iiKQdxIFE1z;TMD-5UwIv Z5Y9rvZy_$Y;y<0$Xe4FV1w`!+;%|<9?tTCO delta 1678 zcmah}ZERCz6u#&7_IB;oj&&_7-B;=ODun_e_(jAy(+?w-sI`?KIc5= zyzf2t_V=rB=4)`>bvhl2xOOW?Q_GAauB;{dJ}AmQC8ls-3Rf9$3#(zZ%;H!2wSM3l zt795*Ze{h%Dw)m#Y%bID88)#BGFyICB32EoB)_t=dCV?74mO`TBr6s57qHTNMz)MC zWMwko6wE1^OE8yY<*`|85i2LYiZ(7TULmv)n^mN_rB+d-RZ7iWqbxRl~?7xRT0aBT`bIeGIyoQeWLvmHm9il9HA{`{vyrK zs(E#v8j6@L)}bY!;fgAcS@F<)4U%U0>Z?|J&bG}+Bn_U%9~&>iaXj{9JM1?dO@o^I zWAWe)TKI8#es2;C;fB@_9&HW=KB47X1bYc;1>{Q88Ez!D$9q!)2HtA!Q!l~Z=u76s zmOU1oO7Szo-r|3Nm7k^XIUH_{s&`;GdTW{?gmvp{{?D{={aSG2ck~M(F$`#oa6Pk0@st6W!s}2L;Vp z#iUY$;%9ImmEm$ApOS9bp4yRfbR-6L8BcS*lZPDqbF!W!&~T)Cx&8&wFY$hNt^S#y zxgg$-2jNxosl-hH8wPsn1vd6HWNqRRbDKyB>HHNkwGq5X;FckRvWa-45P!Qk?xeWJ zr{c0|38^L1)S-g^gGrXR%sh-Tg8+pVYh5OoM zQNF0aWOX{yq)VfaR4d7aiDdO7#Q0VUNG6XVnVharr@} diff --git a/webui/backend/app/services/file_ops_service.py b/webui/backend/app/services/file_ops_service.py index 7aa8637..8b560a8 100644 --- a/webui/backend/app/services/file_ops_service.py +++ b/webui/backend/app/services/file_ops_service.py @@ -353,6 +353,32 @@ class FileOpsService: height=metadata["height"], ) + def prepare_download(self, path: str) -> dict: + resolved_target = self._path_guard.resolve_existing_path(path) + + if resolved_target.absolute.is_dir(): + raise AppError( + code="type_conflict", + message="Source must be a file", + status_code=409, + details={"path": resolved_target.relative}, + ) + if not resolved_target.absolute.is_file(): + raise AppError( + code="type_conflict", + message="Unsupported path type for download", + status_code=409, + details={"path": resolved_target.relative}, + ) + + return { + "content": self._filesystem.stream_file(resolved_target.absolute), + "headers": { + "Content-Disposition": f'attachment; filename="{resolved_target.absolute.name}"', + }, + "content_type": self._content_type_for(resolved_target.absolute) or "application/octet-stream", + } + def save(self, path: str, content: str, expected_modified: str) -> SaveResponse: resolved_target = self._path_guard.resolve_existing_path(path) diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 320bdc47bc34f6152fa18e8834c91d6480f7ae46..6a2df30a80048e97369fc5f63c73169d46b887b5 100644 GIT binary patch delta 8864 zcmb7Jd6X5^nXh}R-rn!Mh9Cji9?-PijYZeKG<(>NAhLA>HV~~ncwC^?>E&Kwe95=RXqf_jpPIm5^<`+QZe8|m(PzBm0xpL+MZ zb?@)~?svcMcfZ>FR%-LEitR%pKcFaz-;tY)e(=hjzj!*{FZ&EMP<%(luA$NFp6u@I zGkf{U`?6UsUkUB?c?|!@c^f-^^U}iPvwqjw?4PpF?(O&Tzhp_%2~-z{rV%xC6CxvJ zRF$pw`=jh9e$VRcCwu$7^=>T>EJm3<%P+mc-t8=2+d z&3NYH%5n38>_oH{9fRM3bSPqTNjN5H)zm)EnAs=_V|Rn&uiV zpmm_Pq-i{>+Z@kYDlf>cC<=z8Q|JAjf+eetEf}ojihfViWDQd^SV=KB(UApFQ)TL0 z(9^b@U^v$?1y*tmgVlf|*5NFfH%%BwP^q)E!St33!@3Tui!N{{t2S#orpmgSBy)Y|`fC&=J7RNJ}dGc+1AzC(2Im7~aQz)>S7xtuELJj;t3tU*y!U{-cm)39VsG(^d= z6qNO65_!XN0T8S6qUnPM_%Jgo%0M!2O12`%DC74esj;}K^U1#fY4c)ac}egV?~4Bpgq-eGyp=7+(SXfCU3GHj0FNUrJ#mg#aRoqeBL1lurgod1r(+ah?F3jWE- z&`}pz9UKdsL~t$D;Z(!sQ2&727dvnjrVf*}m(fBp^E9d>zq^b!5&i-^O}v6?>jDfK zIFllBtR(3Uc&4heCJ(+XYnCK9qH2I+QkMyT4zXnNSyW4=Jc_Eut_WUL9LEqH@C9%) zc;5i@ylb#-~F z^Y|H5Lq0qQKa*F{S+d0Ma_l^+CR@(}fXBeV1Z(SeGp>qKkNZ*VhwoRBc_&en9Jv4^ zH=aaIDavBaIC2+K!&RrtnR`w zhmi$Gk!6v$1e73Kw!!)?T|^I&7tW#D!dkZF0O+nuh$Ek_M^*K6g6SnkG!z*Eonk34 z9E1TA_JieJ-BN7X;ay2W@gRsF`v_H`JIK+qu+mA(;aMZi8&FMTOhD8dE)2f%3|buQ zwxSD$WmzWc$TkeCa}X0n9+Qr%*jP_#_@p>My_+O|fy+&D0(r znQTFPf_T8%t_D!xhyWD$DVVz9a-7HsrcGTUYu2F}G?6?GGm!ml@H~(l`YQ~4;V5{< z*-ub)FfZAa!c~#hV8yFf`mXVOz|DjYP*uc+I93Y>Xv}XgnlgvpNNq(LqW{j&%rHh} z7BH)rEz#SflcQszsmO`Q`;mW({5!L;6IZswL|TKuHKYC^FKd9W8TAXXs0|Xpt_-z^QZveiT6nn`WqMfJ3u{#y zc)l5>dziXe;I(E{5o+<7OBJ;wi&_$2S=xKi9skPI%YutqP*Dq2%tBr5vb6Wvaq3FZ zmo68zTqY`LYqG$)u$?&cH$0Ncg4r26A{h9#(kz_H;atY@UV( z6K5L!%s*%nGl-TEner<%ptG_GpRSv9JK9d6PtiWK9UGAv<_dm|c@L)du*xij$r<`D z^vm>PzNn7Rh;7JjiEPR)#~){fY&?1fe~;X_8CMU$>54fOqayF7&PPY5-c3E1dYDd- z`J3^;8d{8!bwa>d?WTw{PW29`2NVVkp=O8jdlPmPQ;-yuXBCGBq^<=s z3$DVtj-bkhZ3?pFAmk6~@`7Q5v|+_ydKD5Dh+Y^TWHE0+uGU4Hx)StM6;8EH4Mday zqTJ*_Q`vwoghWm*_o$P#e z3%+AEJ%Cb4>>wUFosME2?Vwc1F#cbDv9~eZGIp;WI#ahEG5_w30&|Sn1#%WI7hCo4lges6m_yYs*jY))lxm z3R6jC-0`k)jreYQ{Yh*3OaLJ?tgUnApYgs_RT}m>Lp4*`#hL0fmHJC^PvXJ28Ec4K zVg8H$E#8S9p_(D|C~`iR#|4lxycs?pdFw}GOl<~nE;`C@Sxs=!nhPs6=O3W%n8 z(|knb#N?ugetHDY?d@Jay%klFvY=?*!v!cSycIqaql}AmQ7DI+5l>!d4kHIl4JAFg z%6q5)<`(Z3A51xyt7~8e_6&3NtL;17ds-aw#b`9sKV<{155Cu1N03W9acaS}OG>B{ z4e&MmB>&^ASlr9gFS8{k*7%$&m1X@8~6b1!MLP=gmdG{Bf z%<^UhdsK#GD9TIg0w~vvt4FoH2?Im@qEf7k^6u*arHoQ2LBnyVyS(B8lJC1=wD_iu z7mt*V%r|@Y7Vs?a7WmW%WfWYCQnTYD+}N8HXd6rZ_s6Bqkyq`tRRlW5gUl;K^|o|< z>Y9+T!cX{yumc;`R><{-smurIS1Wd=T9Qi>LhMrXuaSe1G0b=9TksI-Fy#79IX@v^ zS)i*+kd$+|=%#A~d2=TkI40*S5vexE<`!|{3{HTD>8&j-x#`9OxvB0(XZ@Pi)-@dq zev<20`%l^6mG-h;8Mk`Xy}cF})HA}`@^$%aVIA|lc|P4=Tl|i7Rlj>KWa#y%_!*oG z3;C7-3z>D#`j+DOd-n{54aJr|u%Rul%e-Yi>~bpcZEFcL91n)LQrLh|*W(rn z8_;;SlUuUBbbLn{-JB=rJg~FKs`uq_J-%v1;O6#ub@YbJhViPU9Q{F zN35LDvcYsV-W@DKtnI@Q)SJTEyqoeBU5dTyaFs3-UsLSuVd=5&(Sm|juc^5PXv_D{ zrwUu9dT@n;>rxq)?6$Qnt%hy2u3p>XY$THop-fm-nAeDglS(5j{DJAFl3AgDKG_4A zQAWML`v~;D$dPR-fCem$R`R=mUv5ilFF&J7lrZ%0ZPZag)m;gq?S%m`FH_L zSGH73*XoNZngb%rkzd_{*uK}9z%+g~%9h-y%6zN1X(j>VVM60Cja_U(uu3`bOBM+C_C1ckSLkQIFib?Em?4=;GjOP@ly? G+y4QnQDH3r delta 1425 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 LAMr$JVmkjn(?;gR literal 0 HcmV?d00001 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 63cb2b1bdb15c2ef78a876b75a7a306c4e1a5890..1317c6b214a7725094657ad60fc56bb3e0e9ef3a 100644 GIT binary patch delta 984 zcmZXSSxgf_7{_ydYQ`(p~5C7lHH~;zOn`8L6V*Hds zGo@C~CgjGJUk+|)yr}s@qG?G@=^5Cn%!1-fEtIgC(w58t5^UKPh!tt^L{;`)%qQ8M z;AC}BU#6iJ5uPKkN#BQ#N-y;~DmOpKxlDnuAo3kzVGA!r3%PK+5DNP_X)^C7jV?)7 z3+yB;TgXUb)_ux(uH4{KDuCkE%rvD;k|5C3FAHcWi_~vVS3#*G3obKS%ttf)J;TLL zAC4c>YT?52EEseSVIIy%_|MJo`HCUz8b8mtG|>4kP(0LG&6<)SAgR-ZUJ2>!mT@dDG)WV_!qJa# z%w4Gb}O+fAUOtJ{KzlEo^7ANsZ?#EMd)PQdVPz)-$zKk+^*W zv5T-xOMUEh(rWcbI)#9tAu0sKU|29DA977F#It;Bl#htk-F~qVD(ae$8CvUd=+()$ zK3~U@%sTlWM8woOsPI`9h$DU?OuOO@SH z?ZNfP!OcSD=rsL6ZyV9urU^Wps8$n{>QL8xGUp+s>Z|IlI#4q~5>#%RPOj!J8-!3G delta 532 zcmaDmmFa9h6W?cEUM>b8h_~IEc`M>8w(B$9jrJ2vlqAiuFJbAC4I1_`)WIh&^&A;?Gc_#n27MUz=eFRLuw-%V3ZzM8V z*yad`-u&8zhY>8n4RjAkp}rkhVluluSm9=E`(M1975(ZM8P85$;IGVh4osd0lNZ3` zMKE~@OkM_&ljr*%nH(P|GPyiJjgfWoyZ~h&dCo|Y@$TdYfy&JHLfJt)=KG->ll20X z89Bjn4<{D{DKkF`=QUj{$7 zWXcnmye?RrnJ@U&UbDfcsi;sGfUoJ;c546 R@@@71z|6oRRU`(q1^}pqy^#O_ diff --git a/webui/backend/tests/golden/test_api_download_golden.py b/webui/backend/tests/golden/test_api_download_golden.py new file mode 100644 index 0000000..3f487e1 --- /dev/null +++ b/webui/backend/tests/golden/test_api_download_golden.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import asyncio +import sys +import tempfile +import unittest +from pathlib import Path + +import httpx + +sys.path.insert(0, str(Path(__file__).resolve().parents[3])) + +from backend.app.dependencies import get_file_ops_service +from backend.app.fs.filesystem_adapter import FilesystemAdapter +from backend.app.main import app +from backend.app.security.path_guard import PathGuard +from backend.app.services.file_ops_service import FileOpsService + + +class DownloadApiGoldenTest(unittest.TestCase): + def setUp(self) -> None: + self.temp_dir = tempfile.TemporaryDirectory() + self.root = Path(self.temp_dir.name) / "root" + self.root.mkdir(parents=True, exist_ok=True) + path_guard = PathGuard({"storage1": str(self.root), "storage2": str(self.root)}) + service = FileOpsService(path_guard=path_guard, filesystem=FilesystemAdapter()) + + async def _override_file_ops_service() -> FileOpsService: + return service + + app.dependency_overrides[get_file_ops_service] = _override_file_ops_service + + def tearDown(self) -> None: + app.dependency_overrides.clear() + self.temp_dir.cleanup() + + def _get(self, url: str) -> httpx.Response: + async def _run() -> httpx.Response: + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client: + return await client.get(url) + + return asyncio.run(_run()) + + def test_download_success_for_allowed_file(self) -> None: + src = self.root / "report.txt" + src.write_text("hello download", encoding="utf-8") + + response = self._get("/api/files/download?path=storage1/report.txt") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content, b"hello download") + self.assertIn('attachment; filename="report.txt"', response.headers.get("content-disposition", "")) + self.assertEqual(response.headers.get("content-type"), "text/plain; charset=utf-8") + + def test_download_directory_type_conflict(self) -> None: + (self.root / "docs").mkdir() + + response = self._get("/api/files/download?path=storage1/docs") + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "type_conflict") + + def test_download_path_not_found(self) -> None: + response = self._get("/api/files/download?path=storage1/missing.txt") + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.json()["error"]["code"], "path_not_found") + + def test_download_invalid_root_alias(self) -> None: + response = self._get("/api/files/download?path=unknown/file.txt") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_download_traversal_blocked(self) -> None: + response = self._get("/api/files/download?path=storage1/../etc/passwd") + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + + +if __name__ == "__main__": + unittest.main() diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 5b67d96..f39a314 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -73,6 +73,7 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('id="context-menu-target"', body) self.assertIn('id="context-menu-open-btn"', body) self.assertIn('id="context-menu-edit-btn"', body) + self.assertIn('id="context-menu-download-btn"', body) self.assertIn('id="context-menu-rename-btn"', body) self.assertIn('id="context-menu-copy-btn"', body) self.assertIn('id="context-menu-move-btn"', body) @@ -214,9 +215,11 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('function openContextMenu(pane, entry, event)', app_js) self.assertIn('function closeContextMenu()', app_js) self.assertIn('function isOpenableSelection(item)', app_js) + self.assertIn('async function downloadFileRequest(path)', app_js) self.assertIn('function applyContextMenuSelection()', app_js) self.assertIn('function startContextMenuOpen()', app_js) self.assertIn('function startContextMenuEdit()', app_js) + self.assertIn('function startContextMenuDownload()', app_js) self.assertIn('function startContextMenuRename()', app_js) self.assertIn('function startContextMenuCopy()', app_js) self.assertIn('function startContextMenuMove()', app_js) @@ -236,6 +239,9 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('const editableSingle = items.length === 1 && isEditableSelection(items[0]);', app_js) self.assertIn('elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");', app_js) self.assertIn('elements.editButton.disabled = !editableSingle;', app_js) + self.assertIn('const downloadableSingle = items.length === 1 && items[0].kind === "file";', app_js) + self.assertIn('elements.downloadButton.classList.toggle("hidden", !downloadableSingle);', app_js) + self.assertIn('elements.downloadButton.disabled = !downloadableSingle;', app_js) self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js) self.assertIn('elements.copyButton.classList.remove("hidden");', app_js) self.assertIn('elements.copyButton.disabled = items.length === 0;', app_js) @@ -244,6 +250,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('elements.propertiesButton.disabled = items.length === 0;', app_js) self.assertIn('openCurrentDirectory();', app_js) self.assertIn('openEditor();', app_js) + self.assertIn('downloadFileRequest(selected.path);', app_js) + self.assertIn('anchor.download = selected.name;', app_js) self.assertIn('openRenamePopup();', app_js) self.assertIn('startCopySelected();', app_js) self.assertIn('openF6Flow();', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index b06523d..8f7fac8 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -328,6 +328,7 @@ function contextMenuElements() { target: document.getElementById("context-menu-target"), openButton: document.getElementById("context-menu-open-btn"), editButton: document.getElementById("context-menu-edit-btn"), + downloadButton: document.getElementById("context-menu-download-btn"), renameButton: document.getElementById("context-menu-rename-btn"), copyButton: document.getElementById("context-menu-copy-btn"), moveButton: document.getElementById("context-menu-move-btn"), @@ -383,12 +384,15 @@ function openContextMenu(pane, entry, event) { const isMulti = items.length > 1; const openableSingle = items.length === 1 && isOpenableSelection(items[0]); const editableSingle = items.length === 1 && isEditableSelection(items[0]); + const downloadableSingle = items.length === 1 && items[0].kind === "file"; elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; elements.openButton.classList.toggle("hidden", isMulti); elements.openButton.disabled = !openableSingle; elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file"); elements.editButton.disabled = !editableSingle; + elements.downloadButton.classList.toggle("hidden", !downloadableSingle); + elements.downloadButton.disabled = !downloadableSingle; elements.renameButton.classList.toggle("hidden", isMulti); elements.copyButton.classList.remove("hidden"); elements.copyButton.disabled = items.length === 0; @@ -490,6 +494,40 @@ function startContextMenuEdit() { openEditor(); } +async function startDownloadSelected() { + const selectedItems = activePaneState().selectedItems; + if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { + return; + } + const selected = selectedItems[0]; + try { + const blob = await downloadFileRequest(selected.path); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = selected.name; + document.body.append(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + setStatus(`Download started: ${selected.name}`); + } catch (err) { + setActionError("Download", err); + } +} + +function startContextMenuDownload() { + if (contextMenuElements().downloadButton?.disabled) { + return; + } + if (!applyContextMenuSelection()) { + closeContextMenu(); + return; + } + closeContextMenu(); + startDownloadSelected(); +} + function startContextMenuProperties() { if (contextMenuElements().propertiesButton?.disabled) { return; @@ -744,6 +782,15 @@ function createApiError(response, data) { return err; } +async function downloadFileRequest(path) { + const response = await fetch(`/api/files/download?${new URLSearchParams({ path }).toString()}`); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw createApiError(response, data); + } + return response.blob(); +} + async function uploadFileRequest(targetPath, file, overwrite = false) { const formData = new FormData(); formData.append("target_path", targetPath); @@ -3836,6 +3883,9 @@ function setupEvents() { if (contextMenu.editButton) { contextMenu.editButton.onclick = startContextMenuEdit; } + if (contextMenu.downloadButton) { + contextMenu.downloadButton.onclick = startContextMenuDownload; + } if (contextMenu.copyButton) { contextMenu.copyButton.onclick = startContextMenuCopy; } diff --git a/webui/html/index.html b/webui/html/index.html index b28f6f6..656a9c0 100644 --- a/webui/html/index.html +++ b/webui/html/index.html @@ -124,6 +124,7 @@
+