Skip to content

Commit e3071d8

Browse files
authored
Merge pull request #344 from JaneliaSciComp/transparent-links
feat: add preference to show full file path in data links; add labels for UI display to data links
2 parents e0bca63 + b2be8b4 commit e3071d8

16 files changed

Lines changed: 680 additions & 131 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""add url_prefix to proxied_paths
2+
3+
Revision ID: 20b763c28c4f
4+
Revises: a3d7cc6e95e8
5+
Create Date: 2026-04-09 16:11:10.155619
6+
7+
"""
8+
from alembic import op
9+
import sqlalchemy as sa
10+
11+
12+
# revision identifiers, used by Alembic.
13+
revision = '20b763c28c4f'
14+
down_revision = 'a3d7cc6e95e8'
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
op.add_column('proxied_paths', sa.Column('url_prefix', sa.String(), server_default='', nullable=False))
21+
# Backfill: set url_prefix to basename of path for existing rows
22+
proxied_paths = sa.table('proxied_paths', sa.column('path', sa.String), sa.column('url_prefix', sa.String))
23+
conn = op.get_bind()
24+
rows = conn.execute(sa.select(proxied_paths.c.path).distinct()).fetchall()
25+
for (path,) in rows:
26+
basename = path.rsplit('/', 1)[-1] if '/' in path else path
27+
conn.execute(
28+
proxied_paths.update()
29+
.where(proxied_paths.c.path == path)
30+
.values(url_prefix=basename)
31+
)
32+
33+
34+
def downgrade() -> None:
35+
op.drop_column('proxied_paths', 'url_prefix')

fileglancer/database.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ class ProxiedPathDB(Base):
9595
sharing_name = Column(String, nullable=False)
9696
fsp_name = Column(String, nullable=False)
9797
path = Column(String, nullable=False)
98+
url_prefix = Column(String, nullable=False, server_default="")
9899
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC))
99100
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC))
100101

@@ -589,7 +590,7 @@ def _validate_proxied_path(session: Session, fsp_name: str, path: str) -> None:
589590
raise ValueError(f"Path {path} is not accessible relative to {fsp_name}")
590591

591592

592-
def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_name: str, path: str) -> ProxiedPathDB:
593+
def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_name: str, path: str, url_prefix: str = "") -> ProxiedPathDB:
593594
"""Create a new proxied path"""
594595
_validate_proxied_path(session, fsp_name, path)
595596

@@ -601,6 +602,7 @@ def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_
601602
sharing_name=sharing_name,
602603
fsp_name=fsp_name,
603604
path=path,
605+
url_prefix=url_prefix,
604606
created_at=now,
605607
updated_at=now
606608
)
@@ -614,6 +616,7 @@ def create_proxied_path(session: Session, username: str, sharing_name: str, fsp_
614616
return proxied_path
615617

616618

619+
617620
def update_proxied_path(session: Session,
618621
username: str,
619622
sharing_key: str,
@@ -630,6 +633,7 @@ def update_proxied_path(session: Session,
630633

631634
if new_sharing_name:
632635
proxied_path.sharing_name = new_sharing_name
636+
proxied_path.url_prefix = new_sharing_name
633637

634638
if new_fsp_name:
635639
proxied_path.fsp_name = new_fsp_name

fileglancer/model.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ class ProxiedPath(BaseModel):
135135
description="The sharing key is part of the URL proxy path. It is used to uniquely identify the proxied path."
136136
)
137137
sharing_name: str = Field(
138-
description="The sharing path is part of the URL proxy path. It is mainly used to provide file extension information to the client."
138+
description="A display-only label for the data link. Does not appear in the URL."
139139
)
140140
path: str = Field(
141141
description="The path relative to the file share path mount point"
@@ -149,6 +149,9 @@ class ProxiedPath(BaseModel):
149149
updated_at: datetime = Field(
150150
description="When this proxied path was last updated"
151151
)
152+
url_prefix: str = Field(
153+
description="The URL path prefix that appears after the sharing key in the proxy URL"
154+
)
152155
url: Optional[HttpUrl] = Field(
153156
description="The URL for accessing the data via the proxy",
154157
default=None

fileglancer/server.py

Lines changed: 66 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import logging
22
import os
3+
import re
34
import sys
45
import pwd
56
import grp
@@ -95,7 +96,7 @@ def _convert_external_bucket(db_bucket: db.ExternalBucketDB) -> ExternalBucket:
9596
def _convert_proxied_path(db_path: db.ProxiedPathDB, external_proxy_url: Optional[HttpUrl]) -> ProxiedPath:
9697
"""Convert a database ProxiedPathDB model to a Pydantic ProxiedPath model"""
9798
if external_proxy_url:
98-
url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(db_path.sharing_name)}"
99+
url = f"{external_proxy_url}/{db_path.sharing_key}/{quote(db_path.url_prefix, safe='/')}"
99100
else:
100101
logger.warning(f"No external proxy URL was provided, proxy links will not be available.")
101102
url = None
@@ -105,12 +106,33 @@ def _convert_proxied_path(db_path: db.ProxiedPathDB, external_proxy_url: Optiona
105106
sharing_name=db_path.sharing_name,
106107
fsp_name=db_path.fsp_name,
107108
path=db_path.path,
109+
url_prefix=db_path.url_prefix,
108110
created_at=db_path.created_at,
109111
updated_at=db_path.updated_at,
110112
url=url
111113
)
112114

113115

116+
# Regex: allow unreserved URI chars (RFC 3986), plus / for path separators and common safe chars
117+
_VALID_URL_PREFIX_RE = re.compile(r'^[A-Za-z0-9\-._~/!@$&\'()*+,;:=%]+$')
118+
119+
120+
def _validate_url_prefix(url_prefix: str) -> None:
121+
"""Validate that a url_prefix is non-empty and contains only URL-safe characters."""
122+
if not url_prefix or not url_prefix.strip():
123+
raise HTTPException(status_code=400, detail="Data link name must not be empty")
124+
if not _VALID_URL_PREFIX_RE.match(url_prefix):
125+
invalid_chars = set(c for c in url_prefix if not re.match(r"[A-Za-z0-9\-._~/!@$&'()*+,;:=]", c))
126+
raise HTTPException(
127+
status_code=400,
128+
detail=f"Data link name contains invalid URL characters: {' '.join(sorted(invalid_chars))}"
129+
)
130+
if url_prefix.startswith('/') or url_prefix.endswith('/'):
131+
raise HTTPException(status_code=400, detail="Data link name must not start or end with /")
132+
if '//' in url_prefix:
133+
raise HTTPException(status_code=400, detail="Data link name must not contain consecutive slashes")
134+
135+
114136
def _convert_ticket(db_ticket: db.TicketDB) -> Ticket:
115137
return Ticket(
116138
username=db_ticket.username,
@@ -199,29 +221,43 @@ def _get_user_context(username: str) -> UserContext:
199221
return CurrentUserContext()
200222

201223

202-
def _get_file_proxy_client(sharing_key: str, sharing_name: str) -> Tuple[FileProxyClient, UserContext] | Tuple[Response, None]:
224+
def _get_file_proxy_client(sharing_key: str, captured_path: str) -> Tuple[FileProxyClient | Response, UserContext | None, str]:
225+
"""Resolve a sharing key and captured path to a FileProxyClient.
226+
227+
Returns (client, user_context, subpath) on success, or (error_response, None, "") on failure.
228+
"""
229+
def try_strip_prefix(captured: str, prefix: str) -> str | None:
230+
if captured == prefix:
231+
return ""
232+
if captured.startswith(prefix + "/"):
233+
return captured[len(prefix) + 1:]
234+
return None
235+
203236
with db.get_db_session(settings.db_url) as session:
204237

205238
proxied_path = db.get_proxied_path_by_sharing_key(session, sharing_key)
206239
if not proxied_path:
207-
return get_nosuchbucket_response(sharing_name), None
208-
209-
# Vol-E viewer sends URLs with literal % characters (not URL-encoded)
210-
# FastAPI automatically decodes path parameters - % chars are treated as escapes, creating a garbled sharing_name if they're present
211-
# We therefore need to handle two cases:
212-
# 1. Properly encoded requests (sharing_name matches DB value of proxied_path.sharing_name)
213-
# 2. Vol-E's unencoded requests (unquote(proxied_path.sharing_name) matches the garbled request value)
214-
if proxied_path.sharing_name != sharing_name and unquote(proxied_path.sharing_name) != sharing_name:
215-
return get_error_response(404, "NoSuchKey", f"Sharing name mismatch for sharing key {sharing_key}", sharing_name), None
240+
return get_nosuchbucket_response(captured_path), None, ""
241+
242+
# Match captured_path against the stored url_prefix.
243+
# The unquote() fallback handles clients like Vol-E viewer that send URLs
244+
# with literal % characters instead of proper URL encoding — FastAPI
245+
# auto-decodes path params, so we need to match the decoded form too.
246+
subpath = try_strip_prefix(captured_path, proxied_path.url_prefix)
247+
if subpath is None:
248+
subpath = try_strip_prefix(captured_path, unquote(proxied_path.url_prefix))
249+
if subpath is None:
250+
return get_error_response(404, "NoSuchKey", f"Path mismatch for sharing key {sharing_key}", captured_path), None, ""
216251

217252
fsp = db.get_file_share_path(session, proxied_path.fsp_name)
218253
if not fsp:
219-
return get_error_response(400, "InvalidArgument", f"File share path {proxied_path.fsp_name} not found", sharing_name), None
254+
return get_error_response(400, "InvalidArgument", f"File share path {proxied_path.fsp_name} not found", captured_path), None, ""
220255
# Expand ~ to user's home directory before constructing the mount path
221256
expanded_mount_path = os.path.expanduser(fsp.mount_path)
222257
mount_path = f"{expanded_mount_path}/{proxied_path.path}"
258+
target_name = captured_path.rsplit('/', 1)[-1] if captured_path else os.path.basename(proxied_path.path)
223259
# Use 256KB buffer for better performance on network filesystems
224-
return FileProxyClient(proxy_kwargs={'target_name': sharing_name}, path=mount_path, buffer_size=256*1024), _get_user_context(proxied_path.username)
260+
return FileProxyClient(proxy_kwargs={'target_name': target_name}, path=mount_path, buffer_size=256*1024), _get_user_context(proxied_path.username), subpath
225261

226262

227263
@asynccontextmanager
@@ -849,14 +885,20 @@ async def delete_neuroglancer_short_link(short_key: str = Path(..., description=
849885
description="Create a new proxied path")
850886
async def create_proxied_path(fsp_name: str = Query(..., description="The name of the file share path that this proxied path is associated with"),
851887
path: str = Query(..., description="The path relative to the file share path mount point"),
888+
url_prefix: Optional[str] = Query(None, description="The URL path prefix after the sharing key. Defaults to basename of path."),
852889
username: str = Depends(get_current_user)):
853890

854-
sharing_name = os.path.basename(path)
855-
logger.info(f"Creating proxied path for {username} with sharing name {sharing_name} and fsp_name {fsp_name} and path {path}")
891+
if url_prefix is None:
892+
url_prefix = quote(os.path.basename(path), safe='/')
893+
elif not _VALID_URL_PREFIX_RE.match(url_prefix):
894+
url_prefix = quote(url_prefix, safe='/')
895+
_validate_url_prefix(url_prefix)
896+
sharing_name = url_prefix
897+
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})")
856898
with db.get_db_session(settings.db_url) as session:
857899
with _get_user_context(username): # Necessary to validate the user can access the proxied path
858900
try:
859-
new_path = db.create_proxied_path(session, username, sharing_name, fsp_name, path)
901+
new_path = db.create_proxied_path(session, username, sharing_name, fsp_name, path, url_prefix=url_prefix)
860902
return _convert_proxied_path(new_path, settings.external_proxy_url)
861903
except ValueError as e:
862904
logger.error(f"Error creating proxied path: {e}")
@@ -970,12 +1012,10 @@ async def get_neuroglancer_short_links(request: Request,
9701012
return NeuroglancerShortLinkResponse(links=links)
9711013

9721014

973-
@app.get("/files/{sharing_key}/{sharing_name}")
974-
@app.get("/files/{sharing_key}/{sharing_name}/{path:path}")
1015+
@app.get("/files/{sharing_key}/{path:path}")
9751016
async def target_dispatcher(request: Request,
9761017
sharing_key: str,
977-
sharing_name: str,
978-
path: str | None = '',
1018+
path: str = '',
9791019
list_type: Optional[int] = Query(None, alias="list-type"),
9801020
continuation_token: Optional[str] = Query(None, alias="continuation-token"),
9811021
delimiter: Optional[str] = Query(None, alias="delimiter"),
@@ -988,7 +1028,7 @@ async def target_dispatcher(request: Request,
9881028
if 'acl' in request.query_params:
9891029
return get_read_access_acl()
9901030

991-
client, ctx = _get_file_proxy_client(sharing_key, sharing_name)
1031+
client, ctx, subpath = _get_file_proxy_client(sharing_key, path)
9921032
if isinstance(client, Response):
9931033
return client
9941034

@@ -1005,7 +1045,7 @@ async def target_dispatcher(request: Request,
10051045
# Open file in user context, then immediately exit
10061046
# The file descriptor retains access rights after we switch back to root
10071047
with ctx:
1008-
handle = await client.open_object(path, range_header)
1048+
handle = await client.open_object(subpath, range_header)
10091049

10101050
# Context exited! Now stream without holding the lock
10111051
if isinstance(handle, ObjectHandle):
@@ -1015,14 +1055,14 @@ async def target_dispatcher(request: Request,
10151055
return handle
10161056

10171057

1018-
@app.head("/files/{sharing_key}/{sharing_name}/{path:path}")
1019-
async def head_object(sharing_key: str, sharing_name: str, path: str):
1058+
@app.head("/files/{sharing_key}/{path:path}")
1059+
async def head_object(sharing_key: str, path: str = ''):
10201060
try:
1021-
client, ctx = _get_file_proxy_client(sharing_key, sharing_name)
1061+
client, ctx, subpath = _get_file_proxy_client(sharing_key, path)
10221062
if isinstance(client, Response):
10231063
return client
10241064
with ctx:
1025-
return await client.head_object(path)
1065+
return await client.head_object(subpath)
10261066
except:
10271067
logger.opt(exception=sys.exc_info()).info("Error requesting head")
10281068
return get_error_response(500, "InternalError", "Error requesting HEAD", path)

frontend/src/__tests__/mocks/handlers.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,16 +18,36 @@ export const handlers = [
1818
return HttpResponse.json({ paths: [] }, { status: 200 });
1919
}),
2020

21-
http.post('/api/proxied-path', () => {
21+
http.post('/api/proxied-path', ({ request }) => {
22+
const url = new URL(request.url);
23+
const path = url.searchParams.get('path') || '/test/path';
24+
const pathBasename = path.split('/').pop() || path;
25+
const urlPrefix = url.searchParams.get('url_prefix') || pathBasename;
2226
return HttpResponse.json({
2327
username: 'testuser',
2428
sharing_key: 'testkey',
25-
sharing_name: 'testshare',
29+
sharing_name: pathBasename,
30+
path: path,
31+
fsp_name: url.searchParams.get('fsp_name') || 'test_fsp',
32+
created_at: '2025-07-08T15:56:42.588942',
33+
updated_at: '2025-07-08T15:56:42.588942',
34+
url: 'http://127.0.0.1:7878/files/testkey/' + urlPrefix,
35+
url_prefix: urlPrefix
36+
});
37+
}),
38+
39+
http.put('/api/proxied-path/:sharingKey', async ({ params, request }) => {
40+
const { sharingKey } = params;
41+
const body = (await request.json()) as { sharing_name?: string };
42+
return HttpResponse.json({
43+
username: 'testuser',
44+
sharing_key: sharingKey,
45+
sharing_name: body.sharing_name || 'testshare',
2646
path: '/test/path',
2747
fsp_name: 'test_fsp',
2848
created_at: '2025-07-08T15:56:42.588942',
2949
updated_at: '2025-07-08T15:56:42.588942',
30-
url: 'http://127.0.0.1:7878/files/testkey/test/path'
50+
url: 'http://127.0.0.1:7878/files/' + sharingKey + '/path'
3151
});
3252
}),
3353

0 commit comments

Comments
 (0)