Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
c234a21
feat: add is_transparent column to proxied_paths table
allison-truhlar Apr 7, 2026
d633a44
feat: pass is_transparent when creating proxied paths
allison-truhlar Apr 7, 2026
ec0c0a5
feat: rewrite proxy file resolution for transparent links
allison-truhlar Apr 7, 2026
78d44c4
test: add backend tests for transparent link creation and resolution
allison-truhlar Apr 7, 2026
6b7a94c
refactor: restrict update_proxied_path to sharing_name only
allison-truhlar Apr 7, 2026
a15e5fa
feat: add transparentDataLinks user preference
allison-truhlar Apr 7, 2026
9faf085
feat: add transparent data links option; renamed component to reflect…
allison-truhlar Apr 7, 2026
81baf18
chore: pass is_transparent when posting a new data link
allison-truhlar Apr 7, 2026
d5b828d
feat: rework DataLinkDialog with settings preview
allison-truhlar Apr 7, 2026
b501947
feat: add useUpdateProxiedPathMutation hook
allison-truhlar Apr 7, 2026
85ae848
feat: add EditDataLinkLabelDialog component with tests
allison-truhlar Apr 7, 2026
82cd324
feat: add buttons to access the edit data link label dialog
allison-truhlar Apr 7, 2026
b6ea01f
Merge branch 'main' into transparent-links
allison-truhlar Apr 8, 2026
61a25a2
chore: fix linter warnings/errors
allison-truhlar Apr 8, 2026
02c7822
fix: include file share linux_path in transparent link URLs
allison-truhlar Apr 9, 2026
a01aecd
feat: replace is_transparent with url_prefix for data link URLs
allison-truhlar Apr 9, 2026
ddd95c4
feat: add three-way data link subpath mode preference
allison-truhlar Apr 9, 2026
02667aa
refactor: remove editable data link label in favor of url_prefix as name
allison-truhlar Apr 9, 2026
23a6b92
fix: remove dead code
allison-truhlar Apr 9, 2026
604c672
fix: revert test_update_proxied_path to old version
allison-truhlar Apr 9, 2026
6f108a6
fix: revert all proxied path update-related code
allison-truhlar Apr 9, 2026
8276dc5
feat: add client and server-side validation for data link name
allison-truhlar Apr 9, 2026
7044884
fix: remove stale code
allison-truhlar Apr 9, 2026
29ccb2b
fix: make sure url_prefix is set to new_sharing_name
allison-truhlar Apr 9, 2026
1b87888
fix: URL-encode default url_prefix when derived from path basename
allison-truhlar Apr 9, 2026
aff8e14
fix: remove unused dataLinkCustomSubpath preference
allison-truhlar Apr 9, 2026
64dd2f3
fix: URL-encode url_prefix on the frontend for name and full_path modes
allison-truhlar Apr 9, 2026
926e525
fix: allow percent-encoded characters in url_prefix validation
allison-truhlar Apr 9, 2026
fb6d4b2
fix: revert backend URL-encoding of default url_prefix
allison-truhlar Apr 9, 2026
a86bdad
fix: URL-encode unsafe characters in url_prefix on the backend
allison-truhlar Apr 9, 2026
b2be8b4
fixed toast
krokicki Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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')
26 changes: 13 additions & 13 deletions fileglancer/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -589,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)

Expand All @@ -601,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
)
Expand All @@ -617,27 +619,25 @@ 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")

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()
Expand Down
9 changes: 8 additions & 1 deletion fileglancer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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(
Expand Down
95 changes: 59 additions & 36 deletions fileglancer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -199,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
Expand Down Expand Up @@ -849,14 +875,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}")
Expand Down Expand Up @@ -890,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")
Expand Down Expand Up @@ -970,12 +995,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"),
Expand All @@ -988,7 +1011,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

Expand All @@ -1005,7 +1028,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):
Expand All @@ -1015,14 +1038,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)
Expand Down
Loading
Loading