From 4062cbf6c8a69625ddd7e77131e16eea69e9c6b8 Mon Sep 17 00:00:00 2001 From: kodi Date: Fri, 27 Mar 2026 11:39:26 +0100 Subject: [PATCH] Add Phase 2 remote browse scaffolding for /Clients --- container/Containerfile | 2 +- finder_commander/app/main.py | 108 +++++++++- .../app/__pycache__/config.cpython-313.pyc | Bin 3217 -> 3437 bytes .../__pycache__/dependencies.cpython-313.pyc | Bin 8211 -> 8978 bytes webui/backend/app/config.py | 2 + .../app/db/remote_client_repository.py | 14 ++ webui/backend/app/dependencies.py | 17 +- .../browse_service.cpython-313.pyc | Bin 2630 -> 3148 bytes webui/backend/app/services/browse_service.py | 12 +- .../app/services/remote_browse_service.py | 201 ++++++++++++++++++ .../app/services/remote_client_service.py | 33 ++- webui/backend/data/tasks.db | Bin 421888 -> 421888 bytes .../test_api_browse_golden.cpython-313.pyc | Bin 8387 -> 14935 bytes .../tests/golden/test_api_browse_golden.py | 172 ++++++++++++++- webui/html/app.js | 105 +++++++-- 15 files changed, 635 insertions(+), 31 deletions(-) create mode 100644 webui/backend/app/services/remote_browse_service.py diff --git a/container/Containerfile b/container/Containerfile index 6dbe5b1..9349438 100644 --- a/container/Containerfile +++ b/container/Containerfile @@ -18,7 +18,7 @@ RUN mkdir -p /app/backend /app/html /app/conf /Volumes/8TB /Volumes/8TB_RAID1 # Installeer een lichtgewicht Python API framework (FastAPI) # We gebruiken --break-system-packages omdat we in een container zitten -RUN pip3 install fastapi uvicorn python-multipart --break-system-packages +RUN pip3 install fastapi uvicorn python-multipart httpx --break-system-packages # Exposeer de poort voor de webinterface EXPOSE 8030 diff --git a/finder_commander/app/main.py b/finder_commander/app/main.py index e7ed3a8..43c0a28 100644 --- a/finder_commander/app/main.py +++ b/finder_commander/app/main.py @@ -2,6 +2,7 @@ from __future__ import annotations import fnmatch import html +import json import mimetypes import os import secrets @@ -9,6 +10,7 @@ import shutil import stat import time from datetime import datetime +from functools import lru_cache from pathlib import Path from typing import Literal, Optional @@ -94,6 +96,97 @@ def _now_iso() -> str: return datetime.utcnow().isoformat(timespec="seconds") + "Z" +@lru_cache(maxsize=1) +def remote_agent_config() -> dict: + config_path = os.getenv("FINDER_COMMANDER_REMOTE_AGENT_CONFIG", "").strip() + if not config_path: + return {} + try: + return json.loads(Path(config_path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + + +def remote_agent_access_token() -> str: + return os.getenv("FINDER_COMMANDER_AGENT_ACCESS_TOKEN", "").strip() or str( + remote_agent_config().get("agent_access_token", "") + ).strip() + + +def remote_agent_shares() -> dict[str, str]: + raw = remote_agent_config().get("shares", {}) + if not isinstance(raw, dict): + return {} + normalized: dict[str, str] = {} + for key, value in raw.items(): + share_key = str(key).strip() + share_root = str(value).strip() + if share_key and share_root: + normalized[share_key] = share_root + return normalized + + +def require_remote_agent_auth(request: Request) -> None: + expected_token = remote_agent_access_token() + if not expected_token: + return + authorization = request.headers.get("authorization", "").strip() + if authorization != f"Bearer {expected_token}": + raise HTTPException(status_code=403, detail="Invalid agent token") + + +def share_root(share: str) -> Path: + shares = remote_agent_shares() + normalized_share = (share or "").strip() + if normalized_share not in shares: + raise HTTPException(status_code=404, detail="Share not found") + return Path(shares[normalized_share]).expanduser().resolve(strict=False) + + +def ensure_within_share(root: Path, candidate: Path) -> Path: + try: + candidate.relative_to(root) + except ValueError as exc: + raise HTTPException(status_code=403, detail="Path escapes share root") from exc + return candidate + + +def resolve_share_path(share: str, raw_path: str, *, must_exist: bool = True) -> Path: + root = share_root(share) + normalized_raw_path = (raw_path or "").strip().replace("\\", "/") + if normalized_raw_path.startswith("/") or any(part == ".." for part in normalized_raw_path.split("/")): + raise HTTPException(status_code=400, detail="Invalid share-relative path") + candidate = (root / normalized_raw_path).resolve(strict=False) + candidate = ensure_within_share(root, candidate) + if must_exist and not candidate.exists(): + raise HTTPException(status_code=404, detail="Path not found") + return candidate + + +def remote_entry_payload(path: Path) -> dict: + st = path.lstat() + return { + "name": path.name, + "kind": "directory" if path.is_dir() else "file", + "size": st.st_size, + "modified": datetime.fromtimestamp(st.st_mtime).isoformat(timespec="seconds"), + } + + +def sorted_share_entries(path: Path, show_hidden: bool = False) -> list[dict]: + try: + children = list(path.iterdir()) + except PermissionError as exc: + raise HTTPException(status_code=403, detail="Permission denied by operating system") from exc + filtered = [] + for child in children: + if not show_hidden and child.name.startswith("."): + continue + filtered.append(child) + filtered.sort(key=lambda p: (not p.is_dir(), p.name.lower())) + return [remote_entry_payload(child) for child in filtered] + + def rel_from_home(path: Path) -> str: return "" if path == HOME_ROOT else str(path.relative_to(HOME_ROOT)) @@ -314,7 +407,8 @@ async def harden_headers(request: Request, call_next): @app.get("/health") -def health() -> dict: +def health(request: Request) -> dict: + require_remote_agent_auth(request) return {"ok": True, "app": APP_NAME, "time": _now_iso(), "home": str(HOME_ROOT)} @@ -332,7 +426,17 @@ def index(request: Request): @app.get("/api/list") -def api_list(path: str = "", show_hidden: bool = False) -> dict: +def api_list(request: Request, path: str = "", share: str = "", show_hidden: bool = False) -> dict: + if share.strip(): + require_remote_agent_auth(request) + target = resolve_share_path(share, path) + if not target.is_dir(): + raise HTTPException(status_code=400, detail="Path is not a directory") + return { + "share": share.strip(), + "path": path.strip().replace("\\", "/").strip("/"), + "entries": sorted_share_entries(target, show_hidden=show_hidden), + } target = resolve_user_path(path) if not target.is_dir(): raise HTTPException(status_code=400, detail="Path is not a directory") diff --git a/webui/backend/app/__pycache__/config.cpython-313.pyc b/webui/backend/app/__pycache__/config.cpython-313.pyc index 61a6cae479766af0c3b778528157edac5c21e485..c452a9baef40bc2077f74e6e851ed1aa26b1b1fe 100644 GIT binary patch delta 546 zcmXw0L2DC16rP#QX0zGdHX%h46PvDVvYlE(2t#hW*Ulqg=E4ftTb_uj{Q-@JJ<$o)EPObsK6gf%wb z`+o<y&3kyht06N%>u}f z*2;qDnnsG_3zgk2-|p?WEmqlO_d9MN8u;<+coklgvNnn>A6xcO%;x!n zBjOWWfNOGAt_qnP+>u}8^CD_Rs{c(FfrJpAem=;>8G)y9Q<`&BNGVpunZKl1$V9iJ zS!SjnS4L?a-5_HMUlTcau4>BW%^S5k zRp706Y5Z&D~m&<(^6C-FOYA}YZ2-nyMe&GjVkr1@BI1Y0OftbkL#b03)25Ygi z_ZJA-h=r9!3V(q@@OgU@=>f<()f z4p#XaZi(~sv{J+*8|GdA@)BRf3NPbGloRh0705pnwYFH8r+U`w^!$bgyAyFyV@eF-8R{l zI-6XkYp{&FsT+MsNr$W0P0gWeTKJPXE=V2dWpD^438n}#1X;W>*Q^rJ6@oIpnv2}U zPxGKKUcqh!krxJ0)b6%I*i)KSm0<>}R&0Ey4O<^E1AV)=bFj{y*6l-0dlp=w6hj?? zs1IErPo;o;8ekB&hNkB=`>Ggt9;lTWllBb b+L1Y(%Mnu$YfQ|J1YL7jcV>(%*T-$Y<-}*S diff --git a/webui/backend/app/__pycache__/dependencies.cpython-313.pyc b/webui/backend/app/__pycache__/dependencies.cpython-313.pyc index e016732242a18e9af920d16dce542f4b785d6775..9ef1b3c15d3ee34f3cf6a013fa1c683459f36034 100644 GIT binary patch delta 2988 zcma)8OKej|6n!uDd$!{QJ8_)EcI+h1kC#8>9}?mOLVge^Qz%Liu1Q`#&BthLr$Q}q zn=ZO23t$Qo>IbNVN-K4NkgBRu7geRwRh1}G)4CB=rFKzu0|gc=PtJW?0A@)n=Qa|?d2cla@-ep6pKIJu4xnFL?G_5meE$)S|*${_yyu2 zC0!zEBVM=gy0Hzrz(d<~Ts!e)aP2y-gOp})9XhU)lx1+8z{RxzyGVHk*`@2)P5c>L zw~pIGDl)h|I|hR85!=4hCVkX?X0Mmj=9$!^{_O2$YF zM`JUs%E3GLvM!7A01dwpQQ2wZU!tE4OT$t%>_;hEvpCo->9AabyHmxUN+sS}fK~8D ztYF?2{ z(i+xinef*GuNbHazNi^ZBbtmbag$ft1+7i&hN(i_-p%s-ZvEyGsTHU;zM7cXp6oiV z9}(BGeMD$X6wRp7U_^~Z=ayz7L8_h!N9LkodX}~Wy9c}NMzei3cn`LRWM5ePQYTRT zFdJONW6RYpAG%_KC?=QUWT}ECnRY-48%`q{ip&RTY-vfQv=>^3*xl?}i1BasT|cHP zVWMgzys)e$SPvM3^-Q z!m@bTFfQc?MC>wD3T9zk?l4S?O4hhyNMxT0MQ4LEu@If2n2Usys*B;M8a&O96XcC9 z&rPa|k>&wQ@l5e0CS16j=lW_3p9WVvW{G@s&5TEffjACtqyfy|a0)lA#hZ@ubw|af zLs@s!ZC3WKSN3iA`W~2!Ir4qMD9I0Gp)?>qiR3_!m8xd(GqEw}mZ_Q9bC>J*HueAk z6D@?VXA|AF7wmSfPds&JHCGXLbc1U`PVGkzGui4}_zL+>;{GIi^N4(7C2YeT&=v`( zicQ<<#A$ZP_I))5qP5^WZ9NX`O}kr+u`71J<)IP0Ry)} z#Lt_O?r^cQc^(OKd6r$x`?~HJAVs1DFVgd^e6KVCjw|ew{1)*9`^Fxxr>OwW7YT6A zQ6`-P$M@N%j;#TTUnIag1s>ZeaQpzBUqN$%_2j(D4%_9yLHvm+0adXh=7~W4&}Sch zCJOio3y0?yLv&s{v-CBfK4vdD{UTve=iw3_DvK6jfNfjyyVE6|2IdBP>O6#kQ|0UZf9`H^A zN|RlW@tANlo7p61+{oplMw1U?S}igMPUMAV za2}x;Xv?e`no?;-Q)F^hT~rfxomVtGuP!NavU<2+UKA&vC`~C$GJZU$>ye6^>7jQ> zjpEA)p?Bb8W(X{fJEWPf07L>{xIDS4KJ>-k9^SP2($e&c+rQx+SaT0N1FrH7S76N* zcwmuGwIoUY)z|kVVuL6v-hPXCP3$+BukQpju4IAosL>c*qDU)QGzMREhYq9CY3*21 zF#i}RBG>`HT?=Gwxgf!|ZC^1MtZB_C@R z9Yb>j%~3SR(M+I08Kfv>Tz4pH2}NC?c(M6uP9Ck)y@w1WtE0JRF+3GpQ1{Xc;D~>? jA}j2IZ-o8qdn8Gs{K(E$OS^23%(5sy&NqoN|4;EheW!wj delta 2372 zcma)7O-y4|6z+q**HWPTLtENH-!L%cZ@|JpfdPi!qL~@=4o(nF?UV;pN?UJR5;K|9 z%wpmW^<)xFOq>`OW8!9H<67g!g-+rmCS91A%*Kt00bLr;x#cnMVHY&%ec!v^Ip>~x z{@%w+FzY<3t#w%Nx1anW@wDwDr%U?gNzbTMc(YQqFb``FODx2^9?Lt{Q4+%+4;i=- z<}-0425yx3P28w~yTUq5+!X^i#yUL(HgFT{f{B|jaFeXd z#7(lP0=r0Ce2QIV-2z=@)2v6JX*Rz8x&}fEwRf2EwN=bB+xR8uwjAZ$o5OYmBZtThY~!pK9d5Fmm~5;48I?~lkX_q zl!z|qRlJ@c!Y=%0t8sbX^1RynOSS*0)z7=(ZRLKAN|c04C(8e+xaC2_L8!4^kh)>W z_J-O=@0Hw!4{oPrfEJV5*EXL#gu8?AQ(3jD@@gr&R}LXH0tfbTr;)>DPP<&?tD|C@ zAbOaV|2s-GTSoQQDpejsY8L*jq7zler(qw7(xo{?ijzb95-p_|A}ZExYq@AHmfel= zd@7}JK83qWaO~(s8>=nN5UM^@DJ(@B*Rtv4zE)H+F|MU@<~Z4FI^;PNy9xa@Q$lqk z^i{=;U`5f9iRE^p+xZyZ#ABpW)1&GkZkkWkE{sqxBK8p z?f%OmFq60uwDMMb7_5}LT{GQoE0NTq=}b17)%boQuJKn;asBXJXHas65f+t`t53q? zy8e*xO9YKVQ3{$kO2km6J-VndkYKCtlVT96|Gt|Ha^YYl_YxMC&iV%429K11u?AJ< z5tM@8-L3Fp(-!>JP(3P4TtBrV^G3qu+%g8v-`Flw&~h-{_-)@UL_wK1pGPy$ zPS{KDpE3RxsvnSv*J-(pg}XINVZ>UiryUVIYGY&8@UJ#{< zMU^5rNSa}wb#g+OtKe;9-1n(AG6eY5V?`W}|wrOaT%$~6*E|}GFxkPIFMF(go z{v`~%=z_Og2T~ZGyT%na85slBece1d@47qWUF06a=kB=z^|ZpZT%Mxb`or5(Ssza(a3-E6%?+=11|<*N@O$84XM;jXDh43a z(c%=tR__Wg{M_j;h&lbDD8$GR)hY^2 zs$@=8$f=At<(*S1O?`luE!wB{(wq6DHq9R(O&`tdUHBld0?z_}DU!4y9oykYHSGA? Ku1Jdbe*6bJC(Y;p diff --git a/webui/backend/app/config.py b/webui/backend/app/config.py index a3c8f8e..192ed0f 100644 --- a/webui/backend/app/config.py +++ b/webui/backend/app/config.py @@ -13,6 +13,7 @@ class Settings: remote_client_offline_timeout_seconds: int remote_client_agent_auth_header: str remote_client_agent_auth_scheme: str + remote_client_agent_auth_token: str DEFAULT_ROOT_ALIASES = { @@ -57,4 +58,5 @@ def get_settings() -> Settings: remote_client_agent_auth_header=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_HEADER", "Authorization").strip() or "Authorization", remote_client_agent_auth_scheme=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_SCHEME", "Bearer").strip() or "Bearer", + remote_client_agent_auth_token=os.getenv("WEBMANAGER_REMOTE_CLIENT_AGENT_AUTH_TOKEN", "").strip(), ) diff --git a/webui/backend/app/db/remote_client_repository.py b/webui/backend/app/db/remote_client_repository.py index b2bad1d..4b14585 100644 --- a/webui/backend/app/db/remote_client_repository.py +++ b/webui/backend/app/db/remote_client_repository.py @@ -97,6 +97,20 @@ class RemoteClientRepository: ).fetchall() return [self._to_dict(row) for row in rows] + def get_client(self, client_id: str) -> dict | None: + with self._connection() as conn: + row = conn.execute( + """ + SELECT * + FROM remote_clients + WHERE client_id = ? + """, + (client_id,), + ).fetchone() + if row is None: + return None + return self._to_dict(row) + def _ensure_schema(self) -> None: db_path = Path(self._db_path) if db_path.parent and str(db_path.parent) not in {"", "."}: diff --git a/webui/backend/app/dependencies.py b/webui/backend/app/dependencies.py index 41aafd7..a670ebc 100644 --- a/webui/backend/app/dependencies.py +++ b/webui/backend/app/dependencies.py @@ -20,6 +20,7 @@ from backend.app.services.duplicate_task_service import DuplicateTaskService 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 +from backend.app.services.remote_browse_service import RemoteBrowseService from backend.app.services.remote_client_service import RemoteClientService from backend.app.services.search_service import SearchService from backend.app.services.settings_service import SettingsService @@ -83,7 +84,11 @@ def get_archive_artifact_root() -> str: async def get_browse_service() -> BrowseService: - return BrowseService(path_guard=get_path_guard(), filesystem=get_filesystem_adapter()) + return BrowseService( + path_guard=get_path_guard(), + filesystem=get_filesystem_adapter(), + remote_browse_service=await get_remote_browse_service(), + ) async def get_file_ops_service() -> FileOpsService: @@ -172,3 +177,13 @@ async def get_remote_client_service() -> RemoteClientService: registration_token=settings.remote_client_registration_token, offline_timeout_seconds=settings.remote_client_offline_timeout_seconds, ) + + +async def get_remote_browse_service() -> RemoteBrowseService: + settings: Settings = get_settings() + return RemoteBrowseService( + remote_client_service=await get_remote_client_service(), + agent_auth_header=settings.remote_client_agent_auth_header, + agent_auth_scheme=settings.remote_client_agent_auth_scheme, + agent_auth_token=settings.remote_client_agent_auth_token, + ) diff --git a/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc b/webui/backend/app/services/__pycache__/browse_service.cpython-313.pyc index 69bc2e855780adbc2a1dbb137ad9ae2b56cb3d21..59904a17152cc289f2a7acbd8755e51766ddcc64 100644 GIT binary patch delta 1537 zcma)6O>7%Q6rS0gU9YpYL$Y>$>?So$V!E;F6e*D~ZIRrAD^h9^Pa`D|$!zS!acg_6 zS*KB|qHuy6QW*8XkpmK3;et4V=&6TTP&rx&=z$A2i>UO`OV!dr*0T$!hcD%VF=b*3lL6gr2f8b>s+B7rJGEL7FZ z;NiXUfr&k%Xg8d;eU&+Tu1#%rr&hMheZ4-){RyAECWQNF3o%R)lPG5LtRS96!ygS$ zIgW0TTbL25Q35hksKV5$WCp$P3oiZ$3bv_2ne^Fxgz$tnd~%)_@XWQ1i}gXk2vyNn zxM0ejUTRtGolNv3gn7Z^x zo8xgQkzyi)`)C~%h1$Qz`N4*j<=4qHFG?vq?Mt6ZMrdQ4;;b|erYc_}1`hHy+29#D z$Q@}E`+GbtcD|4j*TO^Ub(u_Zt)I82?K)$ht%|{((Fb`>(rUkkg=y<3N#?Y0wWvDV`ZR{AZ>kSy=?B z`#~yvk}Sl|4o{yYaSH3&|6vUcO3-HyXNf4l>YxDH^95K}ib?((iQxc$Lgp?+3Z~={ zXem#3cbvV_POVb0n;vOe4VxuId>%4oHX{U<;IArioZxRM)9*1z(9^7K*QxKeZE=ml zm73eCTl*!qy7*_e0< z6e9JS+b&gV%r3Vbw$IM-q?*KYJg3Gc#(@oj?1w{Y;vTRhud9m(FN<9lt{618KN$2a zO9}9zFWkgT-)R&qe#g+;$NFejPl^AR%iVOolg@Y3w3DVsH#fTJ%}#pr*qH1ZnU0a^ z8Y>-R<=f>WW97)$JPK_-2FpZ8|K?Zqb9?}l-v<);Me@UPPR}oqAC=r_ewm1hLT(J& z(k}yx2+35L0UH_sn6dysOu*gvDUVe0Ub5gcZDHnrC)sVYK^?Xor|zkY|D;`2CxIiy z6Tz8{X%PJ?=pX?=h1?|pAEDj|x{|}apr%}Y9G&SYV(zIZmg)sS(opjCCvf&Ox}a=f z=5qlJneGBsqEJ6H&{*_ bh>wBXIX*iw8T|#VK2*!V$ delta 1178 zcmah}OK2295bf#i`MAGC^P6N%GDc&ZC`J&4MGgJ}QE&;{tON`!o856UGTB|aH->o8 zlX?&&Y4qw<1P|g#!Ha?yJu1i?JPKYEjF5vDyJj;+6toZ3Rb5rDs%u`)Tl;l)W<8xw zAhrB!PyB=CYR1-PR>w}*HX1=pA3)4Fg29atPt;7;TD@JlUTGh+<)&9#->2+*Xby3W zA&!~G2{RZTKvyWI%w#0ebVj*R(_Je}pXQBa;W3Y2^()?IOJ!HcZ@_@7Y;ApKgge_A z#Lh!#)(N!Osv%d)yEsgin&s9)skT(+Rb^cE15d1ombd5<`AHkf2mp5+C)__#ZZ_o< zKG(NRorsJQH_(Ud=sU|+^1Wrt$2hBhRQ2!4uXs{EAcj0l#^hU)J#EHSi-c*qGSrS? zXN1ulnZXx&5lSKl)oGnv0{mz(y+|k2D05_wg0$r`(k1VZ#PzyfM1@owwY3*Iw)9vl z>V?i&-`b0qnK`_qnr+sKMi;tB1fDFzlxO90b9@-cE`T?RsqMvgK#VaT zy_PkrHohrS#?m7E%-ua2GSm>Q=p=FkdWi z@$Ddh8Lkoa5fahjd{8bY@`^*scR;>L3?A(P70Pq?Lqr171pwYe-!azrZdfDwoW`R_ y2+F%jc`|v+ZD$;Xv|lKpb$qc=T?)KOeoQ&w1q(;+O7(5uFvhew?Hd9a#s37=YtOX+ diff --git a/webui/backend/app/services/browse_service.py b/webui/backend/app/services/browse_service.py index e516208..23c24e0 100644 --- a/webui/backend/app/services/browse_service.py +++ b/webui/backend/app/services/browse_service.py @@ -3,14 +3,24 @@ from __future__ import annotations from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry from backend.app.fs.filesystem_adapter import FilesystemAdapter from backend.app.security.path_guard import PathGuard +from backend.app.services.remote_browse_service import RemoteBrowseService class BrowseService: - def __init__(self, path_guard: PathGuard, filesystem: FilesystemAdapter): + def __init__( + self, + path_guard: PathGuard, + filesystem: FilesystemAdapter, + remote_browse_service: RemoteBrowseService | None = None, + ): self._path_guard = path_guard self._filesystem = filesystem + self._remote_browse_service = remote_browse_service def browse(self, path: str, show_hidden: bool) -> BrowseResponse: + if self._remote_browse_service and self._remote_browse_service.handles_path(path): + return self._remote_browse_service.browse(path=path, show_hidden=show_hidden) + if self._path_guard.is_virtual_volumes_path(path): directories = [ DirectoryEntry(name=item["name"], path=item["path"], modified="") diff --git a/webui/backend/app/services/remote_browse_service.py b/webui/backend/app/services/remote_browse_service.py new file mode 100644 index 0000000..ac6f3e8 --- /dev/null +++ b/webui/backend/app/services/remote_browse_service.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from urllib.parse import urlencode + +import httpx + +from backend.app.api.errors import AppError +from backend.app.api.schemas import BrowseResponse, DirectoryEntry, FileEntry, RemoteClientItem +from backend.app.services.remote_client_service import RemoteClientService + + +class RemoteBrowseService: + ROOT_PATH = "/Clients" + + def __init__( + self, + remote_client_service: RemoteClientService, + agent_auth_header: str, + agent_auth_scheme: str, + agent_auth_token: str, + agent_timeout_seconds: float = 2.0, + ): + self._remote_client_service = remote_client_service + self._agent_auth_header = (agent_auth_header or "Authorization").strip() or "Authorization" + self._agent_auth_scheme = (agent_auth_scheme or "Bearer").strip() or "Bearer" + self._agent_auth_token = (agent_auth_token or "").strip() + self._agent_timeout_seconds = max(0.1, float(agent_timeout_seconds)) + + @classmethod + def handles_path(cls, path: str) -> bool: + normalized = (path or "").strip() + return normalized == cls.ROOT_PATH or normalized.startswith(f"{cls.ROOT_PATH}/") + + def browse(self, path: str, show_hidden: bool) -> BrowseResponse: + parts = self._path_parts(path) + if not parts: + return self._browse_clients_root() + if len(parts) == 1: + return self._browse_client(parts[0]) + return self._browse_remote_share(parts[0], parts[1], parts[2:], show_hidden) + + @classmethod + def _path_parts(cls, path: str) -> list[str]: + normalized = (path or "").strip().rstrip("/") + if normalized == cls.ROOT_PATH: + return [] + return normalized[len(cls.ROOT_PATH) + 1 :].split("/") + + def _browse_clients_root(self) -> BrowseResponse: + clients = self._remote_client_service.list_clients().items + directories = [ + DirectoryEntry( + name=client.display_name, + path=f"{self.ROOT_PATH}/{client.client_id}", + modified=client.last_seen or client.updated_at, + ) + for client in clients + ] + return BrowseResponse(path=self.ROOT_PATH, directories=directories, files=[]) + + def _browse_client(self, client_id: str) -> BrowseResponse: + client = self._remote_client_service.get_client(client_id) + directories = [ + DirectoryEntry( + name=share.label, + path=f"{self.ROOT_PATH}/{client.client_id}/{share.key}", + modified=client.last_seen or client.updated_at, + ) + for share in client.shares + ] + return BrowseResponse(path=f"{self.ROOT_PATH}/{client.client_id}", directories=directories, files=[]) + + def _browse_remote_share( + self, + client_id: str, + share_key: str, + relative_parts: list[str], + show_hidden: bool, + ) -> BrowseResponse: + client = self._remote_client_service.get_client(client_id) + if client.status != "online": + raise AppError( + code="remote_client_unavailable", + message=f"Remote client '{client.display_name}' is offline", + status_code=503, + details={"client_id": client.client_id, "status": client.status}, + ) + share = next((item for item in client.shares if item.key == share_key), None) + if share is None: + raise AppError( + code="path_not_found", + message="Remote share was not found", + status_code=404, + details={"client_id": client.client_id, "share_key": share_key}, + ) + if not self._agent_auth_token: + raise AppError( + code="remote_client_agent_auth_not_configured", + message="Remote client agent auth token is not configured", + status_code=503, + details={"client_id": client.client_id}, + ) + + base_path = f"{self.ROOT_PATH}/{client.client_id}/{share.key}" + relative_path = "/".join(relative_parts) + agent_payload = self._fetch_remote_listing(client=client, share_key=share.key, relative_path=relative_path, show_hidden=show_hidden) + + directories: list[DirectoryEntry] = [] + files: list[FileEntry] = [] + for entry in agent_payload.get("entries", []): + if not isinstance(entry, dict): + continue + name = str(entry.get("name", "")).strip() + kind = str(entry.get("kind", "")).strip() + if not name or kind not in {"directory", "file"}: + continue + child_path = f"{base_path}/{name}" + modified = str(entry.get("modified", "") or "") + if kind == "directory": + directories.append(DirectoryEntry(name=name, path=child_path, modified=modified)) + continue + size = entry.get("size", 0) + try: + normalized_size = max(0, int(size)) + except (TypeError, ValueError): + normalized_size = 0 + files.append(FileEntry(name=name, path=child_path, size=normalized_size, modified=modified)) + + response_path = base_path if not relative_path else f"{base_path}/{relative_path}" + return BrowseResponse(path=response_path, directories=directories, files=files) + + def _fetch_remote_listing( + self, + *, + client: RemoteClientItem, + share_key: str, + relative_path: str, + show_hidden: bool, + ) -> dict: + normalized_endpoint = client.endpoint.rstrip("/") + query = urlencode({"share": share_key, "path": relative_path, "show_hidden": str(show_hidden).lower()}) + url = f"{normalized_endpoint}/api/list?{query}" + headers = {self._agent_auth_header: f"{self._agent_auth_scheme} {self._agent_auth_token}"} + timeout = httpx.Timeout(self._agent_timeout_seconds, connect=self._agent_timeout_seconds) + + try: + with httpx.Client(timeout=timeout, headers=headers) as client_http: + response = client_http.get(url) + except httpx.TimeoutException as exc: + raise AppError( + code="remote_client_timeout", + message=f"Remote client '{client.display_name}' timed out", + status_code=504, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) from exc + except httpx.HTTPError as exc: + raise AppError( + code="remote_client_unreachable", + message=f"Remote client '{client.display_name}' is unreachable", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) from exc + + if response.status_code == 404: + raise AppError( + code="path_not_found", + message="Remote path was not found", + status_code=404, + details={"client_id": client.client_id, "share_key": share_key}, + ) + if response.status_code in {401, 403}: + raise AppError( + code="remote_client_forbidden", + message=f"Remote client '{client.display_name}' rejected authentication", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) + if response.status_code >= 400: + raise AppError( + code="remote_client_error", + message=f"Remote client '{client.display_name}' browse failed", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint, "status_code": str(response.status_code)}, + ) + try: + payload = response.json() + except ValueError as exc: + raise AppError( + code="remote_client_error", + message=f"Remote client '{client.display_name}' returned invalid JSON", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) from exc + if not isinstance(payload, dict): + raise AppError( + code="remote_client_error", + message=f"Remote client '{client.display_name}' returned an invalid response", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) + return payload diff --git a/webui/backend/app/services/remote_client_service.py b/webui/backend/app/services/remote_client_service.py index 02237fc..8dc82f3 100644 --- a/webui/backend/app/services/remote_client_service.py +++ b/webui/backend/app/services/remote_client_service.py @@ -27,14 +27,30 @@ class RemoteClientService: self._now = now or (lambda: datetime.now(tz=timezone.utc)) def list_clients(self) -> RemoteClientListResponse: - now = self._now() - self._repository.mark_stale_clients_offline( - cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)), - now_iso=self._to_iso(now), - ) + self._refresh_stale_statuses() items = [RemoteClientItem(**row) for row in self._repository.list_clients()] return RemoteClientListResponse(items=items) + def get_client(self, client_id: str) -> RemoteClientItem: + normalized_client_id = (client_id or "").strip() + if not normalized_client_id: + raise AppError( + code="invalid_request", + message="client_id is required", + status_code=400, + details={"client_id": client_id}, + ) + self._refresh_stale_statuses() + item = self._repository.get_client(normalized_client_id) + if item is None: + raise AppError( + code="path_not_found", + message="Remote client was not found", + status_code=404, + details={"client_id": normalized_client_id}, + ) + return RemoteClientItem(**item) + def register_client(self, authorization: str | None, request: RemoteClientRegisterRequest) -> RemoteClientItem: self._require_registration_auth(authorization) payload = self._normalize_register_request(request) @@ -123,6 +139,13 @@ class RemoteClientService: "shares": shares, } + def _refresh_stale_statuses(self) -> None: + now = self._now() + self._repository.mark_stale_clients_offline( + cutoff_iso=self._to_iso(now - timedelta(seconds=self._offline_timeout_seconds)), + now_iso=self._to_iso(now), + ) + @staticmethod def _to_iso(value: datetime) -> str: return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") diff --git a/webui/backend/data/tasks.db b/webui/backend/data/tasks.db index 62299f763d8103f4c97c27c0f72b5b3b4490747a..8533834373a4513ab88684a9c1f735f8fa5a5704 100644 GIT binary patch delta 131 zcmZp8AldLha)K1oOZ$m3PC&9Tp*4ZAHG!!$fw?t-r8R+dYXaLceU`%ve1|s&3hdfD&`EIs{#9orLO14}Do11lp_Ju_2NV`KBE{Is;`$L!g(_(2jzR>l^3 U76z6^1}5A8+p~St7eH4I0Jpv<$N&HU diff --git a/webui/backend/tests/golden/__pycache__/test_api_browse_golden.cpython-313.pyc b/webui/backend/tests/golden/__pycache__/test_api_browse_golden.cpython-313.pyc index 78bdec6c0e109605c98c5c15da2f4a6735468b07..f5a0f61b27995ff9fb8bf7f61347ade5134f9e57 100644 GIT binary patch literal 14935 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 dict: + if client.client_id in self._failing_client_ids: + raise AppError( + code="remote_client_unreachable", + message=f"Remote client '{client.display_name}' is unreachable", + status_code=502, + details={"client_id": client.client_id, "endpoint": client.endpoint}, + ) + return self._listings[(client.client_id, share_key, relative_path)] class BrowseApiGoldenTest(unittest.TestCase): @@ -36,6 +69,12 @@ class BrowseApiGoldenTest(unittest.TestCase): file_path.write_bytes(b"abc") second_file = self.second_root / "archive.txt" second_file.write_text("z", encoding="utf-8") + remote_root = Path(self.temp_dir.name) / "remote-downloads" + remote_root.mkdir(parents=True, exist_ok=True) + remote_dir = remote_root / "Series" + remote_dir.mkdir() + remote_file = remote_root / "episode.mkv" + remote_file.write_bytes(b"remote") hidden_dir = self.root / ".hidden_dir" hidden_dir.mkdir() @@ -43,15 +82,70 @@ class BrowseApiGoldenTest(unittest.TestCase): hidden_file.write_bytes(b"x") mtime = 1710000000 - for path in [folder, file_path, hidden_dir, hidden_file, second_file]: + for path in [folder, file_path, hidden_dir, hidden_file, second_file, remote_dir, remote_file]: Path(path).touch() Path(path).chmod(0o755) - import os os.utime(path, (mtime, mtime)) + repository = RemoteClientRepository(str(Path(self.temp_dir.name) / "remote-clients.db")) + now_iso = "2026-03-26T12:00:00Z" + repository.upsert_client( + client_id="client-123", + display_name="Jan MacBook", + platform="macos", + agent_version="1.1.0", + endpoint="http://agent.test", + shares=[{"key": "downloads", "label": "Downloads"}], + now_iso=now_iso, + ) + repository.upsert_client( + client_id="broken-client", + display_name="Offline iMac", + platform="macos", + agent_version="1.1.0", + endpoint="http://127.0.0.1:1", + shares=[{"key": "downloads", "label": "Downloads"}], + now_iso=now_iso, + ) + service = BrowseService( path_guard=PathGuard({"storage1": str(self.root), "storage2": str(self.second_root)}), filesystem=FilesystemAdapter(), + remote_browse_service=_StubRemoteBrowseService( + remote_client_service=RemoteClientService( + repository=repository, + registration_token="secret-token", + offline_timeout_seconds=60, + now=lambda: datetime(2026, 3, 26, 12, 0, 0, tzinfo=timezone.utc), + ), + listings={ + ( + "client-123", + "downloads", + "", + ): { + "entries": [ + { + "name": "Series", + "kind": "directory", + "size": remote_dir.stat().st_size, + "modified": datetime.fromtimestamp(remote_dir.stat().st_mtime, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + }, + { + "name": "episode.mkv", + "kind": "file", + "size": remote_file.stat().st_size, + "modified": datetime.fromtimestamp(remote_file.stat().st_mtime, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z"), + }, + ] + } + }, + failing_client_ids={"broken-client"}, + ), ) async def _override_browse_service() -> BrowseService: return service @@ -151,6 +245,80 @@ class BrowseApiGoldenTest(unittest.TestCase): }, ) + def test_browse_virtual_clients_and_remote_share(self) -> None: + clients_response = self._get("/Clients") + self.assertEqual(clients_response.status_code, 200) + self.assertEqual( + clients_response.json(), + { + "path": "/Clients", + "directories": [ + { + "name": "Jan MacBook", + "path": "/Clients/client-123", + "modified": "2026-03-26T12:00:00Z", + }, + { + "name": "Offline iMac", + "path": "/Clients/broken-client", + "modified": "2026-03-26T12:00:00Z", + }, + ], + "files": [], + }, + ) + + shares_response = self._get("/Clients/client-123") + self.assertEqual(shares_response.status_code, 200) + self.assertEqual( + shares_response.json(), + { + "path": "/Clients/client-123", + "directories": [ + { + "name": "Downloads", + "path": "/Clients/client-123/downloads", + "modified": "2026-03-26T12:00:00Z", + } + ], + "files": [], + }, + ) + + browse_response = self._get("/Clients/client-123/downloads") + self.assertEqual(browse_response.status_code, 200) + modified = datetime.fromtimestamp(1710000000, tz=timezone.utc).isoformat().replace("+00:00", "Z") + self.assertEqual( + browse_response.json(), + { + "path": "/Clients/client-123/downloads", + "directories": [ + { + "name": "Series", + "path": "/Clients/client-123/downloads/Series", + "modified": modified, + } + ], + "files": [ + { + "name": "episode.mkv", + "path": "/Clients/client-123/downloads/episode.mkv", + "size": 6, + "modified": modified, + } + ], + }, + ) + + def test_remote_client_failure_stays_local_to_remote_subtree(self) -> None: + broken_response = self._get("/Clients/broken-client/downloads") + self.assertEqual(broken_response.status_code, 502) + self.assertEqual(broken_response.json()["error"]["code"], "remote_client_unreachable") + + volumes_response = self._get("/Volumes") + self.assertEqual(volumes_response.status_code, 200) + self.assertEqual(volumes_response.json()["path"], "/Volumes") + if __name__ == "__main__": unittest.main() diff --git a/webui/html/app.js b/webui/html/app.js index 35e8e29..e24fa8e 100644 --- a/webui/html/app.js +++ b/webui/html/app.js @@ -141,6 +141,10 @@ const VALID_THEME_FAMILIES = [ "fluent-neon", ]; const VALID_COLOR_MODES = ["dark", "light"]; +const VIRTUAL_SOURCES = [ + { path: "/Volumes", label: "Volumes" }, + { path: "/Clients", label: "Clients" }, +]; let searchState = { pane: "left", path: "/Volumes", @@ -200,6 +204,56 @@ function activePaneState() { return paneState(state.activePane); } +function sourceRootForPath(path) { + const normalized = (path || "").trim(); + if (normalized === "/Clients" || normalized.startsWith("/Clients/")) { + return "/Clients"; + } + return "/Volumes"; +} + +function isRemoteBrowsePath(path) { + return sourceRootForPath(path) === "/Clients"; +} + +function syncSourceSwitchers() { + ["left", "right"].forEach((pane) => { + const container = document.getElementById(`${pane}-source-switcher`); + if (!container) { + return; + } + const activeSource = sourceRootForPath(paneState(pane).currentPath); + [...container.querySelectorAll("button[data-source-path]")].forEach((button) => { + const isActive = button.dataset.sourcePath === activeSource; + button.disabled = isActive; + button.setAttribute("aria-pressed", isActive ? "true" : "false"); + }); + }); +} + +function ensureSourceSwitchers() { + ["left", "right"].forEach((pane) => { + const toolbar = document.querySelector(`#${pane}-pane .pane-topbar`); + if (!toolbar || document.getElementById(`${pane}-source-switcher`)) { + return; + } + const container = document.createElement("div"); + container.id = `${pane}-source-switcher`; + container.className = "pane-source-switcher"; + VIRTUAL_SOURCES.forEach((source) => { + const button = createButton(source.label, () => { + setActivePane(pane); + navigateTo(pane, source.path); + }); + button.type = "button"; + button.dataset.sourcePath = source.path; + container.append(button); + }); + toolbar.prepend(container); + }); + syncSourceSwitchers(); +} + function setStatus(msg) { document.getElementById("status").textContent = msg; } @@ -716,6 +770,7 @@ function openContextMenu(pane, entry, event) { const items = selectedPathsSet.has(entry.path) ? selectedItems.map((item) => ({ ...item })) : [selectedEntryFromItem(entry)]; + const remoteSelection = items.some((item) => isRemoteBrowsePath(item.path)); contextMenuState.open = true; contextMenuState.pane = pane; @@ -723,26 +778,28 @@ function openContextMenu(pane, entry, event) { contextMenuState.anchorPath = entry.path; const isMulti = items.length > 1; - const openableSingle = items.length === 1 && isOpenableSelection(items[0]); - const editableSingle = items.length === 1 && isEditableSelection(items[0]); - const downloadableSelection = items.length > 0; + const openableSingle = items.length === 1 && (!remoteSelection || items[0].kind === "directory") && isOpenableSelection(items[0]); + const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]); + const downloadableSelection = items.length > 0 && !remoteSelection; 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.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection); elements.editButton.disabled = !editableSingle; - elements.downloadButton.classList.remove("hidden"); + elements.downloadButton.classList.toggle("hidden", remoteSelection); elements.downloadButton.disabled = !downloadableSelection; - elements.renameButton.classList.toggle("hidden", isMulti); + elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection); elements.duplicateButton.classList.remove("hidden"); - elements.duplicateButton.disabled = items.length === 0; + elements.duplicateButton.disabled = remoteSelection || items.length === 0; elements.copyButton.classList.remove("hidden"); - elements.copyButton.disabled = items.length === 0; + elements.copyButton.disabled = remoteSelection || items.length === 0; elements.moveButton.classList.remove("hidden"); + elements.moveButton.disabled = remoteSelection || items.length === 0; elements.deleteButton.classList.remove("hidden"); - elements.propertiesButton.classList.remove("hidden"); - elements.propertiesButton.disabled = items.length === 0; + elements.deleteButton.disabled = remoteSelection || items.length === 0; + elements.propertiesButton.classList.toggle("hidden", remoteSelection); + elements.propertiesButton.disabled = remoteSelection || items.length === 0; const menuWidth = 220; const menuHeight = 120; @@ -2050,12 +2107,17 @@ function updateActionButtons() { const hasSelection = count > 0; const exactlyOne = count === 1; const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); - document.getElementById("view-btn").disabled = !exactlyOne || !allFiles; - document.getElementById("edit-btn").disabled = !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null); - document.getElementById("rename-btn").disabled = !exactlyOne; - document.getElementById("delete-btn").disabled = !hasSelection; - document.getElementById("copy-btn").disabled = !hasSelection; - document.getElementById("move-btn").disabled = !hasSelection; + const remoteBrowse = isRemoteBrowsePath(activePaneState().currentPath); + document.getElementById("view-btn").disabled = remoteBrowse || !exactlyOne || !allFiles; + document.getElementById("edit-btn").disabled = remoteBrowse || !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null); + document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne; + document.getElementById("delete-btn").disabled = remoteBrowse || !hasSelection; + document.getElementById("copy-btn").disabled = remoteBrowse || !hasSelection; + document.getElementById("move-btn").disabled = remoteBrowse || !hasSelection; + document.getElementById("mkdir-btn").disabled = remoteBrowse; + document.getElementById("upload-btn").disabled = remoteBrowse; + document.getElementById("upload-menu-toggle").disabled = remoteBrowse; + document.getElementById("upload-folder-btn").disabled = remoteBrowse; } function isEditableSelection(item) { @@ -2208,7 +2270,7 @@ function currentParentPath(path) { if (!normalized) { return null; } - if (normalized === "/Volumes") { + if (normalized === "/Volumes" || normalized === "/Clients") { return null; } if (normalized.startsWith("/")) { @@ -2287,16 +2349,17 @@ function renderBreadcrumbs(pane, path) { const isHostPath = normalized.startsWith("/"); const parts = normalized.split("/").filter(Boolean); if (isHostPath) { + const rootTarget = parts.length > 0 ? `/${parts[0]}` : "/Volumes"; const rootCrumb = createButton("/", () => { setActivePane(pane); - navigateTo(pane, "/Volumes"); + navigateTo(pane, rootTarget); }); rootCrumb.type = "button"; rootCrumb.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); setActivePane(pane); - navigateTo(pane, "/Volumes"); + navigateTo(pane, rootTarget); }; nav.append(rootCrumb); if (parts.length > 0) { @@ -2619,6 +2682,7 @@ async function loadBrowsePane(pane) { }); const data = await apiRequest("GET", `/api/browse?${query.toString()}`); model.currentPath = data.path; + syncSourceSwitchers(); renderBreadcrumbs(pane, data.path); const visibleItems = []; @@ -2682,6 +2746,8 @@ function navigateTo(pane, path) { model.currentRowIndex = 0; clearSelectionAnchor(pane); setSelectedItem(pane, null); + syncSourceSwitchers(); + updateActionButtons(); loadBrowsePane(pane); } @@ -5305,6 +5371,7 @@ async function init() { setError("actions-error", ""); applyTheme("default", "dark"); setActivePane("left"); + ensureSourceSwitchers(); setupEvents(); await loadSettings(); applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode);