11import logging
22import os
3+ import re
34import sys
45import pwd
56import grp
@@ -95,7 +96,7 @@ def _convert_external_bucket(db_bucket: db.ExternalBucketDB) -> ExternalBucket:
9596def _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+
114136def _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 )
0 commit comments