From fbaf4a9330abc8ac5899bc82936f025f3c027ffb Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 17 Mar 2026 16:36:15 -0400 Subject: [PATCH 1/8] fix: use session.merge in update_proxied_path so mutations are not silently dropped --- fileglancer/database.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/fileglancer/database.py b/fileglancer/database.py index f1f88592..8c66d205 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -628,6 +628,10 @@ def update_proxied_path(session: Session, if username != proxied_path.username: raise ValueError(f"Proxied path with sharing key {sharing_key} not found for user {username}") + # Merge into current session in case the object came from the cache + # (detached objects from a previous session won't persist changes on commit) + proxied_path = session.merge(proxied_path) + if new_sharing_name: proxied_path.sharing_name = new_sharing_name From 5e64d35fb997a1382d8e1a7173f89ac42182df1c Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 17 Mar 2026 16:38:30 -0400 Subject: [PATCH 2/8] feat: decouple proxy URL from sharing_name, use path basename for stable links - this allows the URL to remain stable when the user renames the display name --- fileglancer/server.py | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/fileglancer/server.py b/fileglancer/server.py index 354f3ddf..4e723763 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -95,7 +95,7 @@ def _convert_external_bucket(db_bucket: db.ExternalBucketDB) -> ExternalBucket: def _convert_proxied_path(db_path: db.ProxiedPathDB, external_proxy_url: Optional[HttpUrl]) -> ProxiedPath: """Convert a database ProxiedPathDB model to a Pydantic ProxiedPath model""" if external_proxy_url: - url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(db_path.sharing_name)}" + url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(os.path.basename(db_path.path))}" else: logger.warning(f"No external proxy URL was provided, proxy links will not be available.") url = None @@ -199,29 +199,30 @@ def _get_user_context(username: str) -> UserContext: return CurrentUserContext() - def _get_file_proxy_client(sharing_key: str, sharing_name: str) -> Tuple[FileProxyClient, UserContext] | Tuple[Response, None]: + def _get_file_proxy_client(sharing_key: str, path_basename: str) -> Tuple[FileProxyClient, UserContext] | Tuple[Response, None]: with db.get_db_session(settings.db_url) as session: proxied_path = db.get_proxied_path_by_sharing_key(session, sharing_key) if not proxied_path: - return get_nosuchbucket_response(sharing_name), None + return get_nosuchbucket_response(path_basename), None # Vol-E viewer sends URLs with literal % characters (not URL-encoded) - # FastAPI automatically decodes path parameters - % chars are treated as escapes, creating a garbled sharing_name if they're present + # FastAPI automatically decodes path parameters - % chars are treated as escapes, creating a garbled path_basename if they're present # We therefore need to handle two cases: - # 1. Properly encoded requests (sharing_name matches DB value of proxied_path.sharing_name) - # 2. Vol-E's unencoded requests (unquote(proxied_path.sharing_name) matches the garbled request value) - if proxied_path.sharing_name != sharing_name and unquote(proxied_path.sharing_name) != sharing_name: - return get_error_response(404, "NoSuchKey", f"Sharing name mismatch for sharing key {sharing_key}", sharing_name), None + # 1. Properly encoded requests (path_basename matches os.path.basename of proxied_path.path) + # 2. Vol-E's unencoded requests (unquote of the expected basename matches the garbled request value) + expected_basename = os.path.basename(proxied_path.path) + if expected_basename != path_basename and unquote(expected_basename) != path_basename: + return get_error_response(404, "NoSuchKey", f"Path name mismatch for sharing key {sharing_key}", path_basename), None fsp = db.get_file_share_path(session, proxied_path.fsp_name) if not fsp: - return get_error_response(400, "InvalidArgument", f"File share path {proxied_path.fsp_name} not found", sharing_name), None + return get_error_response(400, "InvalidArgument", f"File share path {proxied_path.fsp_name} not found", path_basename), None # Expand ~ to user's home directory before constructing the mount path expanded_mount_path = os.path.expanduser(fsp.mount_path) mount_path = f"{expanded_mount_path}/{proxied_path.path}" # Use 256KB buffer for better performance on network filesystems - return FileProxyClient(proxy_kwargs={'target_name': sharing_name}, path=mount_path, buffer_size=256*1024), _get_user_context(proxied_path.username) + return FileProxyClient(proxy_kwargs={'target_name': os.path.basename(proxied_path.path)}, path=mount_path, buffer_size=256*1024), _get_user_context(proxied_path.username) @asynccontextmanager @@ -970,11 +971,11 @@ async def get_neuroglancer_short_links(request: Request, return NeuroglancerShortLinkResponse(links=links) - @app.get("/files/{sharing_key}/{sharing_name}") - @app.get("/files/{sharing_key}/{sharing_name}/{path:path}") + @app.get("/files/{sharing_key}/{path_basename}") + @app.get("/files/{sharing_key}/{path_basename}/{path:path}") async def target_dispatcher(request: Request, sharing_key: str, - sharing_name: str, + path_basename: str, path: str | None = '', list_type: Optional[int] = Query(None, alias="list-type"), continuation_token: Optional[str] = Query(None, alias="continuation-token"), @@ -988,7 +989,7 @@ async def target_dispatcher(request: Request, if 'acl' in request.query_params: return get_read_access_acl() - client, ctx = _get_file_proxy_client(sharing_key, sharing_name) + client, ctx = _get_file_proxy_client(sharing_key, path_basename) if isinstance(client, Response): return client @@ -1015,10 +1016,10 @@ async def target_dispatcher(request: Request, return handle - @app.head("/files/{sharing_key}/{sharing_name}/{path:path}") - async def head_object(sharing_key: str, sharing_name: str, path: str): + @app.head("/files/{sharing_key}/{path_basename}/{path:path}") + async def head_object(sharing_key: str, path_basename: str, path: str): try: - client, ctx = _get_file_proxy_client(sharing_key, sharing_name) + client, ctx = _get_file_proxy_client(sharing_key, path_basename) if isinstance(client, Response): return client with ctx: From 2c739ec481b6e2bed45fe7fcb2a8a2a6b9063471 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 17 Mar 2026 16:38:53 -0400 Subject: [PATCH 3/8] feat: add optional sharing_name param to create endpoint; accept JSON body on update --- fileglancer/model.py | 7 +++++++ fileglancer/server.py | 17 ++++++++++++----- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/fileglancer/model.py b/fileglancer/model.py index a46cf8d9..3aec9264 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -154,6 +154,13 @@ class ProxiedPath(BaseModel): default=None ) +class UpdateProxiedPathPayload(BaseModel): + """Payload for updating a proxied path""" + sharing_name: Optional[str] = None + fsp_name: Optional[str] = None + path: Optional[str] = None + + class ProxiedPathResponse(BaseModel): paths: List[ProxiedPath] = Field( description="A list of proxied paths" diff --git a/fileglancer/server.py b/fileglancer/server.py index 4e723763..e822ad8c 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -850,9 +850,13 @@ async def delete_neuroglancer_short_link(short_key: str = Path(..., description= description="Create a new proxied path") async def create_proxied_path(fsp_name: str = Query(..., description="The name of the file share path that this proxied path is associated with"), path: str = Query(..., description="The path relative to the file share path mount point"), + sharing_name: Optional[str] = Query(default=None, description="Display name for the data link"), username: str = Depends(get_current_user)): - sharing_name = os.path.basename(path) + if sharing_name: + sharing_name = sharing_name.strip() + if not sharing_name: + sharing_name = os.path.basename(path) logger.info(f"Creating proxied path for {username} with sharing name {sharing_name} and fsp_name {fsp_name} and path {path}") with db.get_db_session(settings.db_url) as session: with _get_user_context(username): # Necessary to validate the user can access the proxied path @@ -892,14 +896,17 @@ async def get_proxied_path(sharing_key: str = Path(..., description="The sharing @app.put("/api/proxied-path/{sharing_key}", description="Update a proxied path by sharing key") async def update_proxied_path(sharing_key: str = Path(..., description="The sharing key of the proxied path"), - fsp_name: Optional[str] = Query(default=None, description="The name of the file share path that this proxied path is associated with"), - path: Optional[str] = Query(default=None, description="The path relative to the file share path mount point"), - sharing_name: Optional[str] = Query(default=None, description="The sharing path of the proxied path"), + payload: UpdateProxiedPathPayload = Body(...), username: str = Depends(get_current_user)): + # Strip sharing_name and reject whitespace-only values + if payload.sharing_name is not None: + payload.sharing_name = payload.sharing_name.strip() + if not payload.sharing_name: + raise HTTPException(status_code=400, detail="sharing_name cannot be empty") with db.get_db_session(settings.db_url) as session: with _get_user_context(username): # Necessary to validate the user can access the proxied path try: - updated = db.update_proxied_path(session, username, sharing_key, new_path=path, new_sharing_name=sharing_name, new_fsp_name=fsp_name) + updated = db.update_proxied_path(session, username, sharing_key, new_path=payload.path, new_sharing_name=payload.sharing_name, new_fsp_name=payload.fsp_name) return _convert_proxied_path(updated, settings.external_proxy_url) except ValueError as e: logger.error(f"Error updating proxied path: {e}") From e140b3b772e2fc0d8503a4b12e1ca8232e6fd7b1 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 17 Mar 2026 16:39:16 -0400 Subject: [PATCH 4/8] test: add backend tests for sharing_name API and proxy URL stability --- tests/test_endpoints.py | 107 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 96be209d..ccc5fd00 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -293,12 +293,117 @@ def test_update_proxied_path(test_client): # Update the proxied path new_path = "new_test_proxied_path" - response = test_client.put(f"/api/proxied-path/{sharing_key}?fsp_name=tempdir&path={new_path}") + response = test_client.put(f"/api/proxied-path/{sharing_key}", json={"fsp_name": "tempdir", "path": new_path}) assert response.status_code == 200 updated_data = response.json() assert updated_data["path"] == new_path +def test_create_proxied_path_with_sharing_name(test_client, temp_dir): + """Test creating a proxied path with a custom sharing_name""" + path = "test_proxied_path" + + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}&sharing_name=My%20Custom%20Name") + assert response.status_code == 200 + data = response.json() + assert data["sharing_name"] == "My Custom Name" + assert data["path"] == path + + +def test_create_proxied_path_sharing_name_defaults_to_basename(test_client, temp_dir): + """Test that sharing_name defaults to os.path.basename(path) when not provided""" + path = "test_proxied_path" + + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}") + assert response.status_code == 200 + data = response.json() + assert data["sharing_name"] == "test_proxied_path" + + +def test_url_uses_path_basename_not_sharing_name(test_client, temp_dir): + """Test that the URL uses os.path.basename(path) instead of sharing_name""" + path = "test_proxied_path" + + # Create with a custom sharing_name + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}&sharing_name=Custom%20Display%20Name") + assert response.status_code == 200 + data = response.json() + sharing_key = data["sharing_key"] + + # The URL should contain the path basename, not the sharing_name + assert data["url"] is not None + assert f"/{sharing_key}/test_proxied_path" in data["url"] + assert "Custom%20Display%20Name" not in data["url"] + assert "Custom Display Name" not in data["url"] + + +def test_url_stable_after_sharing_name_update(test_client, temp_dir): + """Test that updating sharing_name does not change the URL""" + path = "test_proxied_path" + + # Create a proxied path + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}") + assert response.status_code == 200 + data = response.json() + sharing_key = data["sharing_key"] + original_url = data["url"] + + # Update only the sharing_name + response = test_client.put(f"/api/proxied-path/{sharing_key}", json={"sharing_name": "Renamed Display Name"}) + assert response.status_code == 200 + updated_data = response.json() + + # The sharing_name should be updated + assert updated_data["sharing_name"] == "Renamed Display Name" + # The URL should remain unchanged (still uses path basename) + assert updated_data["url"] == original_url + + +def test_update_proxied_path_sharing_name(test_client): + """Test updating only the sharing_name via PUT with JSON body""" + path = "test_proxied_path" + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}") + assert response.status_code == 200 + data = response.json() + sharing_key = data["sharing_key"] + + # Update only the sharing_name + response = test_client.put(f"/api/proxied-path/{sharing_key}", json={"sharing_name": "New Name"}) + assert response.status_code == 200 + updated_data = response.json() + assert updated_data["sharing_name"] == "New Name" + # Path should remain unchanged + assert updated_data["path"] == path + + +def test_update_proxied_path_persists_across_requests(test_client): + """Test that updating sharing_name is persisted and returned by subsequent GET requests. + Regression test for detached SQLAlchemy cached objects not persisting changes. + """ + path = "test_proxied_path" + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}") + assert response.status_code == 200 + sharing_key = response.json()["sharing_key"] + + # Update the sharing_name + response = test_client.put(f"/api/proxied-path/{sharing_key}", json={"sharing_name": "Updated Nickname"}) + assert response.status_code == 200 + assert response.json()["sharing_name"] == "Updated Nickname" + + # Verify the update is returned by GET (list) + response = test_client.get("/api/proxied-path") + assert response.status_code == 200 + paths = response.json()["paths"] + matched = [p for p in paths if p["sharing_key"] == sharing_key] + assert len(matched) == 1 + assert matched[0]["sharing_name"] == "Updated Nickname" + + # Verify the update is returned by GET (single) + response = test_client.get(f"/api/proxied-path/{sharing_key}") + assert response.status_code == 200 + assert response.json()["sharing_name"] == "Updated Nickname" + + def test_delete_proxied_path(test_client): """Test deleting a proxied path""" # First, create a proxied path to delete From 1e7f17484e9186a88f7f7513c5ea19c0272ea113 Mon Sep 17 00:00:00 2001 From: allison-truhlar Date: Tue, 17 Mar 2026 16:40:06 -0400 Subject: [PATCH 5/8] feat: add nickname input with validation to the data link creation dialog - nickname defaults to the path basename --- .../components/ui/BrowsePage/N5Preview.tsx | 1 + .../components/ui/BrowsePage/ZarrPreview.tsx | 1 + .../src/components/ui/Dialogs/DataLink.tsx | 69 ++++++++++++++++--- frontend/src/hooks/useDataToolLinks.ts | 42 +++++++---- frontend/src/queries/proxiedPathQueries.ts | 47 ++++++++++++- 5 files changed, 135 insertions(+), 25 deletions(-) diff --git a/frontend/src/components/ui/BrowsePage/N5Preview.tsx b/frontend/src/components/ui/BrowsePage/N5Preview.tsx index 17a289f9..cc91f78b 100644 --- a/frontend/src/components/ui/BrowsePage/N5Preview.tsx +++ b/frontend/src/components/ui/BrowsePage/N5Preview.tsx @@ -75,6 +75,7 @@ export default function N5Preview({ action="create" onCancel={handleDialogCancel} onConfirm={handleDialogConfirm} + path={path} setPendingToolKey={setPendingToolKey} setShowDataLinkDialog={setShowDataLinkDialog} showDataLinkDialog={showDataLinkDialog} diff --git a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx index eac0a69f..f2915be0 100644 --- a/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx +++ b/frontend/src/components/ui/BrowsePage/ZarrPreview.tsx @@ -112,6 +112,7 @@ export default function ZarrPreview({ action="create" onCancel={handleDialogCancel} onConfirm={handleDialogConfirm} + path={path} setPendingToolKey={setPendingToolKey} setShowDataLinkDialog={setShowDataLinkDialog} showDataLinkDialog={showDataLinkDialog} diff --git a/frontend/src/components/ui/Dialogs/DataLink.tsx b/frontend/src/components/ui/Dialogs/DataLink.tsx index d11bbe27..ee5775fc 100644 --- a/frontend/src/components/ui/Dialogs/DataLink.tsx +++ b/frontend/src/components/ui/Dialogs/DataLink.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/destructuring-assignment */ // Props are used for TypeScript type narrowing purposes and cannot be destructured at the beginning -import { useState, SetStateAction } from 'react'; +import { useState, useEffect, useRef, SetStateAction } from 'react'; import type { ReactNode, Dispatch } from 'react'; import { Button, Typography } from '@material-tailwind/react'; @@ -25,7 +25,8 @@ interface CommonDataLinkDialogProps { interface CreateLinkFromToolsProps extends CommonDataLinkDialogProps { tools: true; action: 'create'; - onConfirm: () => Promise; + path: string; + onConfirm: (sharingName?: string) => Promise; onCancel: () => void; setPendingToolKey: Dispatch>; } @@ -33,7 +34,7 @@ interface CreateLinkFromToolsProps extends CommonDataLinkDialogProps { interface CreateLinkNotFromToolsProps extends CommonDataLinkDialogProps { tools: false; action: 'create'; - onConfirm: () => Promise; + onConfirm: (sharingName?: string) => Promise; onCancel: () => void; } @@ -50,16 +51,18 @@ type DataLinkDialogProps = | DeleteLinkDialogProps; function CreateLinkBtn({ - onConfirm + onConfirm, + sharingName }: { - readonly onConfirm: () => Promise; + readonly onConfirm: (sharingName?: string) => Promise; + readonly sharingName?: string; }) { return (