Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
57 changes: 52 additions & 5 deletions fileglancer/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,24 @@ def _validate_url_prefix(url_prefix: str) -> None:
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")
# `.` and `..` get collapsed by URL normalization at the recipient,
# which breaks key/path resolution when the link is opened.
if any(seg in (".", "..") for seg in url_prefix.split('/')):
raise HTTPException(status_code=400, detail="Data link name must not contain '.' or '..' segments")


def _normalize_proxied_path(path: str) -> str:
"""Normalize an FSP-relative path for a proxied path record.

The file browser surfaces the FSP root as "." (Filestore returns that as
rel_path). Strip a leading "./" and treat "." as "" so FSP-root data links
don't embed a literal "." in their share URL.
"""
if path == "." or path == "./":
return ""
if path.startswith("./"):
return path[2:]
return path


def _convert_ticket(db_ticket: db.TicketDB) -> Ticket:
Expand Down Expand Up @@ -276,6 +294,10 @@ def _resolve_proxy_info(sharing_key: str, captured_path: str) -> Tuple[dict | Re
Returns (info_dict, subpath) on success, or (error_response, "") on failure.
"""
def try_strip_prefix(captured: str, prefix: str) -> str | None:
# Empty prefix (e.g. legacy records or FSP-root links): the entire
# captured path is the subpath.
if not prefix:
return captured
if captured == prefix:
return ""
if captured.startswith(prefix + "/"):
Expand All @@ -288,18 +310,25 @@ def try_strip_prefix(captured: str, prefix: str) -> str | None:
if not proxied_path:
return get_nosuchbucket_response(captured_path), ""

subpath = try_strip_prefix(captured_path, proxied_path.url_prefix)
# Treat legacy "." (FSP-root sentinel) as empty so old records that
# were created before _normalize_proxied_path still resolve.
stored_path = "" if proxied_path.path == "." else proxied_path.path
stored_prefix = "" if proxied_path.url_prefix == "." else proxied_path.url_prefix

subpath = try_strip_prefix(captured_path, stored_prefix)
if subpath is None:
subpath = try_strip_prefix(captured_path, unquote(proxied_path.url_prefix))
subpath = try_strip_prefix(captured_path, unquote(stored_prefix))
if subpath is None:
return get_error_response(404, "NoSuchKey", f"Path mismatch for sharing key {sharing_key}", captured_path), ""

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", captured_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)
# For FSP-root links (empty path) use the mount path directly to
# avoid a stray trailing slash in mount_path.
mount_path = f"{expanded_mount_path}/{stored_path}" if stored_path else expanded_mount_path
target_name = captured_path.rsplit('/', 1)[-1] if captured_path else (os.path.basename(stored_path) or fsp.name)
return {
"mount_path": mount_path,
"target_name": target_name,
Expand Down Expand Up @@ -980,8 +1009,17 @@ async def create_proxied_path(fsp_name: str = Query(..., description="The name o
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)):

# Normalize the FSP-relative path: the file browser surfaces the FSP
# root as "." (Filestore returns that as rel_path), but using "." in a
# share URL gets collapsed by URL normalization at the recipient,
# producing path mismatch / NoSuchBucket errors when the link is opened.
path = _normalize_proxied_path(path)

if url_prefix is None:
url_prefix = quote(os.path.basename(path), safe='/')
# basename("") is "", which would fail validation, so fall back to
# the FSP name for FSP-root links.
default_prefix = os.path.basename(path) or fsp_name
url_prefix = quote(default_prefix, safe='/')
elif not _VALID_URL_PREFIX_RE.match(url_prefix):
url_prefix = quote(url_prefix, safe='/')
_validate_url_prefix(url_prefix)
Expand All @@ -1007,6 +1045,13 @@ async def get_proxied_paths(fsp_name: str = Query(None, description="The name of
path: str = Query(None, description="The path being proxied"),
username: str = Depends(get_current_user)):

# The file browser surfaces the FSP root as ".", but we normalize "."
# to "" on write — apply the same normalization on lookup so the
# sidebar's "is there a link for the current folder?" query matches
# the stored row.
if path is not None:
path = _normalize_proxied_path(path)

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]
Expand All @@ -1033,6 +1078,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 path is not None:
path = _normalize_proxied_path(path)
# If path or fsp_name is changing, validate access via worker
if path is not None or fsp_name is not None:
with db.get_db_session(settings.db_url) as session:
Expand Down
73 changes: 73 additions & 0 deletions frontend/src/__tests__/componentTests/DataLink.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,76 @@ Could not create data link`
});
});
});

describe('Data Link dialog at FSP root', () => {
beforeEach(async () => {
vi.clearAllMocks();

// Render at the FSP root (no subpath). Filestore surfaces this as ".",
// which the create flow normalizes to "" before falling back to the FSP
// name for the URL's trailing segment.
render(<TestDataLinkComponent />, {
initialEntries: ['/browse/test_fsp']
});

await waitFor(() => {
expect(
screen.getByText('Are you sure you want to create a data link?')
).toBeInTheDocument();
});
});

it('previews the FSP name as the url_prefix fallback', async () => {
const user = userEvent.setup();

// The preview lives inside the collapsed "Advanced settings" accordion.
await user.click(screen.getByText('Advanced settings'));

await waitFor(() => {
expect(
screen.getByText((content: string) =>
content.includes('https://.../<key>/test_fsp')
)
).toBeInTheDocument();
});
});

it('submits the FSP name as the url_prefix for an FSP-root link', async () => {
const { server } = await import('@/__tests__/mocks/node');
const { http, HttpResponse } = await import('msw');

let capturedUrlPrefix: string | null = null;
let capturedPath: string | null = null;
server.use(
http.post('/api/proxied-path', ({ request }) => {
const url = new URL(request.url);
capturedUrlPrefix = url.searchParams.get('url_prefix');
capturedPath = url.searchParams.get('path');
return HttpResponse.json({
username: 'testuser',
sharing_key: 'testkey',
sharing_name: 'test_fsp',
path: capturedPath ?? '',
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/' + capturedUrlPrefix,
url_prefix: capturedUrlPrefix
});
})
);

const user = userEvent.setup();
await user.click(screen.getByText('Create Data Link'));

await waitFor(() => {
expect(toast.success).toHaveBeenCalledWith(
'Data link created successfully'
);
});
// "." is normalized to "" for the backend, and the basename of "" falls
// back to the FSP name so the share URL keeps a meaningful trailing segment.
expect(capturedPath).toBe('');
expect(capturedUrlPrefix).toBe('test_fsp');
});
});
22 changes: 17 additions & 5 deletions frontend/src/components/ui/Dialogs/DataLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { makeMapKey } from '@/utils';
import {
getPreferredPathForDisplay,
joinPaths,
normalizeFspRootPath,
normalizePosixStylePath
} from '@/utils/pathHandling';
import type { FileSharePath } from '@/shared.types';
Expand Down Expand Up @@ -165,13 +166,24 @@ export default function DataLinkDialog(props: DataLinkDialogProps) {
}
const displayPath = getDisplayPath();

// Filestore returns the FSP root as ".", but that gets collapsed by URL
// normalization at the recipient. Treat it as empty so previews and the
// submitted url_prefix fall back to the FSP name (which the create flow
// does too — see useDataToolLinks.handleCreateDataLink).
const normalizedFilePath = normalizeFspRootPath(filePath);
const fspNameForFallback = pathFsp?.name ?? '';
// Generate preview components
const folderNameOnly = filePath ? filePath.split('/').pop() || filePath : '';
const folderNameOnly = normalizedFilePath
? normalizedFilePath.split('/').pop() || normalizedFilePath
: fspNameForFallback;
const linuxPath = pathFsp?.linux_path;
const transparentPath =
linuxPath && filePath
? normalizePosixStylePath(joinPaths(linuxPath, filePath))
: filePath || '';
const transparentPath = linuxPath
? normalizePosixStylePath(
normalizedFilePath
? joinPaths(linuxPath, normalizedFilePath)
: linuxPath
)
: normalizedFilePath || fspNameForFallback;

// Custom subpath local state (only used in this dialog, not persisted)
const [customSubpath, setCustomSubpath] = useState(folderNameOnly);
Expand Down
25 changes: 19 additions & 6 deletions frontend/src/hooks/useDataToolLinks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useFileBrowserContext } from '@/contexts/FileBrowserContext';
import {
escapePathForUrl,
joinPaths,
normalizeFspRootPath,
normalizePosixStylePath
} from '@/utils/pathHandling';
import useCopyTooltip from './useCopyTooltip';
Expand All @@ -35,6 +36,11 @@ export function validateUrlPrefix(value: string): string | null {
if (value.includes('//')) {
return 'Data link name must not contain consecutive slashes';
}
// `.` and `..` get collapsed by URL normalization at the recipient,
// which breaks key/path resolution when the link is opened.
if (value.split('/').some(seg => seg === '.' || seg === '..')) {
return "Data link name must not contain '.' or '..' segments";
}
return null;
}

Expand Down Expand Up @@ -92,15 +98,20 @@ export default function useDataToolLinks(
pathOverride?: string,
urlPrefixOverride?: string
): Promise<boolean> => {
const path = pathOverride || fileBrowserState.dataLinkPath;
const rawPath = pathOverride ?? fileBrowserState.dataLinkPath;
if (!fileQuery.data?.currentFileSharePath) {
toast.error('No file share path selected');
return false;
}
if (!path) {
if (rawPath === null || rawPath === undefined) {
toast.error('No folder selected');
return false;
}
// Filestore returns the FSP root as ".", but a literal "." in the share
// URL gets collapsed by URL normalization at the recipient, producing
// path mismatch / NoSuchBucket errors. Send "" to the backend instead.
const path = normalizeFspRootPath(rawPath);
const fspName = fileQuery.data.currentFileSharePath.name;

try {
let urlPrefix: string;
Expand All @@ -112,16 +123,18 @@ export default function useDataToolLinks(
case 'full_path':
urlPrefix = escapePathForUrl(
normalizePosixStylePath(
linuxPath ? joinPaths(linuxPath, path) : path
linuxPath ? joinPaths(linuxPath, path) : path || fspName
)
);
break;
case 'custom':
urlPrefix = path.split('/').pop() || path;
urlPrefix = path.split('/').pop() || fspName;
break;
case 'name':
default:
urlPrefix = escapePathForUrl(path.split('/').pop() || path);
// basename of "" is "" — fall back to the FSP name for FSP-root
// links so the URL still has a meaningful trailing segment.
urlPrefix = escapePathForUrl(path.split('/').pop() || fspName);
break;
}
}
Expand All @@ -133,7 +146,7 @@ export default function useDataToolLinks(
}

await createProxiedPathMutation.mutateAsync({
fsp_name: fileQuery.data.currentFileSharePath.name,
fsp_name: fspName,
path,
url_prefix: urlPrefix
});
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/utils/pathHandling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ function joinPaths(...paths: string[]): string {
return path.posix.join(...paths.map(path => path?.trim() ?? ''));
}

/**
* Normalizes the Filestore FSP-root path (".") to an empty string.
*
* Filestore returns the FSP root as ".", but a literal "." in a share URL gets
* collapsed by URL normalization at the recipient, producing path mismatch /
* NoSuchBucket errors. Callers building data links should send "" to the
* backend instead and fall back to the FSP name for the URL's trailing segment.
* Example:
* normalizeFspRootPath('.'); // Returns ''
* normalizeFspRootPath('my_folder/my_zarr'); // Returns 'my_folder/my_zarr'
*/
function normalizeFspRootPath(filePath: string | null | undefined): string {
return !filePath || filePath === '.' ? '' : filePath;
}

/**
* Constructs a sharable URL to access file contents from the browser with the Fileglancer API.
* If no filePath is provided, it returns the endpoint URL with the FSP path appended - this is the base URL.
Expand Down Expand Up @@ -266,6 +281,7 @@ export {
joinPaths,
makeBrowseLink,
makePathSegmentArray,
normalizeFspRootPath,
normalizePosixStylePath,
removeLastSegmentFromPath,
removeTrailingSlashes,
Expand Down
Loading
Loading