From c234a21056ce088d4319e0944d0a05d5de3896a0 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:07:10 -0400 Subject: [PATCH 01/30] feat: add is_transparent column to proxied_paths table --- ...de5_add_is_transparent_to_proxied_paths.py | 24 +++++++++++++++++++ fileglancer/database.py | 1 + 2 files changed, 25 insertions(+) create mode 100644 fileglancer/alembic/versions/76858c70bde5_add_is_transparent_to_proxied_paths.py diff --git a/fileglancer/alembic/versions/76858c70bde5_add_is_transparent_to_proxied_paths.py b/fileglancer/alembic/versions/76858c70bde5_add_is_transparent_to_proxied_paths.py new file mode 100644 index 00000000..ca564494 --- /dev/null +++ b/fileglancer/alembic/versions/76858c70bde5_add_is_transparent_to_proxied_paths.py @@ -0,0 +1,24 @@ +"""add is_transparent to proxied_paths + +Revision ID: 76858c70bde5 +Revises: a3d7cc6e95e8 +Create Date: 2026-04-07 14:27:21.377139 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '76858c70bde5' +down_revision = 'a3d7cc6e95e8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('proxied_paths', sa.Column('is_transparent', sa.Boolean(), nullable=False, server_default='0')) + + +def downgrade() -> None: + op.drop_column('proxied_paths', 'is_transparent') \ No newline at end of file diff --git a/fileglancer/database.py b/fileglancer/database.py index f1f88592..70e3fde3 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -95,6 +95,7 @@ class ProxiedPathDB(Base): sharing_name = Column(String, nullable=False) fsp_name = Column(String, nullable=False) path = Column(String, nullable=False) + is_transparent = Column(Boolean, nullable=False, server_default="0") created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) From d633a4424a9e5f92ce6e8734cb00b00e5c3e0770 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:11:20 -0400 Subject: [PATCH 02/30] feat: pass is_transparent when creating proxied paths --- fileglancer/database.py | 3 ++- fileglancer/server.py | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/fileglancer/database.py b/fileglancer/database.py index 70e3fde3..ac39e355 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -590,7 +590,7 @@ def _validate_proxied_path(session: Session, fsp_name: str, path: str) -> None: raise ValueError(f"Path {path} is not accessible relative to {fsp_name}") -def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_name: str, path: str) -> ProxiedPathDB: +def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_name: str, path: str, is_transparent: bool = False) -> ProxiedPathDB: """Create a new proxied path""" _validate_proxied_path(session, fsp_name, path) @@ -602,6 +602,7 @@ def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_ sharing_name=sharing_name, fsp_name=fsp_name, path=path, + is_transparent=is_transparent, created_at=now, updated_at=now ) diff --git a/fileglancer/server.py b/fileglancer/server.py index de84bd50..b7a4a4f7 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -95,7 +95,10 @@ 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)}" + if db_path.is_transparent: + url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(db_path.path, safe='/')}" + else: + 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 @@ -849,14 +852,15 @@ 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"), + is_transparent: bool = Query(False, description="Whether to create a transparent link"), username: str = Depends(get_current_user)): 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}") + logger.info(f"Creating proxied path for {username} with sharing name {sharing_name} and fsp_name {fsp_name} and path {path} (transparent={is_transparent})") 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: - new_path = db.create_proxied_path(session, username, sharing_name, fsp_name, path) + new_path = db.create_proxied_path(session, username, sharing_name, fsp_name, path, is_transparent=is_transparent) return _convert_proxied_path(new_path, settings.external_proxy_url) except ValueError as e: logger.error(f"Error creating proxied path: {e}") From ec0c0a5bd844f0d48c94c72daf04f148d5f501b2 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:15:59 -0400 Subject: [PATCH 03/30] feat: rewrite proxy file resolution for transparent links Collapse /files/{key}/{sharing_name}/{path} into /files/{key}/{path}. _get_file_proxy_client now resolves subpath by matching captured_path against stored path or basename with boundary guards. This is backwards compatible with existing links because {sharing_name} used to just be the name of the directory the data link was being created for (now called a non-transparent link) --- fileglancer/server.py | 65 ++++++++++++++++++++++++++++--------------- 1 file changed, 43 insertions(+), 22 deletions(-) diff --git a/fileglancer/server.py b/fileglancer/server.py index b7a4a4f7..dbc76e63 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -202,29 +202,52 @@ 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, captured_path: str) -> Tuple[FileProxyClient | Response, UserContext | None, str]: + """Resolve a sharing key and captured path to a FileProxyClient. + + Returns (client, user_context, subpath) on success, or (error_response, None, "") on failure. + """ + def try_strip_prefix(captured: str, prefix: str) -> str | None: + if captured == prefix: + return "" + if captured.startswith(prefix + "/"): + return captured[len(prefix) + 1:] + return 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 - - # 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 - # 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 + return get_nosuchbucket_response(captured_path), None, "" + + # Match captured_path against the expected prefix for this link type. + # Transparent links use the full stored path; non-transparent use only the basename. + # We restrict matching to the link's type to prevent non-transparent links + # from accepting full-path URLs (which would leak directory structure). + # The unquote() fallback handles clients like Vol-E viewer that send URLs + # with literal % characters instead of proper URL encoding — FastAPI + # auto-decodes path params, so we need to match the decoded form too. + if proxied_path.is_transparent: + subpath = try_strip_prefix(captured_path, proxied_path.path) + if subpath is None: + subpath = try_strip_prefix(captured_path, unquote(proxied_path.path)) + else: + path_basename = os.path.basename(proxied_path.path) + subpath = try_strip_prefix(captured_path, path_basename) + if subpath is None: + subpath = try_strip_prefix(captured_path, unquote(path_basename)) + if subpath is None: + return get_error_response(404, "NoSuchKey", f"Path mismatch for sharing key {sharing_key}", captured_path), 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", captured_path), 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}" + target_name = captured_path.rsplit('/', 1)[-1] if captured_path else os.path.basename(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': target_name}, path=mount_path, buffer_size=256*1024), _get_user_context(proxied_path.username), subpath @asynccontextmanager @@ -974,12 +997,10 @@ 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:path}") async def target_dispatcher(request: Request, sharing_key: str, - sharing_name: str, - path: str | None = '', + path: str = '', list_type: Optional[int] = Query(None, alias="list-type"), continuation_token: Optional[str] = Query(None, alias="continuation-token"), delimiter: Optional[str] = Query(None, alias="delimiter"), @@ -992,7 +1013,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, subpath = _get_file_proxy_client(sharing_key, path) if isinstance(client, Response): return client @@ -1009,7 +1030,7 @@ async def target_dispatcher(request: Request, # Open file in user context, then immediately exit # The file descriptor retains access rights after we switch back to root with ctx: - handle = await client.open_object(path, range_header) + handle = await client.open_object(subpath, range_header) # Context exited! Now stream without holding the lock if isinstance(handle, ObjectHandle): @@ -1019,14 +1040,14 @@ 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:path}") + async def head_object(sharing_key: str, path: str = ''): try: - client, ctx = _get_file_proxy_client(sharing_key, sharing_name) + client, ctx, subpath = _get_file_proxy_client(sharing_key, path) if isinstance(client, Response): return client with ctx: - return await client.head_object(path) + return await client.head_object(subpath) except: logger.opt(exception=sys.exc_info()).info("Error requesting head") return get_error_response(500, "InternalError", "Error requesting HEAD", path) From 78d44c46243fa30405e9f29e6507823b684998fc Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:20:01 -0400 Subject: [PATCH 04/30] test: add backend tests for transparent link creation and resolution --- tests/test_endpoints.py | 152 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 96be209d..6c28bb5a 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1246,6 +1246,158 @@ def test_head_content_with_symlink(test_client, temp_dir): assert int(response.headers["Content-Length"]) == len(target_content) +def test_create_non_transparent_proxied_path(test_client, temp_dir): + """Test creating a non-transparent link uses basename in URL""" + 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() + # URL should use basename (last path component) + assert data["url"].endswith(f"/{data['sharing_key']}/test_proxied_path") + + +def test_create_transparent_proxied_path(test_client, temp_dir): + """Test creating a transparent link uses full relative path in URL""" + # Create nested dirs for a multi-segment path + nested_path = os.path.join(temp_dir, "a", "b", "c") + os.makedirs(nested_path, exist_ok=True) + path = "a/b/c" + + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}&is_transparent=true") + assert response.status_code == 200 + data = response.json() + # URL should use full relative path + assert data["url"].endswith(f"/{data['sharing_key']}/a/b/c") + + +def test_proxy_resolve_non_transparent(test_client, temp_dir): + """Test that a non-transparent link resolves correctly via basename prefix match""" + # Create a file inside the proxied directory + proxied_dir = os.path.join(temp_dir, "test_proxied_path") + test_file = os.path.join(proxied_dir, "data.txt") + with open(test_file, "w") as f: + f.write("hello") + + # Create the non-transparent link + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=test_proxied_path") + assert response.status_code == 200 + sharing_key = response.json()["sharing_key"] + + # Access through the proxy using basename prefix + response = test_client.get(f"/files/{sharing_key}/test_proxied_path/data.txt") + assert response.status_code == 200 + assert response.text == "hello" + + +def test_proxy_resolve_transparent(test_client, temp_dir): + """Test that a transparent link resolves correctly via full path prefix match""" + nested_dir = os.path.join(temp_dir, "a", "b") + os.makedirs(nested_dir, exist_ok=True) + test_file = os.path.join(nested_dir, "data.txt") + with open(test_file, "w") as f: + f.write("transparent hello") + + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=a/b&is_transparent=true") + assert response.status_code == 200 + sharing_key = response.json()["sharing_key"] + + # Access through the proxy using full path prefix + response = test_client.get(f"/files/{sharing_key}/a/b/data.txt") + assert response.status_code == 200 + assert response.text == "transparent hello" + + +def test_proxy_path_boundary_guard(test_client, temp_dir): + """Test that prefix 'foo' does NOT match 'foobar/file' (path boundary enforcement)""" + # Create two directories: "foo" and "foobar" + foo_dir = os.path.join(temp_dir, "foo") + foobar_dir = os.path.join(temp_dir, "foobar") + os.makedirs(foo_dir, exist_ok=True) + os.makedirs(foobar_dir, exist_ok=True) + with open(os.path.join(foobar_dir, "secret.txt"), "w") as f: + f.write("secret") + + # Create a non-transparent link for "foo" + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=foo") + assert response.status_code == 200 + sharing_key = response.json()["sharing_key"] + + # Try to access "foobar/secret.txt" via the "foo" sharing key - should fail + response = test_client.get(f"/files/{sharing_key}/foobar/secret.txt") + assert response.status_code == 404 + + +def test_proxy_resolve_url_encoded_path_non_transparent(test_client, temp_dir): + """Test that a non-transparent link resolves when the basename contains URL-encoded characters""" + dir_name = "my data set" + proxied_dir = os.path.join(temp_dir, dir_name) + os.makedirs(proxied_dir, exist_ok=True) + with open(os.path.join(proxied_dir, "file.txt"), "w") as f: + f.write("encoded hello") + + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={dir_name}") + assert response.status_code == 200 + sharing_key = response.json()["sharing_key"] + + # Request with URL-encoded space (%20) in the basename + response = test_client.get(f"/files/{sharing_key}/my%20data%20set/file.txt") + assert response.status_code == 200 + assert response.text == "encoded hello" + + +def test_proxy_resolve_url_encoded_path_transparent(test_client, temp_dir): + """Test that a transparent link resolves when the path contains URL-encoded characters""" + nested_dir = os.path.join(temp_dir, "my folder", "sub dir") + os.makedirs(nested_dir, exist_ok=True) + with open(os.path.join(nested_dir, "file.txt"), "w") as f: + f.write("encoded transparent hello") + + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=my folder/sub dir&is_transparent=true") + assert response.status_code == 200 + sharing_key = response.json()["sharing_key"] + + # Request with URL-encoded spaces in the full path + response = test_client.get(f"/files/{sharing_key}/my%20folder/sub%20dir/file.txt") + assert response.status_code == 200 + assert response.text == "encoded transparent hello" + + +def test_proxy_resolve_special_characters_in_path(test_client, temp_dir): + """Test that paths with special characters (parentheses, brackets, etc.) resolve correctly""" + dir_name = "experiment (2) [final]" + proxied_dir = os.path.join(temp_dir, dir_name) + os.makedirs(proxied_dir, exist_ok=True) + with open(os.path.join(proxied_dir, "result.txt"), "w") as f: + f.write("special chars") + + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={dir_name}") + assert response.status_code == 200 + sharing_key = response.json()["sharing_key"] + + response = test_client.get(f"/files/{sharing_key}/experiment%20%282%29%20%5Bfinal%5D/result.txt") + assert response.status_code == 200 + assert response.text == "special chars" + + +def test_proxy_reject_transparent_url_on_non_transparent_link(test_client, temp_dir): + """Test that a non-transparent link rejects requests using the full path""" + nested_dir = os.path.join(temp_dir, "a", "b") + os.makedirs(nested_dir, exist_ok=True) + with open(os.path.join(nested_dir, "file.txt"), "w") as f: + f.write("should not be accessible") + + # Create non-transparent link for "a/b" + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=a/b") + assert response.status_code == 200 + sharing_key = response.json()["sharing_key"] + + # Attempt to access via full path (transparent-style) — should be rejected + response = test_client.get(f"/files/{sharing_key}/a/b/file.txt") + assert response.status_code == 404 + + + def test_broken_symlink_in_file_listing(test_client, temp_dir): """Test that broken symlinks appear in /api/files response with correct properties""" # Create a broken symlink pointing to a nonexistent path From 6b7a94c390ccc03cd499ca1cbbea76707caca76d Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:20:19 -0400 Subject: [PATCH 05/30] refactor: restrict update_proxied_path to sharing_name only --- fileglancer/database.py | 22 ++++++++++------------ fileglancer/model.py | 9 ++++++++- fileglancer/server.py | 20 +++++++++----------- tests/test_endpoints.py | 26 +++++++++++++++++++++----- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/fileglancer/database.py b/fileglancer/database.py index ac39e355..300e5a3f 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -619,10 +619,8 @@ def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_ def update_proxied_path(session: Session, username: str, sharing_key: str, - new_sharing_name: Optional[str] = None, - new_path: Optional[str] = None, - new_fsp_name: Optional[str] = None) -> ProxiedPathDB: - """Update a proxied path""" + new_sharing_name: Optional[str] = None) -> ProxiedPathDB: + """Update a proxied path (only sharing_name can be changed)""" proxied_path = get_proxied_path_by_sharing_key(session, sharing_key) if not proxied_path: raise ValueError(f"Proxied path with sharing key {sharing_key} not found") @@ -630,16 +628,16 @@ 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}") - if new_sharing_name: - proxied_path.sharing_name = new_sharing_name - - if new_fsp_name: - proxied_path.fsp_name = new_fsp_name + # merge() is needed because the cached object may be detached from a prior session. + # The merged object is re-cached; it will become detached when this session closes, + # which is fine since _get_file_proxy_client only reads cached objects. + proxied_path = session.merge(proxied_path) - if new_path: - proxied_path.path = new_path + if new_sharing_name is not None: + if not new_sharing_name.strip(): + raise ValueError("Sharing name cannot be empty") + proxied_path.sharing_name = new_sharing_name - _validate_proxied_path(session, proxied_path.fsp_name, proxied_path.path) proxied_path.updated_at = datetime.now(UTC) session.commit() diff --git a/fileglancer/model.py b/fileglancer/model.py index 2ff0e0c4..77296665 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -135,7 +135,7 @@ class ProxiedPath(BaseModel): description="The sharing key is part of the URL proxy path. It is used to uniquely identify the proxied path." ) sharing_name: str = Field( - description="The sharing path is part of the URL proxy path. It is mainly used to provide file extension information to the client." + description="A display-only label for the data link. Does not appear in the URL." ) path: str = Field( description="The path relative to the file share path mount point" @@ -160,6 +160,13 @@ class ProxiedPathResponse(BaseModel): ) +class UpdateProxiedPathPayload(BaseModel): + sharing_name: Optional[str] = Field( + description="New label for the data link", + default=None + ) + + class ExternalBucket(BaseModel): """An external bucket for S3-compatible storage""" id: int = Field( diff --git a/fileglancer/server.py b/fileglancer/server.py index dbc76e63..1efda94b 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -917,19 +917,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"), + async def update_proxied_path(sharing_key: str = Path(...), + payload: UpdateProxiedPathPayload = Body(...), username: str = Depends(get_current_user)): + # No user context needed -- we only update sharing_name (display only), + # no filesystem access validation required. 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) - return _convert_proxied_path(updated, settings.external_proxy_url) - except ValueError as e: - logger.error(f"Error updating proxied path: {e}") - raise HTTPException(status_code=400, detail=str(e)) + try: + updated = db.update_proxied_path(session, username, sharing_key, new_sharing_name=payload.sharing_name) + return _convert_proxied_path(updated, settings.external_proxy_url) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) @app.delete("/api/proxied-path/{sharing_key}", description="Delete a proxied path by sharing key") diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 6c28bb5a..7c9e3527 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -282,7 +282,7 @@ def test_get_proxied_paths(test_client): def test_update_proxied_path(test_client): - """Test updating a proxied path""" + """Test updating a proxied path's sharing name""" # First, create a proxied path to update path = "test_proxied_path" response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}") @@ -290,13 +290,13 @@ def test_update_proxied_path(test_client): data = response.json() sharing_key = data["sharing_key"] - # Update the proxied path - new_path = "new_test_proxied_path" + # Update the sharing name via JSON body + new_sharing_name = "my_new_nickname" - 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={"sharing_name": new_sharing_name}) assert response.status_code == 200 updated_data = response.json() - assert updated_data["path"] == new_path + assert updated_data["sharing_name"] == new_sharing_name def test_delete_proxied_path(test_client): @@ -1397,6 +1397,22 @@ def test_proxy_reject_transparent_url_on_non_transparent_link(test_client, temp_ assert response.status_code == 404 +def test_update_sharing_name_does_not_change_url(test_client, temp_dir): + """Test that updating sharing_name doesn't affect the URL""" + path = "test_proxied_path" + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}") + assert response.status_code == 200 + original_data = response.json() + original_url = original_data["url"] + + # Update the sharing name + sharing_key = original_data["sharing_key"] + response = test_client.put(f"/api/proxied-path/{sharing_key}", json={"sharing_name": "my_custom_name"}) + assert response.status_code == 200 + updated_data = response.json() + assert updated_data["sharing_name"] == "my_custom_name" + assert updated_data["url"] == original_url + def test_broken_symlink_in_file_listing(test_client, temp_dir): """Test that broken symlinks appear in /api/files response with correct properties""" From a15e5fabb0c5a471208fb0d2fabe7e01bfb1c46e Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:21:10 -0400 Subject: [PATCH 06/30] feat: add transparentDataLinks user preference --- frontend/src/contexts/PreferencesContext.tsx | 11 +++++++++++ frontend/src/queries/preferencesQueries.ts | 3 +++ 2 files changed, 14 insertions(+) diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx index e1c4e8d9..1bec8977 100644 --- a/frontend/src/contexts/PreferencesContext.tsx +++ b/frontend/src/contexts/PreferencesContext.tsx @@ -48,6 +48,7 @@ type PreferencesContextType = { layout: string; hideDotFiles: boolean; areDataLinksAutomatic: boolean; + transparentDataLinks: boolean; disableNeuroglancerStateGeneration: boolean; disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; @@ -77,6 +78,7 @@ type PreferencesContextType = { setLayoutWithPropertiesOpen: () => Promise>; toggleHideDotFiles: () => Promise>; toggleAutomaticDataLinks: () => Promise>; + toggleTransparentDataLinks: () => Promise>; toggleDisableNeuroglancerStateGeneration: () => Promise>; toggleDisableHeuristicalLayerTypeDetection: () => Promise>; toggleUseLegacyMultichannelApproach: () => Promise>; @@ -212,6 +214,13 @@ export const PreferencesProvider = ({ ); }; + const toggleTransparentDataLinks = async (): Promise> => { + return togglePreference( + 'transparentDataLinks', + preferencesQuery.data?.transparentDataLinks ?? false + ); + }; + const toggleDisableNeuroglancerStateGeneration = async (): Promise< Result > => { @@ -524,6 +533,7 @@ export const PreferencesProvider = ({ hideDotFiles: preferencesQuery.data?.hideDotFiles || false, areDataLinksAutomatic: preferencesQuery.data?.areDataLinksAutomatic ?? false, + transparentDataLinks: preferencesQuery.data?.transparentDataLinks ?? false, disableNeuroglancerStateGeneration: preferencesQuery.data?.disableNeuroglancerStateGeneration || false, disableHeuristicalLayerTypeDetection: @@ -555,6 +565,7 @@ export const PreferencesProvider = ({ setLayoutWithPropertiesOpen, toggleHideDotFiles, toggleAutomaticDataLinks, + toggleTransparentDataLinks, toggleDisableNeuroglancerStateGeneration, toggleDisableHeuristicalLayerTypeDetection, toggleUseLegacyMultichannelApproach, diff --git a/frontend/src/queries/preferencesQueries.ts b/frontend/src/queries/preferencesQueries.ts index 57f50815..dcffd276 100644 --- a/frontend/src/queries/preferencesQueries.ts +++ b/frontend/src/queries/preferencesQueries.ts @@ -33,6 +33,7 @@ type PreferencesApiResponse = { layout?: { value: string }; hideDotFiles?: { value: boolean }; areDataLinksAutomatic?: { value: boolean }; + transparentDataLinks?: { value: boolean }; disableNeuroglancerStateGeneration?: { value: boolean }; disableHeuristicalLayerTypeDetection?: { value: boolean }; useLegacyMultichannelApproach?: { value: boolean }; @@ -65,6 +66,7 @@ export type PreferencesQueryData = { layout: string; hideDotFiles: boolean; areDataLinksAutomatic: boolean; + transparentDataLinks: boolean; disableNeuroglancerStateGeneration: boolean; disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; @@ -229,6 +231,7 @@ const createTransformPreferences = ( layout: rawData.layout?.value || '', hideDotFiles: rawData.hideDotFiles?.value || false, areDataLinksAutomatic: rawData.areDataLinksAutomatic?.value ?? false, + transparentDataLinks: rawData.transparentDataLinks?.value ?? false, disableNeuroglancerStateGeneration: rawData.disableNeuroglancerStateGeneration?.value || false, disableHeuristicalLayerTypeDetection: From 9faf085be350c97ceff3dcd77906234f29ae5ace Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:22:23 -0400 Subject: [PATCH 07/30] feat: add transparent data links option; renamed component to reflect multiple opts --- .../ui/PreferencesPage/DataLinkOptions.tsx | 70 ++++++++++++------- 1 file changed, 46 insertions(+), 24 deletions(-) diff --git a/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx b/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx index e8b25abc..7075f589 100644 --- a/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx +++ b/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx @@ -3,39 +3,61 @@ import toast from 'react-hot-toast'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import OptionsSection from '@/components/ui/PreferencesPage/OptionsSection'; -type AutomaticLinksToggleProps = { +type DataLinkOptionsProps = { readonly checkboxesOnly?: boolean; }; -export default function AutomaticLinksToggle({ +export default function DataLinkOptions({ checkboxesOnly = false -}: AutomaticLinksToggleProps) { - const { areDataLinksAutomatic, toggleAutomaticDataLinks } = - usePreferencesContext(); +}: DataLinkOptionsProps) { + const { + areDataLinksAutomatic, + toggleAutomaticDataLinks, + transparentDataLinks, + toggleTransparentDataLinks + } = usePreferencesContext(); + + const automaticOption = { + checked: areDataLinksAutomatic, + id: 'automatic_data_links', + label: 'Enable automatic data link creation', + onChange: async () => { + const result = await toggleAutomaticDataLinks(); + if (result.success) { + toast.success( + areDataLinksAutomatic + ? 'Disabled automatic data links' + : 'Enabled automatic data links' + ); + } else { + toast.error(result.error); + } + } + }; + + const transparentOption = { + checked: transparentDataLinks, + id: 'transparent_data_links', + label: 'Include full path in data links', + onChange: async () => { + const result = await toggleTransparentDataLinks(); + if (result.success) { + toast.success( + transparentDataLinks + ? 'Disabled full path in data links' + : 'Enabled full path in data links' + ); + } else { + toast.error(result.error); + } + } + }; return ( { - const result = await toggleAutomaticDataLinks(); - if (result.success) { - toast.success( - areDataLinksAutomatic - ? 'Disabled automatic data links' - : 'Enabled automatic data links' - ); - } else { - toast.error(result.error); - } - } - } - ]} + options={[automaticOption, transparentOption]} /> ); } From 81baf18a41da63e3b8bd3d03b9550c3e3dbdfe01 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:24:31 -0400 Subject: [PATCH 08/30] chore: pass is_transparent when posting a new data link --- frontend/src/hooks/useDataToolLinks.ts | 6 ++++-- frontend/src/queries/proxiedPathQueries.ts | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/frontend/src/hooks/useDataToolLinks.ts b/frontend/src/hooks/useDataToolLinks.ts index f882c69b..767ffab7 100644 --- a/frontend/src/hooks/useDataToolLinks.ts +++ b/frontend/src/hooks/useDataToolLinks.ts @@ -57,7 +57,8 @@ export default function useDataToolLinks( currentDirProxiedPathQuery } = useProxiedPathContext(); - const { areDataLinksAutomatic } = usePreferencesContext(); + const { areDataLinksAutomatic, transparentDataLinks } = + usePreferencesContext(); const { externalDataUrlQuery } = useExternalBucketContext(); const { handleCopy, showCopiedTooltip } = useCopyTooltip(); @@ -77,7 +78,8 @@ export default function useDataToolLinks( try { await createProxiedPathMutation.mutateAsync({ fsp_name: fileQuery.data.currentFileSharePath.name, - path + path, + is_transparent: transparentDataLinks }); toast.success('Data link created successfully'); await allProxiedPathsQuery.refetch(); diff --git a/frontend/src/queries/proxiedPathQueries.ts b/frontend/src/queries/proxiedPathQueries.ts index 315aa820..6cb85c7b 100644 --- a/frontend/src/queries/proxiedPathQueries.ts +++ b/frontend/src/queries/proxiedPathQueries.ts @@ -27,6 +27,9 @@ type ProxiedPathApiResponse = { type CreateProxiedPathPayload = { fsp_name: string; path: string; + is_transparent?: boolean; +}; + }; /** From d5b828d6886c86a9addbfcaa0f2fe235e6beaaa3 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:26:25 -0400 Subject: [PATCH 09/30] feat: rework DataLinkDialog with settings preview also needed to update the mock for /api/proxied-path for the DataLinkDialog test --- frontend/src/__tests__/mocks/handlers.ts | 16 ++++++- .../src/components/ui/Dialogs/DataLink.tsx | 47 ++++++++++++++----- 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index e61b2d9b..9a4d8d3a 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -18,11 +18,23 @@ export const handlers = [ return HttpResponse.json({ paths: [] }, { status: 200 }); }), - http.post('/api/proxied-path', () => { + http.post('/api/proxied-path', ({ request }) => { + const url = new URL(request.url); + const isTransparent = url.searchParams.get('is_transparent') === 'true'; + const path = url.searchParams.get('path') || '/test/path'; + const pathBasename = path.split('/').pop() || path; + const urlSuffix = isTransparent ? path : pathBasename; return HttpResponse.json({ username: 'testuser', sharing_key: 'testkey', - sharing_name: 'testshare', + sharing_name: pathBasename, + path: path, + fsp_name: url.searchParams.get('fsp_name') || 'test_fsp', + created_at: '2025-07-08T15:56:42.588942', + updated_at: '2025-07-08T15:56:42.588942', + url: 'http://127.0.0.1:7878/files/testkey/' + urlSuffix + }); + }), path: '/test/path', fsp_name: 'test_fsp', created_at: '2025-07-08T15:56:42.588942', diff --git a/frontend/src/components/ui/Dialogs/DataLink.tsx b/frontend/src/components/ui/Dialogs/DataLink.tsx index d11bbe27..981e8a34 100644 --- a/frontend/src/components/ui/Dialogs/DataLink.tsx +++ b/frontend/src/components/ui/Dialogs/DataLink.tsx @@ -4,6 +4,7 @@ import { useState, SetStateAction } from 'react'; import type { ReactNode, Dispatch } from 'react'; import { Button, Typography } from '@material-tailwind/react'; +import { Link } from 'react-router'; import type { ProxiedPath } from '@/contexts/ProxiedPathContext'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; @@ -14,7 +15,7 @@ import type { FileSharePath } from '@/shared.types'; import type { PendingToolKey } from '@/hooks/useZarrMetadata'; import FgDialog from './FgDialog'; import TextWithFilePath from './TextWithFilePath'; -import AutomaticLinksToggle from '@/components/ui/PreferencesPage/DataLinkOptions'; +import DataLinkOptions from '@/components/ui/PreferencesPage/DataLinkOptions'; import DeleteBtn from '@/components/ui/buttons/DeleteBtn'; interface CommonDataLinkDialogProps { @@ -105,7 +106,13 @@ function BtnContainer({ children }: { readonly children: ReactNode }) { export default function DataLinkDialog(props: DataLinkDialogProps) { const { fspName, filePath } = useFileBrowserContext(); - const { pathPreference, areDataLinksAutomatic } = usePreferencesContext(); + const { + pathPreference, + areDataLinksAutomatic, + toggleAutomaticDataLinks, + transparentDataLinks, + toggleTransparentDataLinks + } = usePreferencesContext(); const { zonesAndFspQuery } = useZoneAndFspMapContext(); const [localAreDataLinksAutomatic] = useState(areDataLinksAutomatic); @@ -130,6 +137,11 @@ export default function DataLinkDialog(props: DataLinkDialogProps) { } const displayPath = getDisplayPath(); + // Generate a preview data link URL + const folderNameOnly = filePath ? filePath.split('/').pop() || filePath : ''; + const pathPortion = transparentDataLinks ? filePath || '' : folderNameOnly; + const dataLinkPreview = `https://...//${pathPortion}`; + return ( { @@ -140,29 +152,38 @@ export default function DataLinkDialog(props: DataLinkDialogProps) { }} open={props.showDataLinkDialog} > -
+
{props.action === 'create' && localAreDataLinksAutomatic ? ( <> ) : props.action === 'create' && !localAreDataLinksAutomatic ? ( <> - + + Are you sure you want to create a data link? + If you share the data link with internal collaborators, they will be able to view these data. -
- - Don't ask me this again: - - -
+
+ + Data link settings: + + + + {dataLinkPreview} + + + You can always modify settings on the{' '} + + preferences page + {' '} + . + +
) : null} {props.action === 'delete' ? ( From b5019477d84b72ea3aedeee981dbd026cd59dad7 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:31:39 -0400 Subject: [PATCH 10/30] feat: add useUpdateProxiedPathMutation hook New mutation with optimistic rollback for PUT /api/proxied-path/:key. --- frontend/src/__tests__/mocks/handlers.ts | 10 ++- frontend/src/contexts/ProxiedPathContext.tsx | 4 ++ frontend/src/queries/proxiedPathQueries.ts | 64 +++++++++++++++++++- 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 9a4d8d3a..342e89dd 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -35,11 +35,19 @@ export const handlers = [ url: 'http://127.0.0.1:7878/files/testkey/' + urlSuffix }); }), + + http.put('/api/proxied-path/:sharingKey', async ({ params, request }) => { + const { sharingKey } = params; + const body = (await request.json()) as { sharing_name?: string }; + return HttpResponse.json({ + username: 'testuser', + sharing_key: sharingKey, + sharing_name: body.sharing_name || 'testshare', path: '/test/path', fsp_name: 'test_fsp', created_at: '2025-07-08T15:56:42.588942', updated_at: '2025-07-08T15:56:42.588942', - url: 'http://127.0.0.1:7878/files/testkey/test/path' + url: 'http://127.0.0.1:7878/files/' + sharingKey + '/path' }); }), diff --git a/frontend/src/contexts/ProxiedPathContext.tsx b/frontend/src/contexts/ProxiedPathContext.tsx index 5d964540..681cb0dd 100644 --- a/frontend/src/contexts/ProxiedPathContext.tsx +++ b/frontend/src/contexts/ProxiedPathContext.tsx @@ -6,6 +6,7 @@ import { useAllProxiedPathsQuery, useProxiedPathByFspAndPathQuery, useCreateProxiedPathMutation, + useUpdateProxiedPathMutation, useDeleteProxiedPathMutation } from '@/queries/proxiedPathQueries'; @@ -29,6 +30,7 @@ type ProxiedPathContextType = { typeof useProxiedPathByFspAndPathQuery >; createProxiedPathMutation: ReturnType; + updateProxiedPathMutation: ReturnType; deleteProxiedPathMutation: ReturnType; }; @@ -71,6 +73,7 @@ export const ProxiedPathProvider = ({ ); const createProxiedPathMutation = useCreateProxiedPathMutation(); + const updateProxiedPathMutation = useUpdateProxiedPathMutation(); const deleteProxiedPathMutation = useDeleteProxiedPathMutation(); const value: ProxiedPathContextType = { @@ -78,6 +81,7 @@ export const ProxiedPathProvider = ({ proxiedPathByFspAndPathQuery, currentDirProxiedPathQuery, createProxiedPathMutation, + updateProxiedPathMutation, deleteProxiedPathMutation }; diff --git a/frontend/src/queries/proxiedPathQueries.ts b/frontend/src/queries/proxiedPathQueries.ts index 6cb85c7b..ce8da50f 100644 --- a/frontend/src/queries/proxiedPathQueries.ts +++ b/frontend/src/queries/proxiedPathQueries.ts @@ -30,6 +30,12 @@ type CreateProxiedPathPayload = { is_transparent?: boolean; }; +/** + * Payload for updating a proxied path (label only) + */ +type UpdateProxiedPathPayload = { + sharing_key: string; + sharing_name: string; }; /** @@ -181,10 +187,14 @@ export function useCreateProxiedPathMutation(): UseMutationResult< return useMutation({ mutationFn: async (payload: CreateProxiedPathPayload) => { - const url = buildUrl('/api/proxied-path', null, { + const queryParams: Record = { fsp_name: payload.fsp_name, path: payload.path - }); + }; + if (payload.is_transparent !== undefined) { + queryParams.is_transparent = String(payload.is_transparent); + } + const url = buildUrl('/api/proxied-path', null, queryParams); const proxiedPath = await sendRequestAndThrowForNotOk(url, 'POST'); return proxiedPath as ProxiedPath; }, @@ -285,3 +295,53 @@ export function useDeleteProxiedPathMutation(): UseMutationResult< } }); } + +/** + * Mutation hook for updating a proxied path (label only) + * + * @example + * const mutation = useUpdateProxiedPathMutation(); + * mutation.mutate({ sharing_key: 'abc123', sharing_name: 'My Link' }); + */ +export function useUpdateProxiedPathMutation(): UseMutationResult< + ProxiedPath, + Error, + UpdateProxiedPathPayload, + { previousPaths?: ProxiedPath[] } +> { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (payload: UpdateProxiedPathPayload) => { + const url = buildUrl('/api/proxied-path/', payload.sharing_key, null); + const proxiedPath = await sendRequestAndThrowForNotOk(url, 'PUT', { + sharing_name: payload.sharing_name + }); + return proxiedPath as ProxiedPath; + }, + onMutate: async () => { + await queryClient.cancelQueries({ queryKey: proxiedPathQueryKeys.all }); + const previousPaths = queryClient.getQueryData( + proxiedPathQueryKeys.list() + ); + return { previousPaths }; + }, + onSuccess: (updatedPath: ProxiedPath) => { + queryClient.setQueryData( + proxiedPathQueryKeys.detail(updatedPath.fsp_name, updatedPath.path), + updatedPath + ); + queryClient.invalidateQueries({ + queryKey: proxiedPathQueryKeys.all + }); + }, + onError: (_err, _variables, context) => { + if (context?.previousPaths) { + queryClient.setQueryData( + proxiedPathQueryKeys.list(), + context.previousPaths + ); + } + } + }); +} From 85ae848b03bb50624aa0c40e0402fc82885924b6 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:31:53 -0400 Subject: [PATCH 11/30] feat: add EditDataLinkLabelDialog component with tests --- .../componentTests/EditDataLinkLabel.test.tsx | 121 ++++++++++++++++++ .../ui/Dialogs/EditDataLinkLabel.tsx | 78 +++++++++++ 2 files changed, 199 insertions(+) create mode 100644 frontend/src/__tests__/componentTests/EditDataLinkLabel.test.tsx create mode 100644 frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx diff --git a/frontend/src/__tests__/componentTests/EditDataLinkLabel.test.tsx b/frontend/src/__tests__/componentTests/EditDataLinkLabel.test.tsx new file mode 100644 index 00000000..b66c9d51 --- /dev/null +++ b/frontend/src/__tests__/componentTests/EditDataLinkLabel.test.tsx @@ -0,0 +1,121 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { userEvent } from '@testing-library/user-event'; + +import EditDataLinkLabelDialog from '@/components/ui/Dialogs/EditDataLinkLabel'; + +describe('EditDataLinkLabelDialog', () => { + const mockOnClose = vi.fn(); + const mockOnConfirm = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders with current label', () => { + render( + + ); + + const input = screen.getByPlaceholderText('Enter label'); + expect(input).toHaveValue('my-link'); + expect(screen.getByText('Edit Data Link Label')).toBeInTheDocument(); + }); + + it('has Save button disabled when value is unchanged', () => { + render( + + ); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeDisabled(); + }); + + it('has Save button disabled when input is empty', async () => { + const user = userEvent.setup(); + + render( + + ); + + const input = screen.getByPlaceholderText('Enter label'); + await user.clear(input); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeDisabled(); + }); + + it('has Save button enabled when value is changed', async () => { + const user = userEvent.setup(); + + render( + + ); + + const input = screen.getByPlaceholderText('Enter label'); + await user.clear(input); + await user.type(input, 'new-name'); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).not.toBeDisabled(); + }); + + it('calls onConfirm with new label on save', async () => { + const user = userEvent.setup(); + + render( + + ); + + const input = screen.getByPlaceholderText('Enter label'); + await user.clear(input); + await user.type(input, 'new-name'); + await user.click(screen.getByRole('button', { name: /save/i })); + + await waitFor(() => { + expect(mockOnConfirm).toHaveBeenCalledWith('new-name'); + }); + }); + + it('calls onClose when Cancel is clicked', async () => { + const user = userEvent.setup(); + + render( + + ); + + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(mockOnClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx b/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx new file mode 100644 index 00000000..78d8a626 --- /dev/null +++ b/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { Button, Typography } from '@material-tailwind/react'; + +import FgDialog from './FgDialog'; + +type EditDataLinkLabelDialogProps = { + readonly open: boolean; + readonly onClose: () => void; + readonly currentLabel: string; + readonly onConfirm: (newLabel: string) => Promise; +}; + +export default function EditDataLinkLabelDialog({ + open, + onClose, + currentLabel, + onConfirm +}: EditDataLinkLabelDialogProps) { + const [label, setLabel] = useState(currentLabel); + const [saving, setSaving] = useState(false); + + const isValid = label.trim().length > 0; + const hasChanged = label !== currentLabel; + + const handleSave = async () => { + if (!isValid || !hasChanged) { + return; + } + setSaving(true); + try { + await onConfirm(label.trim()); + onClose(); + } finally { + setSaving(false); + } + }; + + return ( + +
+ + Edit Data Link Label + + + The label is only used in the data links table - it is useful for grouping data links via the sort and search functionality. Changing it will not + affect the sharing URL. + + setLabel(e.target.value)} + placeholder="Enter label" + type="text" + value={label} + /> +
+ + +
+
+
+ ); +} From 82cd324a5c5601771ba1ef009aa6daf4dfe01ba6 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Tue, 7 Apr 2026 13:38:00 -0400 Subject: [PATCH 12/30] feat: add buttons to access the edit data link label dialog also change the column heading on the data link table from "name" to "label" --- .../ui/PropertiesDrawer/PropertiesDrawer.tsx | 34 ++++++- .../src/components/ui/Table/TableCard.tsx | 9 +- .../src/components/ui/Table/linksColumns.tsx | 94 ++++++++++++++----- 3 files changed, 105 insertions(+), 32 deletions(-) diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index 1bffce04..18f99a91 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -22,6 +22,7 @@ import TicketDetails from '@/components/ui/PropertiesDrawer/TicketDetails'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; import DataLinkUsageDialog from '@/components/ui/Dialogs/dataLinkUsage/DataLinkUsageDialog'; +import EditDataLinkLabelDialog from '@/components/ui/Dialogs/EditDataLinkLabel'; import TextDialogBtn from '@/components/ui/buttons/DialogTextBtn'; import FgSwitch from '@/components/ui/widgets/FgSwitch'; import { getPreferredPathForDisplay } from '@/utils'; @@ -97,6 +98,8 @@ export default function PropertiesDrawer({ }: PropertiesDrawerProps) { const location = useLocation(); const [showDataLinkDialog, setShowDataLinkDialog] = useState(false); + const [showEditLabelDialog, setShowEditLabelDialog] = + useState(false); const [activeTab, setActiveTab] = useState('overview'); const { fileQuery, fileBrowserState } = useFileBrowserContext(); @@ -105,7 +108,8 @@ export default function PropertiesDrawer({ const { allProxiedPathsQuery, proxiedPathByFspAndPathQuery, - deleteProxiedPathMutation + deleteProxiedPathMutation, + updateProxiedPathMutation } = useProxiedPathContext(); const { externalDataUrlQuery } = useExternalBucketContext(); @@ -332,6 +336,14 @@ export default function PropertiesDrawer({ proxiedPathByFspAndPathQuery.data?.url)! } /> + {proxiedPathByFspAndPathQuery.data ? ( + + ) : null} ) : null} + {showEditLabelDialog && proxiedPathByFspAndPathQuery.data ? ( + setShowEditLabelDialog(false)} + onConfirm={async (newLabel: string) => { + try { + await updateProxiedPathMutation.mutateAsync({ + sharing_key: proxiedPathByFspAndPathQuery.data!.sharing_key, + sharing_name: newLabel + }); + toast.success('Data link label updated'); + } catch (error) { + const msg = + error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to update label: ${msg}`); + } + }} + open={showEditLabelDialog} + /> + ) : null}
); } diff --git a/frontend/src/components/ui/Table/TableCard.tsx b/frontend/src/components/ui/Table/TableCard.tsx index 659d9fc4..f71a7eea 100644 --- a/frontend/src/components/ui/Table/TableCard.tsx +++ b/frontend/src/components/ui/Table/TableCard.tsx @@ -164,15 +164,8 @@ const globalFilterFn: FilterFn = (row, _columnId, filterValue) => { const query = String(filterValue).toLowerCase(); - // Search all columns except the name column - // NOTE: this needs to change if we allow custom sharing names - // For now, the sharing name is always in the file path + // Search all columns const rowValues = row.getVisibleCells().flatMap(cell => { - // Exclude the name column (sharing_name) - if (cell.column.id === 'sharing_name') { - return []; - } - const value = cell.getValue(); if (value === null || value === undefined) { return ['']; diff --git a/frontend/src/components/ui/Table/linksColumns.tsx b/frontend/src/components/ui/Table/linksColumns.tsx index c84725e3..0f2cba3a 100644 --- a/frontend/src/components/ui/Table/linksColumns.tsx +++ b/frontend/src/components/ui/Table/linksColumns.tsx @@ -2,9 +2,11 @@ import { useState, useMemo } from 'react'; import type { MouseEvent } from 'react'; import { Typography } from '@material-tailwind/react'; import type { ColumnDef } from '@tanstack/react-table'; +import toast from 'react-hot-toast'; import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; import DataLinkUsageDialog from '@/components/ui/Dialogs/dataLinkUsage/DataLinkUsageDialog'; +import EditDataLinkLabelDialog from '@/components/ui/Dialogs/EditDataLinkLabel'; import DataLinksActionsMenu from '@/components/ui/Menus/DataLinksActions'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; @@ -37,6 +39,7 @@ export type PathCellValue = { type ProxiedPathRowActionProps = { handleCopyPath: (path: string) => Promise; handleCopyUrl: (item: ProxiedPath) => Promise; + handleEditLabel: () => void; handleUnshare: () => void; handleViewDataLinkUsage: () => void; item: ProxiedPath; @@ -58,6 +61,33 @@ function createPathMap( }; } +function LabelCell({ + item, + onContextMenu +}: { + readonly item: ProxiedPath; + readonly onContextMenu?: ( + e: MouseEvent, + data: { value: string } + ) => void; +}) { + return ( +
{ + e.preventDefault(); + onContextMenu?.(e, { value: item.sharing_name }); + }} + > + + + {item.sharing_name} + + +
+ ); +} + function PathCell({ displayPath, item, @@ -98,11 +128,16 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { const [showDataLinkDialog, setShowDataLinkDialog] = useState(false); const [showDataLinkUsageDialog, setShowDataLinkUsageDialog] = useState(false); + const [showEditLabelDialog, setShowEditLabelDialog] = + useState(false); const { handleDeleteDataLink } = useDataToolLinks(setShowDataLinkDialog); const { pathPreference } = usePreferencesContext(); const { zonesAndFspQuery } = useZoneAndFspMapContext(); - const { deleteProxiedPathMutation, allProxiedPathsQuery } = - useProxiedPathContext(); + const { + deleteProxiedPathMutation, + allProxiedPathsQuery, + updateProxiedPathMutation + } = useProxiedPathContext(); const { handleCopyPath, handleCopyUrl, handleUnshare } = useProxiedPathRow({ setShowDataLinkDialog @@ -122,7 +157,28 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { setShowDataLinkUsageDialog(true); }; + const handleEditLabel = () => { + setShowEditLabelDialog(true); + }; + + const handleConfirmLabel = async (newLabel: string) => { + try { + await updateProxiedPathMutation.mutateAsync({ + sharing_key: item.sharing_key, + sharing_name: newLabel + }); + toast.success('Data link label updated'); + } catch (error) { + const msg = error instanceof Error ? error.message : 'Unknown error'; + toast.error(`Failed to update label: ${msg}`); + } + }; + const menuItems: MenuItem[] = [ + { + name: 'Edit label', + action: (props: ProxiedPathRowActionProps) => props.handleEditLabel() + }, { name: 'Copy path', action: async (props: ProxiedPathRowActionProps) => { @@ -150,6 +206,7 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { const actionProps = { handleCopyPath, handleCopyUrl, + handleEditLabel, handleUnshare, handleViewDataLinkUsage, item, @@ -193,6 +250,15 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { path={item.path} /> ) : null} + {/* Edit label dialog */} + {showEditLabelDialog ? ( + setShowEditLabelDialog(false)} + onConfirm={handleConfirmLabel} + open={showEditLabelDialog} + /> + ) : null}
); } @@ -205,29 +271,11 @@ export function useLinksColumns(): ColumnDef[] { () => [ { accessorKey: 'sharing_name', - header: 'Name', - cell: ({ cell, row, table }) => { + header: 'Label', + cell: ({ row, table }) => { const item = row.original; const onContextMenu = table.options.meta?.onCellContextMenu; - return ( -
{ - e.preventDefault(); - onContextMenu?.(e, { value: item.sharing_name }); - }} - > - - - {item.sharing_name} - - -
- ); + return ; }, enableSorting: true }, From 61a25a27f4c08194d694d2b2fe38d59f4dfff974 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Wed, 8 Apr 2026 11:05:07 -0400 Subject: [PATCH 13/30] chore: fix linter warnings/errors --- frontend/src/components/ui/Dialogs/DataLink.tsx | 9 ++------- frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/frontend/src/components/ui/Dialogs/DataLink.tsx b/frontend/src/components/ui/Dialogs/DataLink.tsx index 981e8a34..64e10be3 100644 --- a/frontend/src/components/ui/Dialogs/DataLink.tsx +++ b/frontend/src/components/ui/Dialogs/DataLink.tsx @@ -106,13 +106,8 @@ function BtnContainer({ children }: { readonly children: ReactNode }) { export default function DataLinkDialog(props: DataLinkDialogProps) { const { fspName, filePath } = useFileBrowserContext(); - const { - pathPreference, - areDataLinksAutomatic, - toggleAutomaticDataLinks, - transparentDataLinks, - toggleTransparentDataLinks - } = usePreferencesContext(); + const { pathPreference, areDataLinksAutomatic, transparentDataLinks } = + usePreferencesContext(); const { zonesAndFspQuery } = useZoneAndFspMapContext(); const [localAreDataLinksAutomatic] = useState(areDataLinksAutomatic); diff --git a/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx b/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx index 78d8a626..c02f2db4 100644 --- a/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx +++ b/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx @@ -42,8 +42,9 @@ export default function EditDataLinkLabelDialog({ Edit Data Link Label - The label is only used in the data links table - it is useful for grouping data links via the sort and search functionality. Changing it will not - affect the sharing URL. + The label is only used in the data links table - it is useful for + grouping data links via the sort and search functionality. Changing it + will not affect the sharing URL. From 02c782221e74579c03af0e4ba9a63e6c757a525d Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 09:57:23 -0400 Subject: [PATCH 14/30] fix: include file share linux_path in transparent link URLs --- fileglancer/server.py | 45 +++++++++++++++++++++++++++++++++-------- tests/test_endpoints.py | 12 +++++------ 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/fileglancer/server.py b/fileglancer/server.py index 1efda94b..e9d01e86 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -92,11 +92,21 @@ def _convert_external_bucket(db_bucket: db.ExternalBucketDB) -> ExternalBucket: ) -def _convert_proxied_path(db_path: db.ProxiedPathDB, external_proxy_url: Optional[HttpUrl]) -> ProxiedPath: +def _get_linux_path(session, fsp_name: str) -> Optional[str]: + """Look up the linux_path for a file share path by name.""" + fsp = db.get_file_share_path(session, fsp_name) + return fsp.linux_path if fsp else None + + +def _convert_proxied_path(db_path: db.ProxiedPathDB, external_proxy_url: Optional[HttpUrl], linux_path: Optional[str] = None) -> ProxiedPath: """Convert a database ProxiedPathDB model to a Pydantic ProxiedPath model""" if external_proxy_url: if db_path.is_transparent: - url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(db_path.path, safe='/')}" + if linux_path: + full_path = f"{linux_path.strip('/')}/{db_path.path}" + else: + full_path = db_path.path + url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(full_path, safe='/')}" else: url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(os.path.basename(db_path.path))}" else: @@ -228,9 +238,16 @@ def try_strip_prefix(captured: str, prefix: str) -> str | None: # with literal % characters instead of proper URL encoding — FastAPI # auto-decodes path params, so we need to match the decoded form too. if proxied_path.is_transparent: - subpath = try_strip_prefix(captured_path, proxied_path.path) + # Transparent links include the linux_path prefix in the URL + fsp_for_prefix = db.get_file_share_path(session, proxied_path.fsp_name) + linux_path = fsp_for_prefix.linux_path if fsp_for_prefix and fsp_for_prefix.linux_path else None + if linux_path: + full_prefix = f"{linux_path.strip('/')}/{proxied_path.path}" + else: + full_prefix = proxied_path.path + subpath = try_strip_prefix(captured_path, full_prefix) if subpath is None: - subpath = try_strip_prefix(captured_path, unquote(proxied_path.path)) + subpath = try_strip_prefix(captured_path, unquote(full_prefix)) else: path_basename = os.path.basename(proxied_path.path) subpath = try_strip_prefix(captured_path, path_basename) @@ -884,7 +901,8 @@ async def create_proxied_path(fsp_name: str = Query(..., description="The name o with _get_user_context(username): # Necessary to validate the user can access the proxied path try: new_path = db.create_proxied_path(session, username, sharing_name, fsp_name, path, is_transparent=is_transparent) - return _convert_proxied_path(new_path, settings.external_proxy_url) + linux_path = _get_linux_path(session, fsp_name) if is_transparent else None + return _convert_proxied_path(new_path, settings.external_proxy_url, linux_path=linux_path) except ValueError as e: logger.error(f"Error creating proxied path: {e}") raise HTTPException(status_code=400, detail=str(e)) @@ -898,7 +916,16 @@ async def get_proxied_paths(fsp_name: str = Query(None, description="The name of with db.get_db_session(settings.db_url) as session: db_proxied_paths = db.get_proxied_paths(session, username, fsp_name, path) - proxied_paths = [_convert_proxied_path(db_path, settings.external_proxy_url) for db_path in db_proxied_paths] + # Cache linux_path lookups by fsp_name for transparent paths + linux_path_cache: Dict[str, Optional[str]] = {} + proxied_paths = [] + for db_path in db_proxied_paths: + lp = None + if db_path.is_transparent: + if db_path.fsp_name not in linux_path_cache: + linux_path_cache[db_path.fsp_name] = _get_linux_path(session, db_path.fsp_name) + lp = linux_path_cache[db_path.fsp_name] + proxied_paths.append(_convert_proxied_path(db_path, settings.external_proxy_url, linux_path=lp)) return ProxiedPathResponse(paths=proxied_paths) @@ -913,7 +940,8 @@ async def get_proxied_path(sharing_key: str = Path(..., description="The sharing raise HTTPException(status_code=404, detail="Proxied path not found for sharing key {sharing_key}") if path.username != username: raise HTTPException(status_code=404, detail="Proxied path not found for username {username} and sharing key {sharing_key}") - return _convert_proxied_path(path, settings.external_proxy_url) + lp = _get_linux_path(session, path.fsp_name) if path.is_transparent else None + return _convert_proxied_path(path, settings.external_proxy_url, linux_path=lp) @app.put("/api/proxied-path/{sharing_key}", description="Update a proxied path by sharing key") @@ -925,7 +953,8 @@ async def update_proxied_path(sharing_key: str = Path(...), with db.get_db_session(settings.db_url) as session: try: updated = db.update_proxied_path(session, username, sharing_key, new_sharing_name=payload.sharing_name) - return _convert_proxied_path(updated, settings.external_proxy_url) + lp = _get_linux_path(session, updated.fsp_name) if updated.is_transparent else None + return _convert_proxied_path(updated, settings.external_proxy_url, linux_path=lp) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 7c9e3527..4fb266dc 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1267,8 +1267,8 @@ def test_create_transparent_proxied_path(test_client, temp_dir): response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}&is_transparent=true") assert response.status_code == 200 data = response.json() - # URL should use full relative path - assert data["url"].endswith(f"/{data['sharing_key']}/a/b/c") + # URL should include linux_path prefix and full relative path + assert data["url"].endswith(f"/{data['sharing_key']}/tempdir/test/path/a/b/c") def test_proxy_resolve_non_transparent(test_client, temp_dir): @@ -1302,8 +1302,8 @@ def test_proxy_resolve_transparent(test_client, temp_dir): assert response.status_code == 200 sharing_key = response.json()["sharing_key"] - # Access through the proxy using full path prefix - response = test_client.get(f"/files/{sharing_key}/a/b/data.txt") + # Access through the proxy using linux_path prefix + full path + response = test_client.get(f"/files/{sharing_key}/tempdir/test/path/a/b/data.txt") assert response.status_code == 200 assert response.text == "transparent hello" @@ -1357,8 +1357,8 @@ def test_proxy_resolve_url_encoded_path_transparent(test_client, temp_dir): assert response.status_code == 200 sharing_key = response.json()["sharing_key"] - # Request with URL-encoded spaces in the full path - response = test_client.get(f"/files/{sharing_key}/my%20folder/sub%20dir/file.txt") + # Request with URL-encoded spaces in the full path (includes linux_path prefix) + response = test_client.get(f"/files/{sharing_key}/tempdir/test/path/my%20folder/sub%20dir/file.txt") assert response.status_code == 200 assert response.text == "encoded transparent hello" From a01aecdee3d712b7abcb65f6b5116afb3d85c14d Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 19:54:58 +0000 Subject: [PATCH 15/30] feat: replace is_transparent with url_prefix for data link URLs Replace the boolean is_transparent flag with a free-form url_prefix string that stores the exact URL path segment appearing after the sharing key. This allows data links to use any arbitrary subpath (directory name, full path, or custom string) rather than being limited to two hardcoded modes. --- ...3c28c4f_add_url_prefix_to_proxied_paths.py | 35 ++++++++ ...de5_add_is_transparent_to_proxied_paths.py | 24 ----- fileglancer/database.py | 6 +- fileglancer/model.py | 3 + fileglancer/server.py | 90 ++++++------------- frontend/src/__tests__/mocks/handlers.ts | 6 +- frontend/src/contexts/ProxiedPathContext.tsx | 5 +- frontend/src/queries/proxiedPathQueries.ts | 56 +----------- tests/test_endpoints.py | 47 +++++----- 9 files changed, 99 insertions(+), 173 deletions(-) create mode 100644 fileglancer/alembic/versions/20b763c28c4f_add_url_prefix_to_proxied_paths.py delete mode 100644 fileglancer/alembic/versions/76858c70bde5_add_is_transparent_to_proxied_paths.py diff --git a/fileglancer/alembic/versions/20b763c28c4f_add_url_prefix_to_proxied_paths.py b/fileglancer/alembic/versions/20b763c28c4f_add_url_prefix_to_proxied_paths.py new file mode 100644 index 00000000..0a507d6e --- /dev/null +++ b/fileglancer/alembic/versions/20b763c28c4f_add_url_prefix_to_proxied_paths.py @@ -0,0 +1,35 @@ +"""add url_prefix to proxied_paths + +Revision ID: 20b763c28c4f +Revises: a3d7cc6e95e8 +Create Date: 2026-04-09 16:11:10.155619 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '20b763c28c4f' +down_revision = 'a3d7cc6e95e8' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column('proxied_paths', sa.Column('url_prefix', sa.String(), server_default='', nullable=False)) + # Backfill: set url_prefix to basename of path for existing rows + proxied_paths = sa.table('proxied_paths', sa.column('path', sa.String), sa.column('url_prefix', sa.String)) + conn = op.get_bind() + rows = conn.execute(sa.select(proxied_paths.c.path).distinct()).fetchall() + for (path,) in rows: + basename = path.rsplit('/', 1)[-1] if '/' in path else path + conn.execute( + proxied_paths.update() + .where(proxied_paths.c.path == path) + .values(url_prefix=basename) + ) + + +def downgrade() -> None: + op.drop_column('proxied_paths', 'url_prefix') \ No newline at end of file diff --git a/fileglancer/alembic/versions/76858c70bde5_add_is_transparent_to_proxied_paths.py b/fileglancer/alembic/versions/76858c70bde5_add_is_transparent_to_proxied_paths.py deleted file mode 100644 index ca564494..00000000 --- a/fileglancer/alembic/versions/76858c70bde5_add_is_transparent_to_proxied_paths.py +++ /dev/null @@ -1,24 +0,0 @@ -"""add is_transparent to proxied_paths - -Revision ID: 76858c70bde5 -Revises: a3d7cc6e95e8 -Create Date: 2026-04-07 14:27:21.377139 - -""" -from alembic import op -import sqlalchemy as sa - - -# revision identifiers, used by Alembic. -revision = '76858c70bde5' -down_revision = 'a3d7cc6e95e8' -branch_labels = None -depends_on = None - - -def upgrade() -> None: - op.add_column('proxied_paths', sa.Column('is_transparent', sa.Boolean(), nullable=False, server_default='0')) - - -def downgrade() -> None: - op.drop_column('proxied_paths', 'is_transparent') \ No newline at end of file diff --git a/fileglancer/database.py b/fileglancer/database.py index 300e5a3f..d24ddf41 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -95,7 +95,7 @@ class ProxiedPathDB(Base): sharing_name = Column(String, nullable=False) fsp_name = Column(String, nullable=False) path = Column(String, nullable=False) - is_transparent = Column(Boolean, nullable=False, server_default="0") + url_prefix = Column(String, nullable=False, server_default="") created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC)) updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) @@ -590,7 +590,7 @@ def _validate_proxied_path(session: Session, fsp_name: str, path: str) -> None: raise ValueError(f"Path {path} is not accessible relative to {fsp_name}") -def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_name: str, path: str, is_transparent: bool = False) -> ProxiedPathDB: +def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_name: str, path: str, url_prefix: str = "") -> ProxiedPathDB: """Create a new proxied path""" _validate_proxied_path(session, fsp_name, path) @@ -602,7 +602,7 @@ def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_ sharing_name=sharing_name, fsp_name=fsp_name, path=path, - is_transparent=is_transparent, + url_prefix=url_prefix, created_at=now, updated_at=now ) diff --git a/fileglancer/model.py b/fileglancer/model.py index 77296665..2b50eb63 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -149,6 +149,9 @@ class ProxiedPath(BaseModel): updated_at: datetime = Field( description="When this proxied path was last updated" ) + url_prefix: str = Field( + description="The URL path prefix that appears after the sharing key in the proxy URL" + ) url: Optional[HttpUrl] = Field( description="The URL for accessing the data via the proxy", default=None diff --git a/fileglancer/server.py b/fileglancer/server.py index e9d01e86..288f2ee2 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -92,23 +92,10 @@ def _convert_external_bucket(db_bucket: db.ExternalBucketDB) -> ExternalBucket: ) -def _get_linux_path(session, fsp_name: str) -> Optional[str]: - """Look up the linux_path for a file share path by name.""" - fsp = db.get_file_share_path(session, fsp_name) - return fsp.linux_path if fsp else None - - -def _convert_proxied_path(db_path: db.ProxiedPathDB, external_proxy_url: Optional[HttpUrl], linux_path: Optional[str] = None) -> ProxiedPath: +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: - if db_path.is_transparent: - if linux_path: - full_path = f"{linux_path.strip('/')}/{db_path.path}" - else: - full_path = db_path.path - url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(full_path, safe='/')}" - else: - url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(os.path.basename(db_path.path))}" + url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(db_path.url_prefix, safe='/')}" else: logger.warning(f"No external proxy URL was provided, proxy links will not be available.") url = None @@ -118,6 +105,7 @@ def _convert_proxied_path(db_path: db.ProxiedPathDB, external_proxy_url: Optiona sharing_name=db_path.sharing_name, fsp_name=db_path.fsp_name, path=db_path.path, + url_prefix=db_path.url_prefix, created_at=db_path.created_at, updated_at=db_path.updated_at, url=url @@ -230,29 +218,13 @@ def try_strip_prefix(captured: str, prefix: str) -> str | None: if not proxied_path: return get_nosuchbucket_response(captured_path), None, "" - # Match captured_path against the expected prefix for this link type. - # Transparent links use the full stored path; non-transparent use only the basename. - # We restrict matching to the link's type to prevent non-transparent links - # from accepting full-path URLs (which would leak directory structure). + # Match captured_path against the stored url_prefix. # The unquote() fallback handles clients like Vol-E viewer that send URLs # with literal % characters instead of proper URL encoding — FastAPI # auto-decodes path params, so we need to match the decoded form too. - if proxied_path.is_transparent: - # Transparent links include the linux_path prefix in the URL - fsp_for_prefix = db.get_file_share_path(session, proxied_path.fsp_name) - linux_path = fsp_for_prefix.linux_path if fsp_for_prefix and fsp_for_prefix.linux_path else None - if linux_path: - full_prefix = f"{linux_path.strip('/')}/{proxied_path.path}" - else: - full_prefix = proxied_path.path - subpath = try_strip_prefix(captured_path, full_prefix) - if subpath is None: - subpath = try_strip_prefix(captured_path, unquote(full_prefix)) - else: - path_basename = os.path.basename(proxied_path.path) - subpath = try_strip_prefix(captured_path, path_basename) - if subpath is None: - subpath = try_strip_prefix(captured_path, unquote(path_basename)) + subpath = try_strip_prefix(captured_path, proxied_path.url_prefix) + if subpath is None: + subpath = try_strip_prefix(captured_path, unquote(proxied_path.url_prefix)) if subpath is None: return get_error_response(404, "NoSuchKey", f"Path mismatch for sharing key {sharing_key}", captured_path), None, "" @@ -892,17 +864,18 @@ 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"), - is_transparent: bool = Query(False, description="Whether to create a transparent link"), + url_prefix: Optional[str] = Query(None, description="The URL path prefix after the sharing key. Defaults to basename of path."), username: str = Depends(get_current_user)): - 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} (transparent={is_transparent})") + if url_prefix is None: + url_prefix = os.path.basename(path) + sharing_name = url_prefix + logger.info(f"Creating proxied path for {username} with sharing name {sharing_name} and fsp_name {fsp_name} and path {path} (url_prefix={url_prefix})") 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: - new_path = db.create_proxied_path(session, username, sharing_name, fsp_name, path, is_transparent=is_transparent) - linux_path = _get_linux_path(session, fsp_name) if is_transparent else None - return _convert_proxied_path(new_path, settings.external_proxy_url, linux_path=linux_path) + new_path = db.create_proxied_path(session, username, sharing_name, fsp_name, path, url_prefix=url_prefix) + return _convert_proxied_path(new_path, settings.external_proxy_url) except ValueError as e: logger.error(f"Error creating proxied path: {e}") raise HTTPException(status_code=400, detail=str(e)) @@ -916,16 +889,7 @@ async def get_proxied_paths(fsp_name: str = Query(None, description="The name of with db.get_db_session(settings.db_url) as session: db_proxied_paths = db.get_proxied_paths(session, username, fsp_name, path) - # Cache linux_path lookups by fsp_name for transparent paths - linux_path_cache: Dict[str, Optional[str]] = {} - proxied_paths = [] - for db_path in db_proxied_paths: - lp = None - if db_path.is_transparent: - if db_path.fsp_name not in linux_path_cache: - linux_path_cache[db_path.fsp_name] = _get_linux_path(session, db_path.fsp_name) - lp = linux_path_cache[db_path.fsp_name] - proxied_paths.append(_convert_proxied_path(db_path, settings.external_proxy_url, linux_path=lp)) + proxied_paths = [_convert_proxied_path(db_path, settings.external_proxy_url) for db_path in db_proxied_paths] return ProxiedPathResponse(paths=proxied_paths) @@ -940,23 +904,23 @@ async def get_proxied_path(sharing_key: str = Path(..., description="The sharing raise HTTPException(status_code=404, detail="Proxied path not found for sharing key {sharing_key}") if path.username != username: raise HTTPException(status_code=404, detail="Proxied path not found for username {username} and sharing key {sharing_key}") - lp = _get_linux_path(session, path.fsp_name) if path.is_transparent else None - return _convert_proxied_path(path, settings.external_proxy_url, linux_path=lp) + return _convert_proxied_path(path, settings.external_proxy_url) @app.put("/api/proxied-path/{sharing_key}", description="Update a proxied path by sharing key") - async def update_proxied_path(sharing_key: str = Path(...), - payload: UpdateProxiedPathPayload = Body(...), + 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"), username: str = Depends(get_current_user)): - # No user context needed -- we only update sharing_name (display only), - # no filesystem access validation required. with db.get_db_session(settings.db_url) as session: - try: - updated = db.update_proxied_path(session, username, sharing_key, new_sharing_name=payload.sharing_name) - lp = _get_linux_path(session, updated.fsp_name) if updated.is_transparent else None - return _convert_proxied_path(updated, settings.external_proxy_url, linux_path=lp) - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) + 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) + return _convert_proxied_path(updated, settings.external_proxy_url) + except ValueError as e: + logger.error(f"Error updating proxied path: {e}") + raise HTTPException(status_code=400, detail=str(e)) @app.delete("/api/proxied-path/{sharing_key}", description="Delete a proxied path by sharing key") diff --git a/frontend/src/__tests__/mocks/handlers.ts b/frontend/src/__tests__/mocks/handlers.ts index 342e89dd..609be704 100644 --- a/frontend/src/__tests__/mocks/handlers.ts +++ b/frontend/src/__tests__/mocks/handlers.ts @@ -20,10 +20,9 @@ export const handlers = [ http.post('/api/proxied-path', ({ request }) => { const url = new URL(request.url); - const isTransparent = url.searchParams.get('is_transparent') === 'true'; const path = url.searchParams.get('path') || '/test/path'; const pathBasename = path.split('/').pop() || path; - const urlSuffix = isTransparent ? path : pathBasename; + const urlPrefix = url.searchParams.get('url_prefix') || pathBasename; return HttpResponse.json({ username: 'testuser', sharing_key: 'testkey', @@ -32,7 +31,8 @@ export const handlers = [ fsp_name: url.searchParams.get('fsp_name') || 'test_fsp', created_at: '2025-07-08T15:56:42.588942', updated_at: '2025-07-08T15:56:42.588942', - url: 'http://127.0.0.1:7878/files/testkey/' + urlSuffix + url: 'http://127.0.0.1:7878/files/testkey/' + urlPrefix, + url_prefix: urlPrefix }); }), diff --git a/frontend/src/contexts/ProxiedPathContext.tsx b/frontend/src/contexts/ProxiedPathContext.tsx index 681cb0dd..3697b68e 100644 --- a/frontend/src/contexts/ProxiedPathContext.tsx +++ b/frontend/src/contexts/ProxiedPathContext.tsx @@ -6,7 +6,6 @@ import { useAllProxiedPathsQuery, useProxiedPathByFspAndPathQuery, useCreateProxiedPathMutation, - useUpdateProxiedPathMutation, useDeleteProxiedPathMutation } from '@/queries/proxiedPathQueries'; @@ -19,6 +18,7 @@ export type ProxiedPath = { created_at: string; updated_at: string; url: string; + url_prefix: string; }; type ProxiedPathContextType = { @@ -30,7 +30,6 @@ type ProxiedPathContextType = { typeof useProxiedPathByFspAndPathQuery >; createProxiedPathMutation: ReturnType; - updateProxiedPathMutation: ReturnType; deleteProxiedPathMutation: ReturnType; }; @@ -73,7 +72,6 @@ export const ProxiedPathProvider = ({ ); const createProxiedPathMutation = useCreateProxiedPathMutation(); - const updateProxiedPathMutation = useUpdateProxiedPathMutation(); const deleteProxiedPathMutation = useDeleteProxiedPathMutation(); const value: ProxiedPathContextType = { @@ -81,7 +79,6 @@ export const ProxiedPathProvider = ({ proxiedPathByFspAndPathQuery, currentDirProxiedPathQuery, createProxiedPathMutation, - updateProxiedPathMutation, deleteProxiedPathMutation }; diff --git a/frontend/src/queries/proxiedPathQueries.ts b/frontend/src/queries/proxiedPathQueries.ts index ce8da50f..ad845c36 100644 --- a/frontend/src/queries/proxiedPathQueries.ts +++ b/frontend/src/queries/proxiedPathQueries.ts @@ -27,7 +27,7 @@ type ProxiedPathApiResponse = { type CreateProxiedPathPayload = { fsp_name: string; path: string; - is_transparent?: boolean; + url_prefix?: string; }; /** @@ -191,8 +191,8 @@ export function useCreateProxiedPathMutation(): UseMutationResult< fsp_name: payload.fsp_name, path: payload.path }; - if (payload.is_transparent !== undefined) { - queryParams.is_transparent = String(payload.is_transparent); + if (payload.url_prefix !== undefined) { + queryParams.url_prefix = payload.url_prefix; } const url = buildUrl('/api/proxied-path', null, queryParams); const proxiedPath = await sendRequestAndThrowForNotOk(url, 'POST'); @@ -295,53 +295,3 @@ export function useDeleteProxiedPathMutation(): UseMutationResult< } }); } - -/** - * Mutation hook for updating a proxied path (label only) - * - * @example - * const mutation = useUpdateProxiedPathMutation(); - * mutation.mutate({ sharing_key: 'abc123', sharing_name: 'My Link' }); - */ -export function useUpdateProxiedPathMutation(): UseMutationResult< - ProxiedPath, - Error, - UpdateProxiedPathPayload, - { previousPaths?: ProxiedPath[] } -> { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async (payload: UpdateProxiedPathPayload) => { - const url = buildUrl('/api/proxied-path/', payload.sharing_key, null); - const proxiedPath = await sendRequestAndThrowForNotOk(url, 'PUT', { - sharing_name: payload.sharing_name - }); - return proxiedPath as ProxiedPath; - }, - onMutate: async () => { - await queryClient.cancelQueries({ queryKey: proxiedPathQueryKeys.all }); - const previousPaths = queryClient.getQueryData( - proxiedPathQueryKeys.list() - ); - return { previousPaths }; - }, - onSuccess: (updatedPath: ProxiedPath) => { - queryClient.setQueryData( - proxiedPathQueryKeys.detail(updatedPath.fsp_name, updatedPath.path), - updatedPath - ); - queryClient.invalidateQueries({ - queryKey: proxiedPathQueryKeys.all - }); - }, - onError: (_err, _variables, context) => { - if (context?.previousPaths) { - queryClient.setQueryData( - proxiedPathQueryKeys.list(), - context.previousPaths - ); - } - } - }); -} diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 4fb266dc..a2cf6076 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -1257,18 +1257,19 @@ def test_create_non_transparent_proxied_path(test_client, temp_dir): assert data["url"].endswith(f"/{data['sharing_key']}/test_proxied_path") -def test_create_transparent_proxied_path(test_client, temp_dir): - """Test creating a transparent link uses full relative path in URL""" +def test_create_proxied_path_with_custom_url_prefix(test_client, temp_dir): + """Test creating a link with a custom url_prefix""" # Create nested dirs for a multi-segment path nested_path = os.path.join(temp_dir, "a", "b", "c") os.makedirs(nested_path, exist_ok=True) path = "a/b/c" - response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}&is_transparent=true") + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}&url_prefix=custom/prefix") assert response.status_code == 200 data = response.json() - # URL should include linux_path prefix and full relative path - assert data["url"].endswith(f"/{data['sharing_key']}/tempdir/test/path/a/b/c") + # URL should use the custom url_prefix + assert data["url"].endswith(f"/{data['sharing_key']}/custom/prefix") + assert data["url_prefix"] == "custom/prefix" def test_proxy_resolve_non_transparent(test_client, temp_dir): @@ -1290,22 +1291,22 @@ def test_proxy_resolve_non_transparent(test_client, temp_dir): assert response.text == "hello" -def test_proxy_resolve_transparent(test_client, temp_dir): - """Test that a transparent link resolves correctly via full path prefix match""" +def test_proxy_resolve_custom_url_prefix(test_client, temp_dir): + """Test that a link with custom url_prefix resolves correctly""" nested_dir = os.path.join(temp_dir, "a", "b") os.makedirs(nested_dir, exist_ok=True) test_file = os.path.join(nested_dir, "data.txt") with open(test_file, "w") as f: - f.write("transparent hello") + f.write("custom prefix hello") - response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=a/b&is_transparent=true") + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=a/b&url_prefix=my/custom/prefix") assert response.status_code == 200 sharing_key = response.json()["sharing_key"] - # Access through the proxy using linux_path prefix + full path - response = test_client.get(f"/files/{sharing_key}/tempdir/test/path/a/b/data.txt") + # Access through the proxy using the custom url_prefix + response = test_client.get(f"/files/{sharing_key}/my/custom/prefix/data.txt") assert response.status_code == 200 - assert response.text == "transparent hello" + assert response.text == "custom prefix hello" def test_proxy_path_boundary_guard(test_client, temp_dir): @@ -1346,21 +1347,21 @@ def test_proxy_resolve_url_encoded_path_non_transparent(test_client, temp_dir): assert response.text == "encoded hello" -def test_proxy_resolve_url_encoded_path_transparent(test_client, temp_dir): - """Test that a transparent link resolves when the path contains URL-encoded characters""" +def test_proxy_resolve_url_encoded_path_custom_prefix(test_client, temp_dir): + """Test that a link with URL-encoded characters in url_prefix resolves correctly""" nested_dir = os.path.join(temp_dir, "my folder", "sub dir") os.makedirs(nested_dir, exist_ok=True) with open(os.path.join(nested_dir, "file.txt"), "w") as f: - f.write("encoded transparent hello") + f.write("encoded custom prefix hello") - response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=my folder/sub dir&is_transparent=true") + response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=my folder/sub dir&url_prefix=my folder/sub dir") assert response.status_code == 200 sharing_key = response.json()["sharing_key"] - # Request with URL-encoded spaces in the full path (includes linux_path prefix) - response = test_client.get(f"/files/{sharing_key}/tempdir/test/path/my%20folder/sub%20dir/file.txt") + # Request with URL-encoded spaces in the url_prefix + response = test_client.get(f"/files/{sharing_key}/my%20folder/sub%20dir/file.txt") assert response.status_code == 200 - assert response.text == "encoded transparent hello" + assert response.text == "encoded custom prefix hello" def test_proxy_resolve_special_characters_in_path(test_client, temp_dir): @@ -1380,19 +1381,19 @@ def test_proxy_resolve_special_characters_in_path(test_client, temp_dir): assert response.text == "special chars" -def test_proxy_reject_transparent_url_on_non_transparent_link(test_client, temp_dir): - """Test that a non-transparent link rejects requests using the full path""" +def test_proxy_reject_mismatched_url_prefix(test_client, temp_dir): + """Test that a link rejects requests with a different prefix than url_prefix""" nested_dir = os.path.join(temp_dir, "a", "b") os.makedirs(nested_dir, exist_ok=True) with open(os.path.join(nested_dir, "file.txt"), "w") as f: f.write("should not be accessible") - # Create non-transparent link for "a/b" + # Create link for "a/b" with default url_prefix (basename "b") response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path=a/b") assert response.status_code == 200 sharing_key = response.json()["sharing_key"] - # Attempt to access via full path (transparent-style) — should be rejected + # Attempt to access via full path "a/b" — should be rejected since url_prefix is just "b" response = test_client.get(f"/files/{sharing_key}/a/b/file.txt") assert response.status_code == 404 From ddd95c4580aac4311b2917d1fe4beea9eb171cf5 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 19:55:11 +0000 Subject: [PATCH 16/30] feat: add three-way data link subpath mode preference Replace the transparentDataLinks boolean preference with a dataLinkSubpathMode preference ('name' | 'full_path' | 'custom'). The preferences page and create data link dialog show a radio group. When custom mode is selected, the dialog shows an editable text input. When automatic links + custom mode are both enabled, a dialog is shown instead of silently creating the link. --- .../src/components/ui/Dialogs/DataLink.tsx | 124 +++++++++++++++--- .../ui/PreferencesPage/DataLinkOptions.tsx | 73 +++++++---- .../ui/PropertiesDrawer/PropertiesDrawer.tsx | 38 +----- frontend/src/contexts/PreferencesContext.tsx | 29 ++-- frontend/src/hooks/useDataToolLinks.ts | 61 +++++++-- frontend/src/queries/preferencesQueries.ts | 13 +- 6 files changed, 241 insertions(+), 97 deletions(-) diff --git a/frontend/src/components/ui/Dialogs/DataLink.tsx b/frontend/src/components/ui/Dialogs/DataLink.tsx index 64e10be3..6a385f97 100644 --- a/frontend/src/components/ui/Dialogs/DataLink.tsx +++ b/frontend/src/components/ui/Dialogs/DataLink.tsx @@ -26,7 +26,7 @@ interface CommonDataLinkDialogProps { interface CreateLinkFromToolsProps extends CommonDataLinkDialogProps { tools: true; action: 'create'; - onConfirm: () => Promise; + onConfirm: (urlPrefixOverride?: string) => Promise; onCancel: () => void; setPendingToolKey: Dispatch>; } @@ -34,7 +34,7 @@ interface CreateLinkFromToolsProps extends CommonDataLinkDialogProps { interface CreateLinkNotFromToolsProps extends CommonDataLinkDialogProps { tools: false; action: 'create'; - onConfirm: () => Promise; + onConfirm: (urlPrefixOverride?: string) => Promise; onCancel: () => void; } @@ -106,23 +106,28 @@ function BtnContainer({ children }: { readonly children: ReactNode }) { export default function DataLinkDialog(props: DataLinkDialogProps) { const { fspName, filePath } = useFileBrowserContext(); - const { pathPreference, areDataLinksAutomatic, transparentDataLinks } = - usePreferencesContext(); + const { + pathPreference, + areDataLinksAutomatic, + dataLinkSubpathMode, + dataLinkCustomSubpath + } = usePreferencesContext(); const { zonesAndFspQuery } = useZoneAndFspMapContext(); const [localAreDataLinksAutomatic] = useState(areDataLinksAutomatic); + const fspKey = + props.action === 'delete' + ? makeMapKey('fsp', props.proxiedPath.fsp_name) + : fspName + ? makeMapKey('fsp', fspName) + : ''; + + const pathFsp = + fspKey && zonesAndFspQuery.isSuccess + ? (zonesAndFspQuery.data[fspKey] as FileSharePath) + : null; + function getDisplayPath(): string { - const fspKey = - props.action === 'delete' - ? makeMapKey('fsp', props.proxiedPath.fsp_name) - : fspName - ? makeMapKey('fsp', fspName) - : ''; - - const pathFsp = - fspKey && zonesAndFspQuery.isSuccess - ? (zonesAndFspQuery.data[fspKey] as FileSharePath) - : null; const targetPath = props.action === 'delete' ? props.proxiedPath.path : filePath; @@ -132,10 +137,49 @@ export default function DataLinkDialog(props: DataLinkDialogProps) { } const displayPath = getDisplayPath(); - // Generate a preview data link URL + // Generate preview components const folderNameOnly = filePath ? filePath.split('/').pop() || filePath : ''; - const pathPortion = transparentDataLinks ? filePath || '' : folderNameOnly; - const dataLinkPreview = `https://...//${pathPortion}`; + const linuxPath = pathFsp?.linux_path; + const transparentPath = + linuxPath && filePath + ? `${linuxPath.replace(/\/+$/, '')}/${filePath}` + : filePath || ''; + + // Custom subpath local state (only used in this dialog, not persisted) + const [customSubpath, setCustomSubpath] = useState( + dataLinkCustomSubpath || folderNameOnly + ); + + // Compute preview based on current mode + function getPreviewPath(): string { + switch (dataLinkSubpathMode) { + case 'full_path': + return transparentPath; + case 'custom': + return customSubpath; + case 'name': + default: + return folderNameOnly; + } + } + const dataLinkPreview = `https://...//${getPreviewPath()}`; + + // Whether this dialog was triggered by automatic+custom mode + const isAutoCustom = + props.action === 'create' && + localAreDataLinksAutomatic && + dataLinkSubpathMode === 'custom'; + + const handleConfirmWithPrefix = async () => { + if (props.action !== 'create') { + return; + } + if (dataLinkSubpathMode === 'custom') { + await props.onConfirm(customSubpath); + } else { + await props.onConfirm(); + } + }; return (
- {props.action === 'create' && localAreDataLinksAutomatic ? ( + {props.action === 'create' && isAutoCustom ? ( + <> + + Set data link name + + + Enter the name for your data link: + + setCustomSubpath(e.target.value)} + type="text" + value={customSubpath} + /> + + {dataLinkPreview} + + + + + + + You're seeing this because you enabled custom data link naming in + your{' '} + + preferences + + . + + + ) : props.action === 'create' && + localAreDataLinksAutomatic && + dataLinkSubpathMode !== 'custom' ? ( <> ) : props.action === 'create' && !localAreDataLinksAutomatic ? ( <> @@ -160,7 +236,7 @@ export default function DataLinkDialog(props: DataLinkDialogProps) { be able to view these data. - +
@@ -168,6 +244,14 @@ export default function DataLinkDialog(props: DataLinkDialogProps) { Data link settings: + {dataLinkSubpathMode === 'custom' ? ( + setCustomSubpath(e.target.value)} + type="text" + value={customSubpath} + /> + ) : null} {dataLinkPreview} diff --git a/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx b/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx index 7075f589..74ceb920 100644 --- a/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx +++ b/frontend/src/components/ui/PreferencesPage/DataLinkOptions.tsx @@ -1,4 +1,5 @@ import toast from 'react-hot-toast'; +import { Typography } from '@material-tailwind/react'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import OptionsSection from '@/components/ui/PreferencesPage/OptionsSection'; @@ -7,14 +8,20 @@ type DataLinkOptionsProps = { readonly checkboxesOnly?: boolean; }; +const SUBPATH_MODES = [ + { value: 'name' as const, label: 'Directory name only' }, + { value: 'full_path' as const, label: 'Full path' }, + { value: 'custom' as const, label: 'Custom' } +]; + export default function DataLinkOptions({ checkboxesOnly = false }: DataLinkOptionsProps) { const { areDataLinksAutomatic, toggleAutomaticDataLinks, - transparentDataLinks, - toggleTransparentDataLinks + dataLinkSubpathMode, + setDataLinkSubpathMode } = usePreferencesContext(); const automaticOption = { @@ -35,29 +42,51 @@ export default function DataLinkOptions({ } }; - const transparentOption = { - checked: transparentDataLinks, - id: 'transparent_data_links', - label: 'Include full path in data links', - onChange: async () => { - const result = await toggleTransparentDataLinks(); - if (result.success) { - toast.success( - transparentDataLinks - ? 'Disabled full path in data links' - : 'Enabled full path in data links' - ); - } else { - toast.error(result.error); - } + const handleSubpathModeChange = async ( + mode: 'name' | 'full_path' | 'custom' + ) => { + const result = await setDataLinkSubpathMode(mode); + if (result.success) { + const label = SUBPATH_MODES.find(m => m.value === mode)?.label; + toast.success(`Data link subpath set to: ${label}`); + } else { + toast.error(result.error); } }; return ( - + <> + +
+ + Link name format: + + {SUBPATH_MODES.map(mode => ( +
+ handleSubpathModeChange(mode.value)} + type="radio" + /> + + {mode.label} + +
+ ))} +
+ ); } diff --git a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx index 18f99a91..b94e9bae 100644 --- a/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx +++ b/frontend/src/components/ui/PropertiesDrawer/PropertiesDrawer.tsx @@ -22,7 +22,6 @@ import TicketDetails from '@/components/ui/PropertiesDrawer/TicketDetails'; import FgTooltip from '@/components/ui/widgets/FgTooltip'; import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; import DataLinkUsageDialog from '@/components/ui/Dialogs/dataLinkUsage/DataLinkUsageDialog'; -import EditDataLinkLabelDialog from '@/components/ui/Dialogs/EditDataLinkLabel'; import TextDialogBtn from '@/components/ui/buttons/DialogTextBtn'; import FgSwitch from '@/components/ui/widgets/FgSwitch'; import { getPreferredPathForDisplay } from '@/utils'; @@ -98,18 +97,16 @@ export default function PropertiesDrawer({ }: PropertiesDrawerProps) { const location = useLocation(); const [showDataLinkDialog, setShowDataLinkDialog] = useState(false); - const [showEditLabelDialog, setShowEditLabelDialog] = - useState(false); const [activeTab, setActiveTab] = useState('overview'); const { fileQuery, fileBrowserState } = useFileBrowserContext(); - const { pathPreference, areDataLinksAutomatic } = usePreferencesContext(); + const { pathPreference, areDataLinksAutomatic, dataLinkSubpathMode } = + usePreferencesContext(); const { ticketByPathQuery } = useTicketContext(); const { allProxiedPathsQuery, proxiedPathByFspAndPathQuery, - deleteProxiedPathMutation, - updateProxiedPathMutation + deleteProxiedPathMutation } = useProxiedPathContext(); const { externalDataUrlQuery } = useExternalBucketContext(); @@ -293,6 +290,7 @@ export default function PropertiesDrawer({ onChange={async () => { if ( areDataLinksAutomatic && + dataLinkSubpathMode !== 'custom' && !proxiedPathByFspAndPathQuery.data ) { await handleCreateDataLink(); @@ -336,14 +334,6 @@ export default function PropertiesDrawer({ proxiedPathByFspAndPathQuery.data?.url)! } /> - {proxiedPathByFspAndPathQuery.data ? ( - - ) : null} ) : null} - {showEditLabelDialog && proxiedPathByFspAndPathQuery.data ? ( - setShowEditLabelDialog(false)} - onConfirm={async (newLabel: string) => { - try { - await updateProxiedPathMutation.mutateAsync({ - sharing_key: proxiedPathByFspAndPathQuery.data!.sharing_key, - sharing_name: newLabel - }); - toast.success('Data link label updated'); - } catch (error) { - const msg = - error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Failed to update label: ${msg}`); - } - }} - open={showEditLabelDialog} - /> - ) : null}
); } diff --git a/frontend/src/contexts/PreferencesContext.tsx b/frontend/src/contexts/PreferencesContext.tsx index 1bec8977..02e499e1 100644 --- a/frontend/src/contexts/PreferencesContext.tsx +++ b/frontend/src/contexts/PreferencesContext.tsx @@ -48,7 +48,8 @@ type PreferencesContextType = { layout: string; hideDotFiles: boolean; areDataLinksAutomatic: boolean; - transparentDataLinks: boolean; + dataLinkSubpathMode: 'name' | 'full_path' | 'custom'; + dataLinkCustomSubpath: string; disableNeuroglancerStateGeneration: boolean; disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; @@ -78,7 +79,9 @@ type PreferencesContextType = { setLayoutWithPropertiesOpen: () => Promise>; toggleHideDotFiles: () => Promise>; toggleAutomaticDataLinks: () => Promise>; - toggleTransparentDataLinks: () => Promise>; + setDataLinkSubpathMode: ( + mode: 'name' | 'full_path' | 'custom' + ) => Promise>; toggleDisableNeuroglancerStateGeneration: () => Promise>; toggleDisableHeuristicalLayerTypeDetection: () => Promise>; toggleUseLegacyMultichannelApproach: () => Promise>; @@ -214,11 +217,18 @@ export const PreferencesProvider = ({ ); }; - const toggleTransparentDataLinks = async (): Promise> => { - return togglePreference( - 'transparentDataLinks', - preferencesQuery.data?.transparentDataLinks ?? false - ); + const setDataLinkSubpathMode = async ( + mode: 'name' | 'full_path' | 'custom' + ): Promise> => { + try { + await updatePreferenceMutation.mutateAsync({ + key: 'dataLinkSubpathMode', + value: mode + }); + return createSuccess(undefined); + } catch (error) { + return handleError(error); + } }; const toggleDisableNeuroglancerStateGeneration = async (): Promise< @@ -533,7 +543,8 @@ export const PreferencesProvider = ({ hideDotFiles: preferencesQuery.data?.hideDotFiles || false, areDataLinksAutomatic: preferencesQuery.data?.areDataLinksAutomatic ?? false, - transparentDataLinks: preferencesQuery.data?.transparentDataLinks ?? false, + dataLinkSubpathMode: preferencesQuery.data?.dataLinkSubpathMode ?? 'name', + dataLinkCustomSubpath: preferencesQuery.data?.dataLinkCustomSubpath ?? '', disableNeuroglancerStateGeneration: preferencesQuery.data?.disableNeuroglancerStateGeneration || false, disableHeuristicalLayerTypeDetection: @@ -565,7 +576,7 @@ export const PreferencesProvider = ({ setLayoutWithPropertiesOpen, toggleHideDotFiles, toggleAutomaticDataLinks, - toggleTransparentDataLinks, + setDataLinkSubpathMode, toggleDisableNeuroglancerStateGeneration, toggleDisableHeuristicalLayerTypeDetection, toggleUseLegacyMultichannelApproach, diff --git a/frontend/src/hooks/useDataToolLinks.ts b/frontend/src/hooks/useDataToolLinks.ts index 767ffab7..baddb2c8 100644 --- a/frontend/src/hooks/useDataToolLinks.ts +++ b/frontend/src/hooks/useDataToolLinks.ts @@ -22,7 +22,7 @@ export default function useDataToolLinks( handleCreateDataLink: (pathOverride?: string) => Promise; handleDeleteDataLink: (proxiedPath: ProxiedPath) => Promise; handleToolClick: (toolKey: PendingToolKey) => Promise; - handleDialogConfirm: () => Promise; + handleDialogConfirm: (urlPrefixOverride?: string) => Promise; handleDialogCancel: () => void; showCopiedTooltip: boolean; }; @@ -34,7 +34,7 @@ export default function useDataToolLinks( handleCreateDataLink: (pathOverride?: string) => Promise; handleDeleteDataLink: (proxiedPath: ProxiedPath) => Promise; handleToolClick: (toolKey: PendingToolKey) => Promise; - handleDialogConfirm: () => Promise; + handleDialogConfirm: (urlPrefixOverride?: string) => Promise; handleDialogCancel: () => void; showCopiedTooltip: boolean; }; @@ -57,13 +57,14 @@ export default function useDataToolLinks( currentDirProxiedPathQuery } = useProxiedPathContext(); - const { areDataLinksAutomatic, transparentDataLinks } = + const { areDataLinksAutomatic, dataLinkSubpathMode, dataLinkCustomSubpath } = usePreferencesContext(); const { externalDataUrlQuery } = useExternalBucketContext(); const { handleCopy, showCopiedTooltip } = useCopyTooltip(); const handleCreateDataLink = async ( - pathOverride?: string + pathOverride?: string, + urlPrefixOverride?: string ): Promise => { const path = pathOverride || fileBrowserState.dataLinkPath; if (!fileQuery.data?.currentFileSharePath) { @@ -76,10 +77,31 @@ export default function useDataToolLinks( } try { + let urlPrefix: string; + if (urlPrefixOverride !== undefined) { + urlPrefix = urlPrefixOverride; + } else { + const linuxPath = fileQuery.data.currentFileSharePath.linux_path; + switch (dataLinkSubpathMode) { + case 'full_path': + urlPrefix = linuxPath + ? `${linuxPath.replace(/\/+$/, '')}/${path}` + : path; + break; + case 'custom': + urlPrefix = dataLinkCustomSubpath || path.split('/').pop() || path; + break; + case 'name': + default: + urlPrefix = path.split('/').pop() || path; + break; + } + } + await createProxiedPathMutation.mutateAsync({ fsp_name: fileQuery.data.currentFileSharePath.name, path, - is_transparent: transparentDataLinks + url_prefix: urlPrefix }); toast.success('Data link created successfully'); await allProxiedPathsQuery.refetch(); @@ -174,7 +196,7 @@ export default function useDataToolLinks( const handleToolClick = async (toolKey: PendingToolKey) => { if (!currentDirProxiedPathQuery.data && !externalDataUrlQuery.data) { - if (areDataLinksAutomatic) { + if (areDataLinksAutomatic && dataLinkSubpathMode !== 'custom') { await createLinkAndExecuteAction(toolKey); } else { setPendingToolKey?.(toolKey); @@ -190,11 +212,32 @@ export default function useDataToolLinks( // First case is for link creation through a data tool button click // Second case is for link creation through the PropertiesDrawer dialog - const handleDialogConfirm = async () => { + const handleDialogConfirm = async (urlPrefixOverride?: string) => { if (pendingToolKey) { - await createLinkAndExecuteAction(); + if (urlPrefixOverride !== undefined) { + const success = await handleCreateDataLink( + fileQuery.data?.currentFileOrFolder?.path, + urlPrefixOverride + ); + if (success) { + // Wait for URLs to update then execute tool action + let attempts = 0; + const maxAttempts = 50; + while (attempts < maxAttempts) { + const currentUrls = currentUrlsRef.current; + if (currentUrls && currentUrls.copy && currentUrls.copy !== '') { + await executeToolAction(pendingToolKey, currentUrls); + break; + } + await new Promise(resolve => setTimeout(resolve, 100)); + attempts++; + } + } + } else { + await createLinkAndExecuteAction(); + } } else { - await handleCreateDataLink(); + await handleCreateDataLink(undefined, urlPrefixOverride); } setShowDataLinkDialog?.(false); }; diff --git a/frontend/src/queries/preferencesQueries.ts b/frontend/src/queries/preferencesQueries.ts index dcffd276..38c7d19f 100644 --- a/frontend/src/queries/preferencesQueries.ts +++ b/frontend/src/queries/preferencesQueries.ts @@ -33,7 +33,8 @@ type PreferencesApiResponse = { layout?: { value: string }; hideDotFiles?: { value: boolean }; areDataLinksAutomatic?: { value: boolean }; - transparentDataLinks?: { value: boolean }; + dataLinkSubpathMode?: { value: string }; + dataLinkCustomSubpath?: { value: string }; disableNeuroglancerStateGeneration?: { value: boolean }; disableHeuristicalLayerTypeDetection?: { value: boolean }; useLegacyMultichannelApproach?: { value: boolean }; @@ -66,7 +67,8 @@ export type PreferencesQueryData = { layout: string; hideDotFiles: boolean; areDataLinksAutomatic: boolean; - transparentDataLinks: boolean; + dataLinkSubpathMode: 'name' | 'full_path' | 'custom'; + dataLinkCustomSubpath: string; disableNeuroglancerStateGeneration: boolean; disableHeuristicalLayerTypeDetection: boolean; useLegacyMultichannelApproach: boolean; @@ -231,7 +233,12 @@ const createTransformPreferences = ( layout: rawData.layout?.value || '', hideDotFiles: rawData.hideDotFiles?.value || false, areDataLinksAutomatic: rawData.areDataLinksAutomatic?.value ?? false, - transparentDataLinks: rawData.transparentDataLinks?.value ?? false, + dataLinkSubpathMode: + (rawData.dataLinkSubpathMode?.value as + | 'name' + | 'full_path' + | 'custom') ?? 'name', + dataLinkCustomSubpath: rawData.dataLinkCustomSubpath?.value ?? '', disableNeuroglancerStateGeneration: rawData.disableNeuroglancerStateGeneration?.value || false, disableHeuristicalLayerTypeDetection: From 02667aa4975a291a4530c25ceeaff4208d590052 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 19:55:23 +0000 Subject: [PATCH 17/30] refactor: remove editable data link label in favor of url_prefix as name The data link "label" is now always set to the url_prefix at creation time and is no longer separately editable. Remove the EditDataLinkLabel dialog, the edit label menu action, and the useUpdateProxiedPathMutation hook. Rename the table column from "Label" to "Name". --- .../componentTests/EditDataLinkLabel.test.tsx | 121 ------------------ .../ui/Dialogs/EditDataLinkLabel.tsx | 79 ------------ .../src/components/ui/Table/linksColumns.tsx | 50 +------- 3 files changed, 5 insertions(+), 245 deletions(-) delete mode 100644 frontend/src/__tests__/componentTests/EditDataLinkLabel.test.tsx delete mode 100644 frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx diff --git a/frontend/src/__tests__/componentTests/EditDataLinkLabel.test.tsx b/frontend/src/__tests__/componentTests/EditDataLinkLabel.test.tsx deleted file mode 100644 index b66c9d51..00000000 --- a/frontend/src/__tests__/componentTests/EditDataLinkLabel.test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import { userEvent } from '@testing-library/user-event'; - -import EditDataLinkLabelDialog from '@/components/ui/Dialogs/EditDataLinkLabel'; - -describe('EditDataLinkLabelDialog', () => { - const mockOnClose = vi.fn(); - const mockOnConfirm = vi.fn().mockResolvedValue(undefined); - - beforeEach(() => { - vi.clearAllMocks(); - }); - - it('renders with current label', () => { - render( - - ); - - const input = screen.getByPlaceholderText('Enter label'); - expect(input).toHaveValue('my-link'); - expect(screen.getByText('Edit Data Link Label')).toBeInTheDocument(); - }); - - it('has Save button disabled when value is unchanged', () => { - render( - - ); - - const saveButton = screen.getByRole('button', { name: /save/i }); - expect(saveButton).toBeDisabled(); - }); - - it('has Save button disabled when input is empty', async () => { - const user = userEvent.setup(); - - render( - - ); - - const input = screen.getByPlaceholderText('Enter label'); - await user.clear(input); - - const saveButton = screen.getByRole('button', { name: /save/i }); - expect(saveButton).toBeDisabled(); - }); - - it('has Save button enabled when value is changed', async () => { - const user = userEvent.setup(); - - render( - - ); - - const input = screen.getByPlaceholderText('Enter label'); - await user.clear(input); - await user.type(input, 'new-name'); - - const saveButton = screen.getByRole('button', { name: /save/i }); - expect(saveButton).not.toBeDisabled(); - }); - - it('calls onConfirm with new label on save', async () => { - const user = userEvent.setup(); - - render( - - ); - - const input = screen.getByPlaceholderText('Enter label'); - await user.clear(input); - await user.type(input, 'new-name'); - await user.click(screen.getByRole('button', { name: /save/i })); - - await waitFor(() => { - expect(mockOnConfirm).toHaveBeenCalledWith('new-name'); - }); - }); - - it('calls onClose when Cancel is clicked', async () => { - const user = userEvent.setup(); - - render( - - ); - - await user.click(screen.getByRole('button', { name: /cancel/i })); - - expect(mockOnClose).toHaveBeenCalled(); - }); -}); diff --git a/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx b/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx deleted file mode 100644 index c02f2db4..00000000 --- a/frontend/src/components/ui/Dialogs/EditDataLinkLabel.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { useState } from 'react'; -import { Button, Typography } from '@material-tailwind/react'; - -import FgDialog from './FgDialog'; - -type EditDataLinkLabelDialogProps = { - readonly open: boolean; - readonly onClose: () => void; - readonly currentLabel: string; - readonly onConfirm: (newLabel: string) => Promise; -}; - -export default function EditDataLinkLabelDialog({ - open, - onClose, - currentLabel, - onConfirm -}: EditDataLinkLabelDialogProps) { - const [label, setLabel] = useState(currentLabel); - const [saving, setSaving] = useState(false); - - const isValid = label.trim().length > 0; - const hasChanged = label !== currentLabel; - - const handleSave = async () => { - if (!isValid || !hasChanged) { - return; - } - setSaving(true); - try { - await onConfirm(label.trim()); - onClose(); - } finally { - setSaving(false); - } - }; - - return ( - -
- - Edit Data Link Label - - - The label is only used in the data links table - it is useful for - grouping data links via the sort and search functionality. Changing it - will not affect the sharing URL. - - setLabel(e.target.value)} - placeholder="Enter label" - type="text" - value={label} - /> -
- - -
-
-
- ); -} diff --git a/frontend/src/components/ui/Table/linksColumns.tsx b/frontend/src/components/ui/Table/linksColumns.tsx index 0f2cba3a..90ecb528 100644 --- a/frontend/src/components/ui/Table/linksColumns.tsx +++ b/frontend/src/components/ui/Table/linksColumns.tsx @@ -2,11 +2,8 @@ import { useState, useMemo } from 'react'; import type { MouseEvent } from 'react'; import { Typography } from '@material-tailwind/react'; import type { ColumnDef } from '@tanstack/react-table'; -import toast from 'react-hot-toast'; - import DataLinkDialog from '@/components/ui/Dialogs/DataLink'; import DataLinkUsageDialog from '@/components/ui/Dialogs/dataLinkUsage/DataLinkUsageDialog'; -import EditDataLinkLabelDialog from '@/components/ui/Dialogs/EditDataLinkLabel'; import DataLinksActionsMenu from '@/components/ui/Menus/DataLinksActions'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; @@ -39,7 +36,6 @@ export type PathCellValue = { type ProxiedPathRowActionProps = { handleCopyPath: (path: string) => Promise; handleCopyUrl: (item: ProxiedPath) => Promise; - handleEditLabel: () => void; handleUnshare: () => void; handleViewDataLinkUsage: () => void; item: ProxiedPath; @@ -61,7 +57,7 @@ function createPathMap( }; } -function LabelCell({ +function NameCell({ item, onContextMenu }: { @@ -128,16 +124,11 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { const [showDataLinkDialog, setShowDataLinkDialog] = useState(false); const [showDataLinkUsageDialog, setShowDataLinkUsageDialog] = useState(false); - const [showEditLabelDialog, setShowEditLabelDialog] = - useState(false); const { handleDeleteDataLink } = useDataToolLinks(setShowDataLinkDialog); const { pathPreference } = usePreferencesContext(); const { zonesAndFspQuery } = useZoneAndFspMapContext(); - const { - deleteProxiedPathMutation, - allProxiedPathsQuery, - updateProxiedPathMutation - } = useProxiedPathContext(); + const { deleteProxiedPathMutation, allProxiedPathsQuery } = + useProxiedPathContext(); const { handleCopyPath, handleCopyUrl, handleUnshare } = useProxiedPathRow({ setShowDataLinkDialog @@ -157,28 +148,7 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { setShowDataLinkUsageDialog(true); }; - const handleEditLabel = () => { - setShowEditLabelDialog(true); - }; - - const handleConfirmLabel = async (newLabel: string) => { - try { - await updateProxiedPathMutation.mutateAsync({ - sharing_key: item.sharing_key, - sharing_name: newLabel - }); - toast.success('Data link label updated'); - } catch (error) { - const msg = error instanceof Error ? error.message : 'Unknown error'; - toast.error(`Failed to update label: ${msg}`); - } - }; - const menuItems: MenuItem[] = [ - { - name: 'Edit label', - action: (props: ProxiedPathRowActionProps) => props.handleEditLabel() - }, { name: 'Copy path', action: async (props: ProxiedPathRowActionProps) => { @@ -206,7 +176,6 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { const actionProps = { handleCopyPath, handleCopyUrl, - handleEditLabel, handleUnshare, handleViewDataLinkUsage, item, @@ -250,15 +219,6 @@ function ActionsCell({ item }: { readonly item: ProxiedPath }) { path={item.path} /> ) : null} - {/* Edit label dialog */} - {showEditLabelDialog ? ( - setShowEditLabelDialog(false)} - onConfirm={handleConfirmLabel} - open={showEditLabelDialog} - /> - ) : null}
); } @@ -271,11 +231,11 @@ export function useLinksColumns(): ColumnDef[] { () => [ { accessorKey: 'sharing_name', - header: 'Label', + header: 'Name', cell: ({ row, table }) => { const item = row.original; const onContextMenu = table.options.meta?.onCellContextMenu; - return ; + return ; }, enableSorting: true }, From 23a6b929bcaca67598a7e1e955244ce342ff193e Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 15:56:41 -0400 Subject: [PATCH 18/30] fix: remove dead code --- frontend/src/queries/proxiedPathQueries.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/frontend/src/queries/proxiedPathQueries.ts b/frontend/src/queries/proxiedPathQueries.ts index ad845c36..604ed1e5 100644 --- a/frontend/src/queries/proxiedPathQueries.ts +++ b/frontend/src/queries/proxiedPathQueries.ts @@ -30,14 +30,6 @@ type CreateProxiedPathPayload = { url_prefix?: string; }; -/** - * Payload for updating a proxied path (label only) - */ -type UpdateProxiedPathPayload = { - sharing_key: string; - sharing_name: string; -}; - /** * Payload for deleting a proxied path */ From 604c672ce9eb3f44a40a7951a37c83008cd37e5f Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 16:08:12 -0400 Subject: [PATCH 19/30] fix: revert test_update_proxied_path to old version --- tests/test_endpoints.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index a2cf6076..972bd67e 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -282,7 +282,7 @@ def test_get_proxied_paths(test_client): def test_update_proxied_path(test_client): - """Test updating a proxied path's sharing name""" + """Test updating a proxied path""" # First, create a proxied path to update path = "test_proxied_path" response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}") @@ -290,13 +290,8 @@ def test_update_proxied_path(test_client): data = response.json() sharing_key = data["sharing_key"] - # Update the sharing name via JSON body - new_sharing_name = "my_new_nickname" - - response = test_client.put(f"/api/proxied-path/{sharing_key}", json={"sharing_name": new_sharing_name}) assert response.status_code == 200 updated_data = response.json() - assert updated_data["sharing_name"] == new_sharing_name def test_delete_proxied_path(test_client): From 6f108a69163176bc75a7637cc616fabf4497eea0 Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 16:14:37 -0400 Subject: [PATCH 20/30] fix: revert all proxied path update-related code --- fileglancer/database.py | 23 +++++++++++++---------- fileglancer/model.py | 7 ------- tests/test_endpoints.py | 22 +++++----------------- 3 files changed, 18 insertions(+), 34 deletions(-) diff --git a/fileglancer/database.py b/fileglancer/database.py index d24ddf41..c2c37a4a 100644 --- a/fileglancer/database.py +++ b/fileglancer/database.py @@ -616,11 +616,14 @@ def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_ return proxied_path + def update_proxied_path(session: Session, username: str, sharing_key: str, - new_sharing_name: Optional[str] = None) -> ProxiedPathDB: - """Update a proxied path (only sharing_name can be changed)""" + new_sharing_name: Optional[str] = None, + new_path: Optional[str] = None, + new_fsp_name: Optional[str] = None) -> ProxiedPathDB: + """Update a proxied path""" proxied_path = get_proxied_path_by_sharing_key(session, sharing_key) if not proxied_path: raise ValueError(f"Proxied path with sharing key {sharing_key} not found") @@ -628,16 +631,16 @@ 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() is needed because the cached object may be detached from a prior session. - # The merged object is re-cached; it will become detached when this session closes, - # which is fine since _get_file_proxy_client only reads cached objects. - proxied_path = session.merge(proxied_path) - - if new_sharing_name is not None: - if not new_sharing_name.strip(): - raise ValueError("Sharing name cannot be empty") + if new_sharing_name: proxied_path.sharing_name = new_sharing_name + if new_fsp_name: + proxied_path.fsp_name = new_fsp_name + + if new_path: + proxied_path.path = new_path + + _validate_proxied_path(session, proxied_path.fsp_name, proxied_path.path) proxied_path.updated_at = datetime.now(UTC) session.commit() diff --git a/fileglancer/model.py b/fileglancer/model.py index 2b50eb63..553ab178 100644 --- a/fileglancer/model.py +++ b/fileglancer/model.py @@ -163,13 +163,6 @@ class ProxiedPathResponse(BaseModel): ) -class UpdateProxiedPathPayload(BaseModel): - sharing_name: Optional[str] = Field( - description="New label for the data link", - default=None - ) - - class ExternalBucket(BaseModel): """An external bucket for S3-compatible storage""" id: int = Field( diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 972bd67e..11f4c5bf 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -290,8 +290,13 @@ def test_update_proxied_path(test_client): data = response.json() sharing_key = data["sharing_key"] + # 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}") assert response.status_code == 200 updated_data = response.json() + assert updated_data["path"] == new_path def test_delete_proxied_path(test_client): @@ -1393,23 +1398,6 @@ def test_proxy_reject_mismatched_url_prefix(test_client, temp_dir): assert response.status_code == 404 -def test_update_sharing_name_does_not_change_url(test_client, temp_dir): - """Test that updating sharing_name doesn't affect the URL""" - path = "test_proxied_path" - response = test_client.post(f"/api/proxied-path?fsp_name=tempdir&path={path}") - assert response.status_code == 200 - original_data = response.json() - original_url = original_data["url"] - - # Update the sharing name - sharing_key = original_data["sharing_key"] - response = test_client.put(f"/api/proxied-path/{sharing_key}", json={"sharing_name": "my_custom_name"}) - assert response.status_code == 200 - updated_data = response.json() - assert updated_data["sharing_name"] == "my_custom_name" - assert updated_data["url"] == original_url - - def test_broken_symlink_in_file_listing(test_client, temp_dir): """Test that broken symlinks appear in /api/files response with correct properties""" # Create a broken symlink pointing to a nonexistent path From 8276dc5f8c64764d20a5ec44dd237a48e0356a9f Mon Sep 17 00:00:00 2001 From: Allison Truhlar Date: Thu, 9 Apr 2026 16:36:27 -0400 Subject: [PATCH 21/30] feat: add client and server-side validation for data link name --- fileglancer/server.py | 24 +++++++++ .../src/components/ui/Dialogs/DataLink.tsx | 52 +++++++++++++++---- frontend/src/hooks/useDataToolLinks.ts | 27 ++++++++++ 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/fileglancer/server.py b/fileglancer/server.py index 288f2ee2..e0c3b4a5 100644 --- a/fileglancer/server.py +++ b/fileglancer/server.py @@ -1,5 +1,6 @@ import logging import os +import re import sys import pwd import grp @@ -112,6 +113,26 @@ def _convert_proxied_path(db_path: db.ProxiedPathDB, external_proxy_url: Optiona ) +# Regex: allow unreserved URI chars (RFC 3986), plus / for path separators and common safe chars +_VALID_URL_PREFIX_RE = re.compile(r'^[A-Za-z0-9\-._~/!@$&\'()*+,;:=]+$') + + +def _validate_url_prefix(url_prefix: str) -> None: + """Validate that a url_prefix is non-empty and contains only URL-safe characters.""" + if not url_prefix or not url_prefix.strip(): + raise HTTPException(status_code=400, detail="Data link name must not be empty") + if not _VALID_URL_PREFIX_RE.match(url_prefix): + invalid_chars = set(c for c in url_prefix if not re.match(r"[A-Za-z0-9\-._~/!@$&'()*+,;:=]", c)) + raise HTTPException( + status_code=400, + detail=f"Data link name contains invalid URL characters: {' '.join(sorted(invalid_chars))}" + ) + if url_prefix.startswith('/') or url_prefix.endswith('/'): + raise HTTPException(status_code=400, detail="Data link name must not start or end with /") + if '//' in url_prefix: + raise HTTPException(status_code=400, detail="Data link name must not contain consecutive slashes") + + def _convert_ticket(db_ticket: db.TicketDB) -> Ticket: return Ticket( username=db_ticket.username, @@ -869,6 +890,7 @@ async def create_proxied_path(fsp_name: str = Query(..., description="The name o if url_prefix is None: url_prefix = os.path.basename(path) + _validate_url_prefix(url_prefix) sharing_name = url_prefix logger.info(f"Creating proxied path for {username} with sharing name {sharing_name} and fsp_name {fsp_name} and path {path} (url_prefix={url_prefix})") with db.get_db_session(settings.db_url) as session: @@ -913,6 +935,8 @@ async def update_proxied_path(sharing_key: str = Path(..., description="The shar 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"), username: str = Depends(get_current_user)): + if sharing_name is not None: + _validate_url_prefix(sharing_name) 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: diff --git a/frontend/src/components/ui/Dialogs/DataLink.tsx b/frontend/src/components/ui/Dialogs/DataLink.tsx index 6a385f97..4e7a3641 100644 --- a/frontend/src/components/ui/Dialogs/DataLink.tsx +++ b/frontend/src/components/ui/Dialogs/DataLink.tsx @@ -1,13 +1,14 @@ /* 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, useMemo, SetStateAction } from 'react'; import type { ReactNode, Dispatch } from 'react'; import { Button, Typography } from '@material-tailwind/react'; import { Link } from 'react-router'; import type { ProxiedPath } from '@/contexts/ProxiedPathContext'; import { useFileBrowserContext } from '@/contexts/FileBrowserContext'; +import { validateUrlPrefix } from '@/hooks/useDataToolLinks'; import { usePreferencesContext } from '@/contexts/PreferencesContext'; import { useZoneAndFspMapContext } from '@/contexts/ZonesAndFspMapContext'; import { getPreferredPathForDisplay, makeMapKey } from '@/utils'; @@ -51,14 +52,17 @@ type DataLinkDialogProps = | DeleteLinkDialogProps; function CreateLinkBtn({ - onConfirm + onConfirm, + disabled }: { readonly onConfirm: () => Promise; + readonly disabled?: boolean; }) { return (