From 4e1288fe470268e6c1b84064aab6b96ddd5334dd Mon Sep 17 00:00:00 2001 From: kodi Date: Sat, 14 Mar 2026 10:34:31 +0100 Subject: [PATCH] feat: contextmenu copy folders toegevoegd --- .../__pycache__/tasks_runner.cpython-313.pyc | Bin 8996 -> 11269 bytes .../__pycache__/routes_copy.cpython-313.pyc | Bin 1043 -> 1258 bytes .../api/__pycache__/schemas.cpython-313.pyc | Bin 9530 -> 9568 bytes webui/backend/app/api/routes_copy.py | 5 + webui/backend/app/api/schemas.py | 6 +- .../filesystem_adapter.cpython-313.pyc | Bin 18331 -> 18613 bytes webui/backend/app/fs/filesystem_adapter.py | 3 + .../copy_task_service.cpython-313.pyc | Bin 5561 -> 10579 bytes .../backend/app/services/copy_task_service.py | 224 ++++++++++++++---- webui/backend/app/tasks_runner.py | 93 ++++++++ webui/backend/data/tasks.db | Bin 188416 -> 188416 bytes .../test_api_copy_golden.cpython-313.pyc | Bin 12915 -> 21624 bytes .../test_ui_smoke_golden.cpython-313.pyc | Bin 32040 -> 32269 bytes .../tests/golden/test_api_copy_golden.py | 162 ++++++++++++- .../tests/golden/test_ui_smoke_golden.py | 2 + webui/html/app.js | 5 + 16 files changed, 440 insertions(+), 60 deletions(-) diff --git a/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc b/webui/backend/app/__pycache__/tasks_runner.cpython-313.pyc index 61e069aea48f37e60c90c673bdafa162e1f1f4cf..96564b6aa3d3771f2b44257ea4960bd299ecd374 100644 GIT binary patch delta 2091 zcmah}O-x)>6n^*pF@NvDFvCn|rZ6)=jn7zUsr^9^I{c;9whE7G)wVFegF<0w?|@ph zg;+JlpG9+OqAu#9ZiqH9P8yTCFsNna!kXomFL_+9@VOE@xAYy^WA&T zJ@?%EUe8O%%vHniN%&>%zCQl$_Dkliwja@YQ#vkjW=mYQ8CUF(9k!LYG@(u~uG&?$ zN}OwU#MT7U?WnB_Rt9MnYuRF#3Cn{Dk6>O@cm?y}c%NW?jQRx&U^F0@LF1dZ*oLr7 zRG5O5qoQ1}3Oi<32o|*Cc2KZNyOM{-WGhi9uxm2h(B2LYPoFtAFfz`a;hAZEj;C-_ z3oxedQYipe^knFEMGOV2@Rj~eh42q$XNFI$kNT*rvC(xFtNXM0(|xnKoWmQO+}T-Y z)){caX_}`IzScuf=h;>mR3nU8@U-@`%4Z)VGgelpQ z&1~5&Yxbn1qwrxY3jI~1bKN+>g3N32$Lol%=QKu+Qv|)_^^`KT$1BR6AF7Kam$#;t6I++JR<8sl!}>+?8s$DKqRFa*f6z9?{K_Iaw zEh_93hvK7Oe#^G zK4Om|ScY4eQj2(}rk`>KM#m=|ch{miMgNF1>CCLhsPX?%DljXP6?-W{*F`i6UY7?d zqxv4Si!o>m#@Hu?naY$hS30Xv(up0>S&8H<{f_U%e)xs;0kCh3227{K#^OTZ%gJ#I2Ys6n9m?!xEj^VSY%ip;v+Tmm(X_^ct zdX8p$9s!!Fm)HsTz1q^-Fp#(4V6qyzYu;(6le1L*Dk-UbxcdUUP9bGeYtp-r$H$2F zSlVG0YLa)!9SnMsmq!LMQo1~X(PP5}t0(pt!7+lz5v=w1W{@1>!U^V%ah2;QC*nKd zd@4TY#sZ~N{nuICm8pS>>d^G`b&?OF5bT6C>K09;J>8f%AGLTg#Ozd_rv qGI;g7G>gAQqoV}$E*3qlQ_~}}lTI7I0xPxK$}gzQ{8b_pqCWu}?zkEN delta 1493 zcma)*O>7fK6vuaVy&K!{Zk*yIavH}QN&=HoXrVL-B_B{en*<2cR7C`^V{eP8V@tD6 z)p7|vv^}=bqnrwt_JS%TA`3WF91!ZIr^125(jGYUfH<@QgwzY-y>S}*;6rWcr~kZp z^X7fbe)Z8$`<aCSR)?IcUE#1J?_u8B$nb4#zm^4J2hzp}zI~zL7WJt7& z5NT=lYR&)R6=5n5ORN)?QY|7OT9gXo`LI%vrl&_SCBiAy;IL9CYz!@sc-+XH9pDSJ1XuNbnA zc8B4%y~iFVxq-|fw8Wk3t>hCJ-sd?v0lzyt*l^WqYiG=Yqv=F-E_q4s9-=dJ!xdew zC;b{>F9J%j`98UemZOAmgl{3-^YoJmGVX?Nd$zN4aHnVD-S^0_kFcNcK7!lQCViOT zAcBah`r|^;lSj!oLpTCssm_jLBn9CE!YqPEuy;u=2gyLi$PWn<2tR_zWHN5Xb=`?t zHXMFV^|Djd-%_ptH`)gdj*$0p#D9&NCqs@viE^vfz5`@9NH|1TbK=p}^kH*fGF{KN zS*B{r-IShhA}}e}t!1?Zmq9OFOLau^1?lA~W%JNQ4jU^sCEOok4wLVLV`X7Wo zEtK*>WX)aGATzYD@(wr6du{&p3S=J7a3?T|Ua3BcAmWu47d$zgt$1oI(xcFGgfT*s z0_)@AF&QT(_42lpQVkt}bk|UiYCsH)ssXF0^Q9?1xaU_db}cjJ!=_{!9{2ys zW?*H2>kl>fW8jx@Rbfc_6uCmEk7p5=+-SI-hUOaxys37}EZpBXp#P=8i;WkS)I(Ce zRf>9BdtCID$VtK!VH$yDsfzx`8G4REXue!7HXL7_N#pS)3|+soe$(o+Q?As&bBSWX zubjvia+MQ)MQ(NxCr>uS4mG6KJdAq1Dqwv-4&U*88@5t`Zxhl4PM{|#w-MC$Hw*UQ z4x1+Pwf0;$%y*te;rQT;{?F8pHwRhwm*m(*NFajnu7z@bvFMG-Z{Y6h{cTx;IZrgA Ga(W55kR`nU diff --git a/webui/backend/app/api/__pycache__/routes_copy.cpython-313.pyc b/webui/backend/app/api/__pycache__/routes_copy.cpython-313.pyc index d6dc33e891c3683e9c9d6f5a81c0c65ef5885624..67aaf36a00d084b953105d31842705b81fdb7c0b 100644 GIT binary patch delta 478 zcmbQt@rsl0GcPX}0}wcBY|WHr-pJR(XtxE(Rc26Nn92~!5CoD#14$|j455rc%xJPGi($_S0m##a^6WT9lkxd`loDwYVfR zFR>&uKQBHhu{aed$p(?U#SN8n1W9VL74ZYjyd{=gl$uzQ3RGK?oDrX#Ur-rel31Kw zBmj~WfJ!2S1%bjvLX%aQ{M{vhOdcREjs_Ah85$Tqa4~T3x7Rn;Uu0Lh!7p~5U-u%v z?hO&?>HZV_Z^)`#mo>d8Yx;qeK|t>V1G4}Zkl^I{z%jX7Kcr4eoARhs$G%tj5l~CJ}`qA i4|ruhu!0yL*cjL(zOaLspP3n$ycj<+f!IY#KqCOoMQ5h~ delta 250 zcmaFGIhlj+GcPX}0}yD3ZOBYw+Q`?#$QK0UDl;fBOl1gV3}T$Dz$D7Z1R^KDWt6lu zXHH|(Wb)Hwy2VzUUs{x$dW$&uKTnhSmOye*YGO%hd~$w4Wqe6uadr_u z(7Ymn$;X)dg++l(9w07O2NEwC8W=usO^#t!5@!K&n^e^q{WRH%gn;ZKHV`2^c{=k> z8IYcp44;7{Ly-uOxW!?Uo1apelWJF_Fgb_C$B2b7hVcfk#0O>&;{mVC2UZZ{0~-UI X#20oD^D{F8lNaMhCJ?(w9%uvrVIw>8 diff --git a/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc b/webui/backend/app/api/__pycache__/schemas.cpython-313.pyc index cdd28a314ed30e4bc008ef5821bed231a56d4023..d68e0a4947d62de301c3e9f45349bcf90fe2bb94 100644 GIT binary patch delta 627 zcmZ9JO=uHQ6oqr8ww;$`I@pX!XC^;0Ng+l-icmL#77;Ou*e;~vMhjiIvY99;w%QN*Ab(Rr460 zr}t75E9t|S&S*wcEH?#VPcK8u(M*cgFrF#q=0y>#aaDU5Jd@sNaI#~c58L>dc|$E! zl1bdi-cd3=U(mv@>?NM9TsoICX&vu#!`5q&1?ybZUIw2Sb-s2v_~GVIa4*va0{4ho z6r*4RU)&Nc{+ZSYZDPzjp_W7zY~qz?(-J;;wP^SYXGN)DBJc6n>3o`28gqG*VqH{x PvTrBCuN@tq3%7p*|Ffk$ delta 631 zcmXYuPiPZC6vi_pYO~qRu5{NV>+WEn_q8{(a9vUz;9^9QEMAk2#sssf@=Gb$HEb#<^+H!bTpg=mMJnt zkSlm5BPE_=UpZ$uGgf&Xilwyf-XFclRuIHiPkFam3`~5Kdm{5QG!)&3wf^Hal;<%bg zvJFgS&h{))E5Zh~(%P;3?@_<0cU6a4w^+n>=BXi{jutCd7t~P6W^8)FR>%};v$KC} e{MzL8(m+suyPCC_+QF>Z-kpf}?I`$_yZs-k52i-| diff --git a/webui/backend/app/api/routes_copy.py b/webui/backend/app/api/routes_copy.py index d547859..8480740 100644 --- a/webui/backend/app/api/routes_copy.py +++ b/webui/backend/app/api/routes_copy.py @@ -14,4 +14,9 @@ async def copy_file( request: CopyRequest, service: CopyTaskService = Depends(get_copy_task_service), ) -> TaskCreateResponse: + if request.sources is not None: + return service.create_batch_copy_task( + sources=request.sources, + destination_base=request.destination_base, + ) return service.create_copy_task(source=request.source, destination=request.destination) diff --git a/webui/backend/app/api/schemas.py b/webui/backend/app/api/schemas.py index 1a7b8a2..d228d04 100644 --- a/webui/backend/app/api/schemas.py +++ b/webui/backend/app/api/schemas.py @@ -153,8 +153,10 @@ class TaskDetailResponse(BaseModel): class CopyRequest(BaseModel): - source: str - destination: str + source: str | None = None + destination: str | None = None + sources: list[str] | None = None + destination_base: str | None = None class TaskCreateResponse(BaseModel): diff --git a/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc b/webui/backend/app/fs/__pycache__/filesystem_adapter.cpython-313.pyc index 05cd24de1141bdcf4ceddaf19e04bba3666325e4..5d4512b83f161804ae8c6fcba1502183dd977406 100644 GIT binary patch delta 1524 zcmZvcYfM{Z7{|}k%b_sZ0;9Ln(w6lChr+gv1qRa5Qym*^B})IbfyNAyA& ziJusRl1Vc$3ROk+5LqY_36LtGs<|==RYQWLTBu#TqeiG&u67A!=BiexSqlk~FtK1T z3e?I6SVf|aw2)R(SN7|9q>}n_)Ii!ugfx_+-K3p#klp2|kw<$;V>z-7+DRv|VRQh` z%^&I@UBr$Kbq?BRdElr>yLd|%aaMSW@LUz1TX;%^=MkQ}!fO(qr^54+Ch8k8tM`<& ze%&3#uHV|4U==5iVe=?E;tI(F4Fpa|>^+_5IMGE7erZHf^~WAobtO|_di+#wVysXy zr}LB3k3TUrkuK))6RMuRz-N?9=oV=GU&LXy-UBZI~=)tS?#5%a=0C;nnuf+t*FUX)jNu4XnAkQG2YZUI(wUG2cTl zqh0k411vCabBE&sFHsG2AOE4lZuJ91dYfpurhHdBfmIS<`MO$A4-u9^)fJN<=>e0i8nxW&Eu5+FDmE@_3 z$y9obUxHZnittWmQ<*HSI6J-0p6d+DD?DEqVoNNmH`xOUHqTeDu;!vy0 zv)IqwBe2Yt;!ZZc-wI!9&+fma>nib*1>?WV(G6^LAe+j3WWfPT%MkL{7SR%CC+JTl%A-C)bHHdqzMv-VoSsQG$hdbr7k z;%#O#|C>8}9_Y()UYkx_&k{2{Ke)Z@Q~ryig+l-%55g=~To^BnDl^8E)K;SW;m# zNr&xYG)4N89ks}ok~R=@h*F$hxo7rMG5~&7*GKzb!@jo=mk=Kyt|30-DAg85c-%0r*2^4;=ae)|uyU lL~-IN@ax<9d`_^~y=y23)@)6p?_Ar~+9IP)+)|0$Xkr}Qm8cODxE&iSQIm|iu&EMx4tTK}Jsc(Zw(=2Mu?M}J z5k6V%!L8+Lp;Gp%q_4`-taARUTtMZTt8y(W7pTewRj#Ef*NQN7?}t7 znqG}YVU7yXPvDg0*|q{OOFzf@jdR?3fjT=zV9t84<4su4*v&yWYYm(4gR~Xn?Qqdr zjR$S8Xf<{3(R=PlU&%QB`mxcm*GyY^?FE%Plp7t-iM&k);c!^+s}wmby^qBQ6s-@Ezv#VpWy_hG|JRB8 zZ1gew68RblxpjT-4Y}7(ms3vo&00!*1MmaAyCV_ZSXr`-9?Xl?Cpd4?%8r<^#+768 z#3`%q`9F2|p1RULZ;`WhiG44p!s3w>Vojr0(oxT>)W13(EAJO8G?R`P<&E@RI%x1) z68GuHbR2G}>xFi@K)1Sg_S>LHQv-2_A%8a0SbqVs1C zCw1!Lq_k$M9O}DLv(NKb51&w-Ld^zt&}TzV?;=-K-?tm;94!xdLD8o?yt(>5^w&^3 zv{74D?EZ>-; z{>cX60R?uwsILZSY*!KP(6hsdfSd1-=EkH%srH2mU1O@I F{{SQCKnef= diff --git a/webui/backend/app/fs/filesystem_adapter.py b/webui/backend/app/fs/filesystem_adapter.py index ab69c90..8b3c992 100644 --- a/webui/backend/app/fs/filesystem_adapter.py +++ b/webui/backend/app/fs/filesystem_adapter.py @@ -120,6 +120,9 @@ class FilesystemAdapter: on_progress(out_f.tell()) shutil.copystat(src, dst, follow_symlinks=False) + def copy_directory(self, source: str, destination: str) -> None: + shutil.copytree(source, destination, symlinks=True, copy_function=shutil.copy2) + def read_text_preview(self, path: Path, max_bytes: int, encoding: str = "utf-8") -> dict: size = int(path.stat().st_size) limit = max_bytes + 1 diff --git a/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/copy_task_service.cpython-313.pyc index 2d517e850be0b3d733c6bf0731a74377ab88252e..6aa2d74baf9701d72f4eb010ba969cebd04b9a96 100644 GIT binary patch literal 10579 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 I`(ZZzU%ZtVdH?_b delta 2604 zcmZ`*O>i4Y74G@bXe5m^vLsuwC6D!I$CB*WyNXS;j$K za(YH44pceIf%2XpbcM<;RP7#jxdl#C@sm9fZXOIZV#+SIidw3|Nnrz=py(b&#NlTS zZ@%}szkcs^_j@x3g`Z|TpS#^ofS>TsAJ)Fych3{ry^FQ`Kn60E2P+h$q6E`d3SNKsyNl{rKInMHpH zzeYD0^e`Soc3;xA)L@G?H0@iW7_{x1M!S*&{nZymKaLNeU;6y$uHQAu6}f_S7{`{y zu;6`JCtAa9t$foTV7Nj6ec}qRm^^?!5&RB&j%n~kP#8o{o&E$rLv`s<@PC#Mqes3W z91tHWhX7b}^t{PD-akgh3!~LQz&lOJwj8TaYa`tW=)3+9U5KISfiM+Ezvv$dH5_ZB zJ@VRE4;9AIKbg@%CyprHv%Pp1CP~bUv&ax9bk~@^YSDEiT8=_xvsEv{ z=tixsJb4cvsL5{OyZ zlT$4}rmL!`TCLGF+BJO5OSP)W-D)WxYU$W6qE2Wu1z#ak$*!0z*;JEJZdX({)}zNCIEZfZU)UYMc6A5$z`LMBFHsI( zTZ$XfNnJW=NU!VC>yNG-NSF8CY>%alu~~g=))>p{WBJDy55{C&NbH^4yY*qLEe;#v zxGs(xVpWRfi;e*5%_l0OXHeanv%Y)OwT?F-Ve1kUTy z`L;J{c+SERqXLPX}U#cBB-HmU5G-r&S*GJFqM=rcb?wJRKr3Xzzn$@LQLz>g2xdUn5 zkd}05>0s#MUjA@k@Q29{((k8#FmW(&V($|A2Osc`8Nn$%IMt3!8JqVsUjEw9@PZ`m9Jvx67 zIopn$=s18o^Ay;hsYTDiGT}4EMs8`q(9UuHx=!zW)F|K_eNHpz@Ps&FEkI}~pXn@> zHd;6*1+Pv{(X^AURt!YNFTbmc-2eap diff --git a/webui/backend/app/services/copy_task_service.py b/webui/backend/app/services/copy_task_service.py index 98c8bbd..fe24e70 100644 --- a/webui/backend/app/services/copy_task_service.py +++ b/webui/backend/app/services/copy_task_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import os from pathlib import Path import uuid @@ -8,7 +9,7 @@ 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.security.path_guard import PathGuard, ResolvedPath from backend.app.tasks_runner import TaskRunner @@ -20,63 +21,42 @@ class CopyTaskService: self._history_repository = history_repository def create_copy_task(self, source: str, destination: str) -> TaskCreateResponse: - try: - resolved_source = self._path_guard.resolve_existing_path(source) - _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) - if lexical_source.is_symlink(): - raise AppError( - code="type_conflict", - message="Source must be a regular file", - status_code=409, - details={"path": source}, - ) - if not resolved_source.absolute.is_file(): - raise AppError( - code="type_conflict", - message="Source must be a file", - status_code=409, - details={"path": source}, - ) - - resolved_destination = self._path_guard.resolve_path(destination) - destination_parent = resolved_destination.absolute.parent - parent_relative = self._path_guard.entry_relative_path( - resolved_destination.alias, - destination_parent, - display_style=resolved_destination.display_style, + if not source or not destination: + raise AppError( + code="invalid_request", + message="Source and destination are required", + status_code=400, ) - self._map_directory_validation(parent_relative) - - if resolved_destination.absolute.exists(): - raise AppError( - code="already_exists", - message="Target path already exists", - status_code=409, - details={"path": resolved_destination.relative}, - ) - - total_bytes = int(resolved_source.absolute.stat().st_size) + try: + item = self._build_copy_item(source=source, destination=destination) task_id = str(uuid.uuid4()) task = self._repository.create_task( operation="copy", - source=resolved_source.relative, - destination=resolved_destination.relative, + source=item["source_relative"], + destination=item["destination_relative"], task_id=task_id, ) self._record_history( entry_id=task_id, operation="copy", status="queued", - source=resolved_source.relative, - destination=resolved_destination.relative, + source=item["source_relative"], + destination=item["destination_relative"], ) - self._runner.enqueue_copy_file( - task_id=task["id"], - source=str(resolved_source.absolute), - destination=str(resolved_destination.absolute), - total_bytes=total_bytes, - ) + if item["kind"] == "directory": + self._runner.enqueue_copy_directory( + task_id=task["id"], + source=item["source_absolute"], + destination=item["destination_absolute"], + ) + else: + self._runner.enqueue_copy_file( + task_id=task["id"], + source=item["source_absolute"], + destination=item["destination_absolute"], + total_bytes=item["total_bytes"], + ) return TaskCreateResponse(task_id=task["id"], status=task["status"]) except AppError as exc: @@ -91,6 +71,133 @@ class CopyTaskService: ) raise + def create_batch_copy_task(self, sources: list[str] | None, destination_base: str | None) -> TaskCreateResponse: + if not sources or len(sources) < 2: + raise AppError( + code="invalid_request", + message="Batch copy requires at least 2 sources", + status_code=400, + ) + if not destination_base: + raise AppError( + code="invalid_request", + message="Destination base is required", + status_code=400, + ) + + resolved_destination_base = self._path_guard.resolve_directory_path(destination_base) + items: list[dict] = [] + for source in sources: + destination = self._join_destination_base(destination_base, self._path_guard.resolve_existing_path(source).absolute.name) + item = self._build_copy_item( + source=source, + destination=destination, + resolved_destination=resolved_destination_base, + destination_base=destination_base, + ) + items.append(item) + + task_id = str(uuid.uuid4()) + task = self._repository.create_task( + operation="copy", + source=f"{len(items)} items", + destination=resolved_destination_base.relative, + task_id=task_id, + ) + self._record_history( + entry_id=task_id, + operation="copy", + status="queued", + source=f"{len(items)} items", + destination=resolved_destination_base.relative, + ) + self._runner.enqueue_copy_batch( + task_id=task["id"], + items=[ + { + "source": item["source_absolute"], + "destination": item["destination_absolute"], + "kind": item["kind"], + } + for item in items + ], + ) + return TaskCreateResponse(task_id=task["id"], status=task["status"]) + + def _build_copy_item( + self, + source: str, + destination: str, + resolved_destination: ResolvedPath | None = None, + destination_base: str | None = None, + ) -> dict: + resolved_source = self._path_guard.resolve_existing_path(source) + _, _, lexical_source, _ = self._path_guard.resolve_lexical_path(source) + if lexical_source.is_symlink(): + raise AppError( + code="type_conflict", + message="Source must not be a symlink", + status_code=409, + details={"path": source}, + ) + + source_is_file = resolved_source.absolute.is_file() + source_is_directory = resolved_source.absolute.is_dir() + if not source_is_file and not source_is_directory: + raise AppError( + code="type_conflict", + message="Unsupported source path type", + status_code=409, + details={"path": source}, + ) + + if source_is_directory: + self._validate_directory_tree(resolved_source) + + resolved_destination = resolved_destination or self._path_guard.resolve_path(destination) + destination_absolute = ( + resolved_destination.absolute / resolved_source.absolute.name + if destination_base is not None + else resolved_destination.absolute + ) + destination_relative = self._path_guard.entry_relative_path( + resolved_destination.alias, + destination_absolute, + display_style=resolved_destination.display_style, + ) + destination_parent = destination_absolute.parent + parent_relative = self._path_guard.entry_relative_path( + resolved_destination.alias, + destination_parent, + display_style=resolved_destination.display_style, + ) + self._map_directory_validation(parent_relative) + + if destination_absolute.exists(): + raise AppError( + code="already_exists", + message="Target path already exists", + status_code=409, + details={"path": destination_relative}, + ) + + if source_is_directory and self._is_nested_destination(resolved_source.absolute, destination_absolute): + raise AppError( + code="invalid_request", + message="Destination cannot be inside source", + status_code=400, + details={"path": source, "destination": destination_relative}, + ) + + return { + "source_relative": resolved_source.relative, + "destination_relative": destination_relative, + "source_absolute": str(resolved_source.absolute), + "destination_absolute": str(destination_absolute), + "kind": "directory" if source_is_directory else "file", + "total_bytes": int(resolved_source.absolute.stat().st_size) if source_is_file else None, + } + def _map_directory_validation(self, relative_path: str) -> None: try: self._path_guard.resolve_directory_path(relative_path) @@ -104,6 +211,31 @@ class CopyTaskService: ) raise + def _validate_directory_tree(self, resolved_source: ResolvedPath) -> None: + for root, dirnames, filenames in os.walk(resolved_source.absolute, followlinks=False): + root_path = Path(root) + for name in [*dirnames, *filenames]: + entry = root_path / name + if entry.is_symlink(): + raise AppError( + code="type_conflict", + message="Source directory must not contain symlinks", + status_code=409, + details={"path": resolved_source.relative}, + ) + + @staticmethod + def _join_destination_base(destination_base: str, name: str) -> str: + return f"{destination_base.rstrip('/')}/{name}" if destination_base.rstrip("/") else f"/{name}" + + @staticmethod + def _is_nested_destination(source: Path, destination: Path) -> bool: + try: + destination.relative_to(source) + return True + except ValueError: + return False + def _record_history(self, **kwargs) -> None: if self._history_repository: self._history_repository.create_entry(**kwargs) diff --git a/webui/backend/app/tasks_runner.py b/webui/backend/app/tasks_runner.py index b018801..f269d48 100644 --- a/webui/backend/app/tasks_runner.py +++ b/webui/backend/app/tasks_runner.py @@ -22,6 +22,22 @@ class TaskRunner: ) thread.start() + def enqueue_copy_directory(self, task_id: str, source: str, destination: str) -> None: + thread = threading.Thread( + target=self._run_copy_directory, + args=(task_id, source, destination), + daemon=True, + ) + thread.start() + + def enqueue_copy_batch(self, task_id: str, items: list[dict[str, str]]) -> None: + thread = threading.Thread( + target=self._run_copy_batch, + args=(task_id, items), + daemon=True, + ) + thread.start() + def enqueue_move_file( self, task_id: str, @@ -91,6 +107,83 @@ class TaskRunner: ) self._update_history_failed(task_id, str(exc)) + def _run_copy_directory(self, task_id: str, source: str, destination: str) -> None: + self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=1, + current_item=source, + ) + + try: + self._filesystem.copy_directory(source=source, destination=destination) + self._repository.mark_completed( + task_id=task_id, + done_items=1, + total_items=1, + ) + self._update_history_completed(task_id) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=source, + done_bytes=None, + total_bytes=None, + done_items=0, + total_items=1, + ) + self._update_history_failed(task_id, str(exc)) + + def _run_copy_batch(self, task_id: str, items: list[dict[str, str]]) -> None: + total_items = len(items) + current_item = items[0]["source"] if items else None + self._repository.mark_running( + task_id=task_id, + done_items=0, + total_items=total_items, + current_item=current_item, + ) + + completed_items = 0 + for index, item in enumerate(items): + source = item["source"] + destination = item["destination"] + try: + if item["kind"] == "directory": + self._filesystem.copy_directory(source=source, destination=destination) + else: + self._filesystem.copy_file(source=source, destination=destination) + completed_items = index + 1 + next_item = items[index + 1]["source"] if index + 1 < total_items else source + self._repository.update_progress( + task_id=task_id, + done_items=completed_items, + total_items=total_items, + current_item=next_item, + ) + except OSError as exc: + self._repository.mark_failed( + task_id=task_id, + error_code="io_error", + error_message=str(exc), + failed_item=source, + done_bytes=None, + total_bytes=None, + done_items=completed_items, + total_items=total_items, + ) + self._update_history_failed(task_id, str(exc)) + return + + self._repository.mark_completed( + task_id=task_id, + done_items=total_items, + total_items=total_items, + ) + self._update_history_completed(task_id) + def _run_move_file( self, task_id: str, diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index b3484b6bbd6ad70af429a6da53ad0c9b328f126a..ba7d4c752da30094a00244b12415089568b056fe 100644 GIT binary patch delta 1575 zcma)*PiP!f9LL{#li8ikY?HNrtRaC^Y>J6}<<0xEGcQP8yG0_{5^X|Z^^n=gj0MF6 z{6~dmcaV4~)s_Xvs}=l%2V06vNuh~{UW%aYMUY^FHci1pbEt=0if_h*V6!cAd58D? z@%eq<_xFCy1+%$ep6!Ll&h=H{v2fzY33z^L`+e{{xDHH)&cHtH0l3~V8f~A3Z^zAP z_^USftTqlwKyBM+0T!-jVJ^!auG_wa?XnZ_y5ri+4jS>v_uy$$JETuV`6fJN=u^he z##hEim#orX9XP=R9<4hpkl6LzKw%~=?D%$oX*qDp!nGWa^O>YDdUX-)jTdI&;Tjx^ z^d&fe^yAUc5}b`MF2N@?*iz8X;LxBpjRmZjUAqCA5qQ!xkSSslsFpth7T4=a^g=23jQNZCwRy@MGoQ;H$$c1&HqqWrea-mE_`;Yn zdX@e6^+u9NmYBgxQt9(D=fo3O2#;azIUKu=%duZ4o-4|A$Fc~xlUCZ4*nzY#b?GGL zlNQC2+b)(~T@dMV?)W}f9!yIe#}BxaYQW6FoGE)C1O zH$m6Rw$8_pjxeFnRV;+e`KpNJKWfq z78eHy6*jeMTLzg(%oYx9FA3@}Rtu+RQX_6+L>-Bd3=u=FoV+`-;eyEnRC1y|$QBn$ zVuVV{+e?;^maN5>zd^4U#vuqn_nz)bWL!XDeC7iBEf42aU{`X{#5^kf_dhpZ-00V; zuwBD-FEbImh;~G)-=bW6avq&7?KBE=VB1iaC|0^(HqUmhbRI3tDgOBa3rpb%bA2K} zi0retRd{V91+`OK+^D;7SNLX%PU00JL%K=02jAGjQk&WCE*uDh;&@L#0AV3%kmcV; zH(IFN+Vg2gbZ-&$kE+E_*ZU!SdKc~w{dN5A6rW*PHt~P|f%f%14j)$*(JF#{=U#k@ dhb)|2Z#9s%%CKykt$zOwJssWq92Fni?_W;Po9X}n delta 319 zcmWN~KS%;$9LDkY_j~W&JAF@ii$EfW0)_r977`TFWkL-NMdcttL2HmfOObFAwp36U zyscK$&~gy<+7P-05*V~JI7kq!4lWT?H$NYquT$ktm7nxL@-#dR$^6>=8eD9Oc``w2 zgpcAegy}D-$*wGHAU-H33$Iku+GL;@6H&FZ4Tqdg>F3gLAjixX^U~bCi;1AF_0wgL z3k{5@Y5`IiSd@AbqH?$i1z1&X6G9Z8Jo*SK{|WmUZ3|K$J8k^%>mB+EOS1%vsJ4wv zLmKyBjLO>?6e=-`&tr6)mAocS`gxsS^BiB|Lo)4nwLJ%OdeIiHDjnc@w_rw@1iu0; z>)RLN1*7aeT^Xu;gL9^40}yGsn5W?DQ%5eo8}R3WA0~S%UMgF`GvTL}O#*|#xMh3O Ph^6cXwh-QmF;V{q7dv2i diff --git a/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_copy_golden.cpython-313.pyc index 88c47d7a7ae723a9faceedc01ae5daed6704bbcb..d89f9fee6a09c2e0c3ed74827fc6e2491a027bf5 100644 GIT binary patch delta 7771 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 delta 2860 zcmai$U2Igx702(~{q%l0ySCT6?qaXOVBgpn0=5AggRwz;xHzIW2@fT`-t1nxYcIR@ z+`C{~Nf%Wj%|jXqCbapG`lVJ4N8VV6>g9d(W*n-qsMf& z)Oqy=y;1kBx#`oJ^tkR@Yx!xsUyiCHsZL4(k^)i+k`$Cuh@_B|!unuT4@)sZ%7~Qe zNvfC9Bl;seI_=S#o#+?^?7&?9aH)_pi%*!AeQSgGG@e|(<~^y#y2%H}b9y$H=iE;+ zZxe%cn`2E7cMvRz({a8C=%|0~tx8YM2EXj`n<1nl8G-RCUT{#Td&yhrY&)rTA zEl29|eSjD65olyBI9wV(gsvmN1aKHQPOuQq`n3Fwj8yY#P+W<0ux@cZGD9)hSASMz zx_Cd@6GGNGZiB+%bcrv*4T8m**x+(=Y!hQ?G1Rc7L$3NXj2pmrfJuVoA*3fKClT@U z4I0}c-XW%zZ#LXyEhdINMQ}V;!8GR_H%dqI@+R^7reT&8f%sN7CU(bPpc$XW-&NU@ z!b4+)FJ6JHGtS zmUc!dczef9?~@q*tXSFE!z$uGJ14uOx6ecS0|0(sR;%X))Ay43eBW`F71#SLvUFkW zadEXXEdJKnC?-=LH*O>G@qs-qU%x6&9Bmbe(YA^oQP%4u1N|rp`lXd=MGHur- zGo)P#S$2tMrp?E;L4Sc%yh6D3D%AzQh~|$7PS{?YGbt>^sX{($(~%&Ud^hlrAETsv zje2Sxz(BW6#Y_fJC8aW5jz-=xXh}0m>n|GX%sa#4i_ui){~dP*ixfm~s3rc_hUOm+ zUl_g-irolpxY4|MW#GuQ&`~izH0b+a|L6X3x&?3VNG?zI_pzR{*biIai}ar_5tahx zLf*2iUVFiI*y6qD@aM!Q1IZu)!hZ@}6kiT(-}N%YON7g7aX1gfLvbM8x-f|GT_yH? z@!_sF=^*J+6W0EZX78Ouymk`3z1vJXyKRnA?`-bAGZE|0qVn!gvE zXl)0z&RVXn9Sv4QuJWG|G+#ACaWYQ8T0D(u2Bq59oYJG3Tb7;UwKII$v>i7F%lfw5 zG{0KWFw`#Cc)R$=v3~W6B09!jTXW*Zc&mC?hH-&L-517QG#>+Mp77_;{2n0F>Kvrn zjm2lg@Pw9?#U+y}i0(FE5QqX_;{vy-0~aOwUj%jlxJRnSe@K#rcLC1G3+i_h^XhLD zvG?)S3O*(1rQ3tia_Ipm)?S}>$lj_wxf}V8)MIQKKtIP*NRN%Y@#Ss}Xr+X^e<_%w zTO<8!dZI)RXc~Rb5HvPTR4Vj0LQBXf$T@SQaXeFnQpT2}9XIA{F}{&H_$HtO*bEE- z2LKr&86AX!zXZGv{2X`#cpJD1ybpW?Y}x|iA;0gVstZj3dh3|5PjpqHp1|ssfajZR zHPJ|7kKgkYTWt)0i7=_ZYK?gYt2Qvx@f*hJOulFs+7>5Z7*l1NJ_8MdyI?zu5iV=d z;(cfq%QN5V zXWer9h?e8V79Q}<@$Wrd%9RV|KK@5Ve5TKDRN2;*w(YA5{a+1ml?}e)|8qjSme5w| SvzHD2YsMpLj 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 d977911ce834847d1322edcca118acc30432efb6..365d88e8ca89c68061bf2f4b2d77029395e171b2 100644 GIT binary patch delta 474 zcmZ4Si?R0)Bj0CUUM>b8@K@QIDIB+vuR)jb$mV|CCeF#eh9Z+Wc?H06xp0> zet;1ypvn)C@C4F8iOIPTdh=S#b$pY(4aFF@Otv#rX56~j&!d}hv!Yi8MB7?#HAa@n z3YLn@heKH=I~Xc59|>gz(?_B7u~0TJ|9B`nm_8B80j5uea!$VBqs+(!=A91Z2GeIk zc|bJd*~wzQx~%7d&j(+a?BT1*c`o>3@C8ezJpRc(zKTHcOF;2CzOsy$C*Si`WabaP z0;HAsubMJ}yw1R&z>vcjqGdQkGXkL`i{K5d{e(;*iQ!4`iU^tjx delta 322 zcmeDE!?@xXBj0CUUM>b8xT3T*Gc8j4KTh0vRG z%?~hwB~+>t>^nd`U)$&5Lqlm^aH77I8DiZk|x%%_!!;#^}m8gX6k{=0yq3 OFAQMr2e-)|Yh?g_T4Y@S diff --git a/webui/backend/tests/golden/test_api_copy_golden.py b/webui/backend/tests/golden/test_api_copy_golden.py index 05116ad..934135b 100644 --- a/webui/backend/tests/golden/test_api_copy_golden.py +++ b/webui/backend/tests/golden/test_api_copy_golden.py @@ -25,6 +25,9 @@ class FailingFilesystemAdapter(FilesystemAdapter): def copy_file(self, source: str, destination: str, on_progress: callable | None = None) -> None: raise OSError("forced copy failure") + def copy_directory(self, source: str, destination: str) -> None: + raise OSError("forced copy failure") + class CopyApiGoldenTest(unittest.TestCase): def setUp(self) -> None: @@ -96,6 +99,96 @@ class CopyApiGoldenTest(unittest.TestCase): self.assertTrue((self.root / "copy.txt").exists()) self.assertEqual((self.root / "copy.txt").read_text(encoding="utf-8"), "hello") + def test_copy_batch_multi_file_success(self) -> None: + (self.root / "a.txt").write_text("A", encoding="utf-8") + (self.root / "b.txt").write_text("B", encoding="utf-8") + (self.root / "dest").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + { + "sources": ["storage1/a.txt", "storage1/b.txt"], + "destination_base": "storage1/dest", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertEqual((self.root / "dest" / "a.txt").read_text(encoding="utf-8"), "A") + self.assertEqual((self.root / "dest" / "b.txt").read_text(encoding="utf-8"), "B") + + def test_copy_single_directory_success(self) -> None: + src = self.root / "photos" + (src / "nested").mkdir(parents=True) + (src / "cover.jpg").write_text("img", encoding="utf-8") + (src / "nested" / "a.txt").write_text("nested", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/photos", "destination": "storage1/photos-copy"}, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 1) + self.assertEqual(detail["total_items"], 1) + self.assertTrue((self.root / "photos-copy").is_dir()) + self.assertEqual((self.root / "photos-copy" / "cover.jpg").read_text(encoding="utf-8"), "img") + self.assertEqual((self.root / "photos-copy" / "nested" / "a.txt").read_text(encoding="utf-8"), "nested") + + def test_copy_batch_multi_directory_success(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") + (self.root / "dest").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + { + "sources": ["storage1/dir1", "storage1/dir2"], + "destination_base": "storage1/dest", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertEqual((self.root / "dest" / "dir1" / "sub" / "a.txt").read_text(encoding="utf-8"), "A") + self.assertEqual((self.root / "dest" / "dir2" / "b.txt").read_text(encoding="utf-8"), "B") + + def test_copy_batch_mixed_file_and_directory_success(self) -> None: + (self.root / "file.txt").write_text("F", encoding="utf-8") + (self.root / "docs" / "nested").mkdir(parents=True) + (self.root / "docs" / "nested" / "note.txt").write_text("N", encoding="utf-8") + (self.root / "dest").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + { + "sources": ["storage1/file.txt", "storage1/docs"], + "destination_base": "storage1/dest", + }, + ) + + self.assertEqual(response.status_code, 202) + detail = self._wait_task(response.json()["task_id"]) + self.assertEqual(detail["status"], "completed") + self.assertEqual(detail["done_items"], 2) + self.assertEqual(detail["total_items"], 2) + self.assertEqual((self.root / "dest" / "file.txt").read_text(encoding="utf-8"), "F") + self.assertEqual((self.root / "dest" / "docs" / "nested" / "note.txt").read_text(encoding="utf-8"), "N") + def test_copy_source_not_found(self) -> None: response = self._request( "POST", @@ -115,18 +208,6 @@ class CopyApiGoldenTest(unittest.TestCase): }, ) - def test_copy_source_is_directory_type_conflict(self) -> None: - (self.root / "dir").mkdir() - - response = self._request( - "POST", - "/api/files/copy", - {"source": "storage1/dir", "destination": "storage1/out.txt"}, - ) - - self.assertEqual(response.status_code, 409) - self.assertEqual(response.json()["error"]["code"], "type_conflict") - def test_copy_destination_exists_already_exists(self) -> None: (self.root / "source.txt").write_text("x", encoding="utf-8") (self.root / "exists.txt").write_text("y", encoding="utf-8") @@ -149,6 +230,38 @@ class CopyApiGoldenTest(unittest.TestCase): }, ) + def test_copy_directory_destination_exists_already_exists(self) -> None: + (self.root / "src").mkdir() + (self.root / "src" / "a.txt").write_text("x", encoding="utf-8") + (self.root / "exists").mkdir() + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/src", "destination": "storage1/exists"}, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "already_exists") + + def test_copy_batch_destination_exists_already_exists(self) -> None: + (self.root / "a.txt").write_text("A", encoding="utf-8") + (self.root / "dest").mkdir() + (self.root / "dest" / "a.txt").write_text("exists", encoding="utf-8") + (self.root / "b.txt").write_text("B", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + { + "sources": ["storage1/a.txt", "storage1/b.txt"], + "destination_base": "storage1/dest", + }, + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["error"]["code"], "already_exists") + def test_copy_traversal_source(self) -> None: response = self._request( "POST", @@ -171,6 +284,31 @@ class CopyApiGoldenTest(unittest.TestCase): self.assertEqual(response.status_code, 403) self.assertEqual(response.json()["error"]["code"], "path_traversal_detected") + def test_copy_invalid_root_alias(self) -> None: + (self.root / "source.txt").write_text("x", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/source.txt", "destination": "unknown/out.txt"}, + ) + + self.assertEqual(response.status_code, 403) + self.assertEqual(response.json()["error"]["code"], "invalid_root_alias") + + def test_copy_destination_inside_directory_source_blocked(self) -> None: + (self.root / "src").mkdir() + (self.root / "src" / "a.txt").write_text("x", encoding="utf-8") + + response = self._request( + "POST", + "/api/files/copy", + {"source": "storage1/src", "destination": "storage1/src/child"}, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.json()["error"]["code"], "invalid_request") + def test_copy_source_symlink_rejected(self) -> None: target = self.root / "real.txt" target.write_text("x", encoding="utf-8") diff --git a/webui/backend/tests/golden/test_ui_smoke_golden.py b/webui/backend/tests/golden/test_ui_smoke_golden.py index 710a638..7931bdc 100644 --- a/webui/backend/tests/golden/test_ui_smoke_golden.py +++ b/webui/backend/tests/golden/test_ui_smoke_golden.py @@ -223,6 +223,8 @@ class UiSmokeGoldenTest(unittest.TestCase): self.assertIn('closeContextMenu();', app_js) self.assertIn('elements.renameButton.classList.toggle("hidden", isMulti);', app_js) self.assertIn('elements.copyButton.classList.remove("hidden");', app_js) + self.assertIn('const allFiles = items.length > 0 && items.every((item) => item.kind === "file");', app_js) + self.assertIn('elements.copyButton.disabled = !allFiles;', app_js) self.assertIn('elements.moveButton.classList.remove("hidden");', app_js) self.assertIn('openRenamePopup();', app_js) self.assertIn('startCopySelected();', app_js) diff --git a/webui/html/app.js b/webui/html/app.js index 5dd5684..507203c 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -368,10 +368,12 @@ function openContextMenu(pane, entry, event) { contextMenuState.anchorPath = entry.path; const isMulti = items.length > 1; + const allFiles = items.length > 0 && items.every((item) => item.kind === "file"); elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name; elements.renameButton.classList.toggle("hidden", isMulti); elements.copyButton.classList.remove("hidden"); + elements.copyButton.disabled = !allFiles; elements.moveButton.classList.remove("hidden"); elements.deleteButton.classList.remove("hidden"); @@ -433,6 +435,9 @@ function startContextMenuMove() { } function startContextMenuCopy() { + if (contextMenuElements().copyButton?.disabled) { + return; + } if (!applyContextMenuSelection()) { closeContextMenu(); return;