@cytario/web 2.1.8 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/app/.server/auth/README.md +0 -1
- package/app/.server/auth/authMiddleware.ts +6 -28
- package/app/.server/auth/getSessionCredentials.ts +11 -1
- package/app/.server/auth/sessionPolicy.ts +69 -0
- package/app/.server/corsPreflight.ts +136 -0
- package/app/.server/csp.ts +34 -0
- package/app/components/.client/ImageViewer/README.md +2 -2
- package/app/components/DirectoryView/DirectoryViewGrid.tsx +2 -11
- package/app/components/DirectoryView/DirectoryViewTree.tsx +18 -26
- package/app/components/DirectoryView/buildDirectoryTree.ts +89 -42
- package/app/components/DirectoryView/filterNodes.ts +4 -19
- package/app/components/DirectoryView/useLazyTreeNodes.ts +120 -0
- package/app/components/GlobalSearch/GlobalSearch.tsx +8 -3
- package/app/components/GlobalSearch/Suggestions.tsx +21 -3
- package/app/config.ts +0 -7
- package/app/entry.server.tsx +9 -4
- package/app/hooks/useInitConnections.ts +3 -3
- package/app/root.tsx +11 -6
- package/app/routes/connections/connection.form.tsx +9 -3
- package/app/routes/connections/connection.schema.ts +60 -11
- package/app/routes/connections/connections.clientLoader.ts +91 -0
- package/app/routes/connections/connections.loader.ts +19 -39
- package/app/routes/connections/connections.route.tsx +5 -0
- package/app/routes/connections/createConnection.action.ts +39 -13
- package/app/routes/connections/deleteConnection.action.ts +5 -1
- package/app/routes/connections/updateConnection.action.ts +50 -13
- package/app/routes/home/home.route.tsx +5 -1
- package/app/routes/layouts/protected.layout.tsx +15 -1
- package/app/routes/objects/objects.clientLoader.ts +73 -0
- package/app/routes/objects/objects.loader.ts +58 -92
- package/app/routes/objects/objects.route.tsx +41 -40
- package/app/routes/search.route.tsx +133 -28
- package/app/routes.ts +0 -4
- package/app/utils/connectionsStore/useConnectionsStore.ts +25 -62
- package/app/utils/credentialsRefresh.ts +53 -0
- package/app/utils/db/convertCsvToParquet.ts +24 -16
- package/app/utils/db/createDatabase.ts +25 -33
- package/app/utils/db/duckdbBundles.ts +42 -0
- package/app/utils/db/ensureSpatialLoaded.ts +28 -0
- package/app/utils/db/escapeSqlString.ts +4 -0
- package/app/utils/db/getBlobFromObjectNode.ts +15 -36
- package/app/utils/db/getTileDataWasm.ts +4 -5
- package/app/utils/db/sqlQueries.ts +6 -1
- package/app/utils/filterObjects.ts +2 -18
- package/app/utils/limitConcurrency.ts +28 -0
- package/app/utils/listObjectsClient.ts +318 -0
- package/app/utils/listingLimits.ts +7 -0
- package/app/utils/loadConnectionLevel.ts +50 -0
- package/app/utils/localFilesStore/useFileStore.ts +1 -6
- package/app/utils/pathUtils.ts +65 -0
- package/app/utils/resourceId.ts +14 -29
- package/app/utils/s3HostAllowlist.ts +116 -0
- package/app/utils/signedFetch.ts +159 -24
- package/package.json +2 -2
- package/prisma/seed.ts +3 -3
- package/public/duckdb-extensions/checksums.json +12 -0
- package/scripts/download-duckdb-extensions.mjs +188 -0
- package/scripts/prebuild.mjs +18 -10
- package/app/.server/auth/getPresignedUrl.ts +0 -21
- package/app/.server/auth/getS3Client.ts +0 -93
- package/app/routes/presign.route.tsx +0 -42
- package/app/utils/getObjects.ts +0 -24
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type ClientLoaderFunctionArgs } from "react-router";
|
|
2
|
+
|
|
3
|
+
import type { BucketRouteLoaderResponse, loader } from "./objects.loader";
|
|
4
|
+
import { formatTruncationMessage } from "~/utils/listingLimits";
|
|
5
|
+
import { loadConnectionLevel } from "~/utils/loadConnectionLevel";
|
|
6
|
+
import { CorsLikelyError } from "~/utils/signedFetch";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Browser-side `ListObjectsV2` issued directly to S3 via SigV4-signed fetch.
|
|
10
|
+
* Server loader supplies auth + connection metadata only.
|
|
11
|
+
*/
|
|
12
|
+
export const clientLoader = async ({
|
|
13
|
+
request,
|
|
14
|
+
serverLoader,
|
|
15
|
+
}: ClientLoaderFunctionArgs): Promise<BucketRouteLoaderResponse> => {
|
|
16
|
+
const serverData = await serverLoader<typeof loader>();
|
|
17
|
+
|
|
18
|
+
const resolved = { ...serverData, pendingClientLoad: false };
|
|
19
|
+
|
|
20
|
+
if (resolved.serverDeterminedSingleFile) {
|
|
21
|
+
return { ...resolved, nodes: [], isSingleFile: true };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const { nodes, isCapped } = await loadConnectionLevel({
|
|
26
|
+
connectionConfig: resolved.connectionConfig,
|
|
27
|
+
credentials: resolved.credentials,
|
|
28
|
+
connectionName: resolved.connectionName,
|
|
29
|
+
urlPath: resolved.urlPath,
|
|
30
|
+
signal: request.signal,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (nodes.length === 0) {
|
|
34
|
+
return { ...resolved, nodes: [], isSingleFile: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
...resolved,
|
|
39
|
+
nodes,
|
|
40
|
+
...(isCapped
|
|
41
|
+
? {
|
|
42
|
+
notification: {
|
|
43
|
+
message: formatTruncationMessage(resolved.name),
|
|
44
|
+
status: "warning" as const,
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
: {}),
|
|
48
|
+
};
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error("Error in objects clientLoader:", error);
|
|
51
|
+
if (error instanceof CorsLikelyError) {
|
|
52
|
+
return {
|
|
53
|
+
...resolved,
|
|
54
|
+
nodes: [],
|
|
55
|
+
notification: {
|
|
56
|
+
message: `Browser was blocked from reading "${resolved.name}" — likely a CORS misconfiguration on the bucket. Re-check the bucket's CORS policy or contact your administrator.`,
|
|
57
|
+
status: "error",
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
...resolved,
|
|
63
|
+
nodes: [],
|
|
64
|
+
notification: {
|
|
65
|
+
message:
|
|
66
|
+
"We couldn't load the objects for this bucket. Please check your connection or try again later.",
|
|
67
|
+
status: "error",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
clientLoader.hydrate = true;
|
|
@@ -1,39 +1,49 @@
|
|
|
1
|
-
import { _Object } from "@aws-sdk/client-s3";
|
|
2
1
|
import { Credentials } from "@aws-sdk/client-sts";
|
|
3
2
|
import { type LoaderFunctionArgs } from "react-router";
|
|
4
3
|
|
|
5
4
|
import { ConnectionConfig } from "~/.generated/client";
|
|
6
5
|
import { authContext } from "~/.server/auth/authMiddleware";
|
|
7
|
-
import {
|
|
8
|
-
import { buildDirectoryTree, TreeNode } from "~/components/DirectoryView/buildDirectoryTree";
|
|
6
|
+
import { TreeNode } from "~/components/DirectoryView/buildDirectoryTree";
|
|
9
7
|
import { type NotificationInput } from "~/components/Notification/Notification.store";
|
|
10
8
|
import { getConnection } from "~/routes/connections/connections.server";
|
|
11
|
-
import {
|
|
12
|
-
|
|
9
|
+
import {
|
|
10
|
+
ConnectionPrefixError,
|
|
11
|
+
getName,
|
|
12
|
+
prefixSchema,
|
|
13
|
+
resolveConnectionPrefix,
|
|
14
|
+
} from "~/utils/pathUtils";
|
|
13
15
|
import { checkIsPinnedPath } from "~/utils/pinnedPaths.server";
|
|
14
16
|
import { isZarrPath } from "~/utils/zarrUtils";
|
|
15
17
|
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Server-side metadata for an object-browser route. The directory listing
|
|
20
|
+
* itself runs in the browser — see `objects.clientLoader.ts`.
|
|
21
|
+
*/
|
|
22
|
+
export interface BucketRouteServerLoaderResponse {
|
|
17
23
|
connectionName: string;
|
|
18
|
-
nodes: TreeNode[];
|
|
19
24
|
bucketName: string;
|
|
20
|
-
/** URL path segment after /connections/:name/ (relative to connection root) */
|
|
25
|
+
/** URL path segment after /connections/:name/ (relative to connection root). */
|
|
21
26
|
urlPath: string;
|
|
22
|
-
/** Full S3 key (connection prefix + urlPath) */
|
|
27
|
+
/** Full S3 key (connection prefix + urlPath). */
|
|
23
28
|
pathName: string;
|
|
24
29
|
name: string;
|
|
25
|
-
/** True when navigating to a single viewable file (not a directory listing) */
|
|
26
|
-
isSingleFile?: boolean;
|
|
27
|
-
notification?: NotificationInput;
|
|
28
30
|
credentials: Credentials;
|
|
29
31
|
connectionConfig: ConnectionConfig;
|
|
30
32
|
isPinned: boolean;
|
|
33
|
+
/** Set when the URL points at a Zarr directory; the chunk listing is skipped. */
|
|
34
|
+
serverDeterminedSingleFile: boolean;
|
|
35
|
+
/** `true` during SSR; `clientLoader` flips it once the listing resolves. */
|
|
36
|
+
pendingClientLoad: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BucketRouteLoaderResponse extends BucketRouteServerLoaderResponse {
|
|
40
|
+
nodes: TreeNode[];
|
|
41
|
+
/** True when the route should render the viewer rather than a directory listing. */
|
|
42
|
+
isSingleFile?: boolean;
|
|
43
|
+
notification?: NotificationInput;
|
|
31
44
|
}
|
|
32
45
|
|
|
33
|
-
export const loader = async ({
|
|
34
|
-
params,
|
|
35
|
-
context,
|
|
36
|
-
}: LoaderFunctionArgs): Promise<BucketRouteLoaderResponse> => {
|
|
46
|
+
export const loader = async ({ params, context }: LoaderFunctionArgs) => {
|
|
37
47
|
const { user, credentials: connectionsCredentials } = context.get(authContext);
|
|
38
48
|
const { name: connectionName } = params;
|
|
39
49
|
|
|
@@ -49,87 +59,43 @@ export const loader = async ({
|
|
|
49
59
|
const credentials = connectionsCredentials[connectionName];
|
|
50
60
|
if (!credentials) throw new Error(`No credentials for connection: ${connectionName}`);
|
|
51
61
|
|
|
52
|
-
const
|
|
53
|
-
const connPrefix = connectionConfig.prefix?.replace(/\/$/, "") ?? "";
|
|
54
|
-
const pathName = connPrefix ? (urlPath ? `${connPrefix}/${urlPath}` : connPrefix) : urlPath;
|
|
55
|
-
const prefix = getPrefix(pathName);
|
|
56
|
-
const name = getName(pathName, bucketName);
|
|
62
|
+
const rawUrlPath = params["*"] ?? "";
|
|
57
63
|
|
|
58
|
-
const
|
|
64
|
+
const parsed = prefixSchema.safeParse(rawUrlPath);
|
|
65
|
+
if (!parsed.success) {
|
|
66
|
+
throw new Response(parsed.error.issues[0]?.message ?? "Invalid path", { status: 400 });
|
|
67
|
+
}
|
|
59
68
|
|
|
69
|
+
let urlPath: string;
|
|
70
|
+
let pathName: string;
|
|
60
71
|
try {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
if (isZarrPath(pathName)) {
|
|
66
|
-
return {
|
|
67
|
-
credentials,
|
|
68
|
-
connectionConfig,
|
|
69
|
-
isPinned,
|
|
70
|
-
name,
|
|
71
|
-
nodes: [],
|
|
72
|
-
bucketName,
|
|
73
|
-
connectionName,
|
|
74
|
-
pathName,
|
|
75
|
-
urlPath,
|
|
76
|
-
isSingleFile: true,
|
|
77
|
-
};
|
|
72
|
+
({ urlPath, pathName } = resolveConnectionPrefix(connectionConfig.prefix, parsed.data));
|
|
73
|
+
} catch (error) {
|
|
74
|
+
if (error instanceof ConnectionPrefixError) {
|
|
75
|
+
throw new Response(error.message, { status: 400 });
|
|
78
76
|
}
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
const name = getName(pathName, bucketName);
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
s3Client,
|
|
83
|
-
undefined,
|
|
84
|
-
prefix,
|
|
85
|
-
);
|
|
86
|
-
|
|
87
|
-
if (objects.length > 0) {
|
|
88
|
-
const nodes = buildDirectoryTree(objects, connectionName, prefix, urlPath);
|
|
81
|
+
const isPinned = await checkIsPinnedPath(user.sub, connectionName, urlPath);
|
|
82
|
+
const serverDeterminedSingleFile = isZarrPath(pathName);
|
|
89
83
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
84
|
+
// SSR-safe defaults; `clientLoader` overwrites the listing fields after hydration.
|
|
85
|
+
const payload: BucketRouteLoaderResponse = {
|
|
86
|
+
connectionName,
|
|
87
|
+
bucketName,
|
|
88
|
+
urlPath,
|
|
89
|
+
pathName,
|
|
90
|
+
name,
|
|
91
|
+
credentials,
|
|
92
|
+
connectionConfig,
|
|
93
|
+
isPinned,
|
|
94
|
+
serverDeterminedSingleFile,
|
|
95
|
+
pendingClientLoad: true,
|
|
96
|
+
nodes: [],
|
|
97
|
+
isSingleFile: serverDeterminedSingleFile,
|
|
98
|
+
};
|
|
102
99
|
|
|
103
|
-
|
|
104
|
-
return {
|
|
105
|
-
connectionName,
|
|
106
|
-
credentials,
|
|
107
|
-
connectionConfig,
|
|
108
|
-
name,
|
|
109
|
-
nodes: [],
|
|
110
|
-
bucketName,
|
|
111
|
-
urlPath,
|
|
112
|
-
pathName,
|
|
113
|
-
isSingleFile: true,
|
|
114
|
-
isPinned,
|
|
115
|
-
};
|
|
116
|
-
} catch (error) {
|
|
117
|
-
console.error("Error in objects loader:", error);
|
|
118
|
-
return {
|
|
119
|
-
connectionName,
|
|
120
|
-
credentials,
|
|
121
|
-
connectionConfig,
|
|
122
|
-
name,
|
|
123
|
-
nodes: [],
|
|
124
|
-
bucketName,
|
|
125
|
-
urlPath,
|
|
126
|
-
pathName,
|
|
127
|
-
isPinned,
|
|
128
|
-
notification: {
|
|
129
|
-
message:
|
|
130
|
-
"We couldn't load the objects for this bucket. Please check your connection or try again later.",
|
|
131
|
-
status: "error",
|
|
132
|
-
},
|
|
133
|
-
};
|
|
134
|
-
}
|
|
100
|
+
return payload;
|
|
135
101
|
};
|
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { Button, EmptyState } from "@cytario/design";
|
|
2
2
|
import { Ban, Bookmark, BookmarkCheck, Download } from "lucide-react";
|
|
3
|
-
import { lazy, Suspense, useCallback, useEffect } from "react";
|
|
3
|
+
import { lazy, Suspense, useCallback, useEffect, useRef } from "react";
|
|
4
4
|
import {
|
|
5
5
|
type MetaFunction,
|
|
6
6
|
type ShouldRevalidateFunction,
|
|
7
7
|
useFetcher,
|
|
8
8
|
useLoaderData,
|
|
9
9
|
useNavigate,
|
|
10
|
+
useNavigation,
|
|
10
11
|
} from "react-router";
|
|
11
12
|
|
|
13
|
+
import { clientLoader } from "./objects.clientLoader";
|
|
12
14
|
import { type BucketRouteLoaderResponse, loader } from "./objects.loader";
|
|
13
15
|
import { requestDurationMiddleware } from "~/.server/requestDurationMiddleware";
|
|
14
16
|
import { getCrumbs } from "~/components/Breadcrumbs/getCrumbs";
|
|
@@ -30,19 +32,22 @@ import { getName } from "~/utils/pathUtils";
|
|
|
30
32
|
import { constructS3Url } from "~/utils/resourceId";
|
|
31
33
|
import { createSignedFetch } from "~/utils/signedFetch";
|
|
32
34
|
|
|
33
|
-
// Lazy load Viewer to prevent SSR issues with client-only code
|
|
34
35
|
const Viewer = lazy(() =>
|
|
35
36
|
import("~/components/.client/ImageViewer/components/ImageViewer").then((module) => ({
|
|
36
37
|
default: module.Viewer,
|
|
37
38
|
})),
|
|
38
39
|
);
|
|
39
40
|
|
|
40
|
-
export { loader };
|
|
41
|
+
export { clientLoader, loader };
|
|
41
42
|
export type { BucketRouteLoaderResponse };
|
|
42
43
|
|
|
43
44
|
export const middleware = [requestDurationMiddleware];
|
|
44
45
|
|
|
45
|
-
|
|
46
|
+
// Response carries STS credentials — keep it out of every cache between origin
|
|
47
|
+
// and browser.
|
|
48
|
+
export const headers = () => ({ "Cache-Control": "no-store, private" });
|
|
49
|
+
|
|
50
|
+
export const meta: MetaFunction<typeof clientLoader> = ({ loaderData }) => [
|
|
46
51
|
{ title: loaderData?.name ?? "Cytario" },
|
|
47
52
|
];
|
|
48
53
|
|
|
@@ -65,20 +70,9 @@ export const handle = {
|
|
|
65
70
|
},
|
|
66
71
|
};
|
|
67
72
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
* route fires the loader 1.5-3x per client-side navigation — auxiliary
|
|
72
|
-
* fetchers (POST /api/recently-viewed, POST /api/pinned) fire on every
|
|
73
|
-
* file view and would each trigger a full S3 listing re-run.
|
|
74
|
-
*
|
|
75
|
-
* Keying on URL change only also avoids a subtle trap: `formAction` is
|
|
76
|
-
* populated for fetcher submissions too, not just Form submissions on
|
|
77
|
-
* this route, so the obvious `if (formAction) return defaultShouldRevalidate`
|
|
78
|
-
* check would re-trigger revalidation for every aux-fetcher completion.
|
|
79
|
-
* This route has no mutating forms of its own, so skipping the
|
|
80
|
-
* `formAction` branch is safe.
|
|
81
|
-
*/
|
|
73
|
+
// Revalidate only on URL change. Without this, aux fetcher submissions
|
|
74
|
+
// (recently-viewed, pinned) would each retrigger the S3 listing — `formAction`
|
|
75
|
+
// fires for fetcher submissions too, so the usual `if (formAction)` check is unsafe.
|
|
82
76
|
export const shouldRevalidate: ShouldRevalidateFunction = ({ currentUrl, nextUrl }) => {
|
|
83
77
|
if (currentUrl.pathname !== nextUrl.pathname) return true;
|
|
84
78
|
if (currentUrl.search !== nextUrl.search) return true;
|
|
@@ -96,13 +90,13 @@ export default function ObjectsRoute() {
|
|
|
96
90
|
isPinned: loaderIsPinned,
|
|
97
91
|
isSingleFile,
|
|
98
92
|
notification,
|
|
99
|
-
|
|
93
|
+
pendingClientLoad,
|
|
94
|
+
} = useLoaderData<typeof clientLoader>();
|
|
100
95
|
|
|
101
96
|
const viewMode = useLayoutStore((state) => state.viewMode);
|
|
102
97
|
const navigate = useNavigate();
|
|
103
98
|
const { openModal } = useModal();
|
|
104
99
|
|
|
105
|
-
// Handle notifications from loader
|
|
106
100
|
useEffect(() => {
|
|
107
101
|
if (notification) {
|
|
108
102
|
toastBridge.emit({
|
|
@@ -115,12 +109,16 @@ export default function ObjectsRoute() {
|
|
|
115
109
|
const resourceId = `${connectionName}/${urlPath}`;
|
|
116
110
|
const fileType = getFileType(resourceId);
|
|
117
111
|
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
// Server-side loader split for read path is covered by C-81.
|
|
112
|
+
// Defer the submit until navigation goes idle — submitting during hydration
|
|
113
|
+
// races RR's bulk-fetch single-fetch and trips RR issue #13873.
|
|
121
114
|
const recentFetcher = useFetcher();
|
|
115
|
+
const navigation = useNavigation();
|
|
116
|
+
const lastRecentSubmit = useRef<string | null>(null);
|
|
122
117
|
useEffect(() => {
|
|
123
118
|
if (!urlPath) return;
|
|
119
|
+
if (navigation.state !== "idle") return;
|
|
120
|
+
if (lastRecentSubmit.current === resourceId) return;
|
|
121
|
+
lastRecentSubmit.current = resourceId;
|
|
124
122
|
recentFetcher.submit(
|
|
125
123
|
{
|
|
126
124
|
connectionName,
|
|
@@ -130,15 +128,12 @@ export default function ObjectsRoute() {
|
|
|
130
128
|
},
|
|
131
129
|
{ method: "post", action: "/api/recently-viewed" },
|
|
132
130
|
);
|
|
133
|
-
//
|
|
134
|
-
// so a resourceId change guarantees the captured values are fresh. Other deps (recentFetcher,
|
|
135
|
-
// connectionName, urlPath, name, isSingleFile) are stable within the same resourceId.
|
|
131
|
+
// Other deps are stable within the same resourceId.
|
|
136
132
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
137
|
-
}, [resourceId]);
|
|
133
|
+
}, [resourceId, navigation.state]);
|
|
138
134
|
|
|
139
|
-
// Pinning (DB-backed via server action)
|
|
140
135
|
const pinFetcher = useFetcher();
|
|
141
|
-
// Optimistic
|
|
136
|
+
// Optimistic toggle while the request is in flight.
|
|
142
137
|
let isPinned = loaderIsPinned;
|
|
143
138
|
if (pinFetcher.state !== "idle") {
|
|
144
139
|
isPinned = pinFetcher.formMethod?.toLowerCase() === "post";
|
|
@@ -169,7 +164,6 @@ export default function ObjectsRoute() {
|
|
|
169
164
|
}
|
|
170
165
|
}, [connectionName, urlPath, isPinned, nodes, pinFetcher]);
|
|
171
166
|
|
|
172
|
-
// Show directory view when there are multiple objects
|
|
173
167
|
if (nodes.length > 0) {
|
|
174
168
|
return (
|
|
175
169
|
<DirectoryView
|
|
@@ -200,7 +194,6 @@ export default function ObjectsRoute() {
|
|
|
200
194
|
);
|
|
201
195
|
}
|
|
202
196
|
|
|
203
|
-
// Open file viewer when a single file is selected
|
|
204
197
|
if (isSingleFile) {
|
|
205
198
|
const isCsv = fileType === "CSV";
|
|
206
199
|
const isTabularFile = ["CSV", "Parquet", "JSON"].includes(fileType);
|
|
@@ -223,15 +216,10 @@ export default function ObjectsRoute() {
|
|
|
223
216
|
);
|
|
224
217
|
}
|
|
225
218
|
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// format reaches `<Viewer>` once registered. Built-in OME-TIFF /
|
|
229
|
-
// OME-Zarr / TIFF entries flow through the same predicate via
|
|
230
|
-
// `STATIC_FILE_TYPES` in `app/utils/fileType.ts`.
|
|
219
|
+
// Gate on `isImageFile` so plugin-contributed formats reach `<Viewer>`
|
|
220
|
+
// without per-format branching here.
|
|
231
221
|
if (isImageFile(resourceId)) {
|
|
232
|
-
// `pathName`
|
|
233
|
-
// prefix joined with the URL splat server-side — see objects.loader.ts).
|
|
234
|
-
// Feed it directly to `constructS3Url`, which expects a full key.
|
|
222
|
+
// `pathName` already includes the connection prefix.
|
|
235
223
|
const s3Url = constructS3Url(connectionConfig, pathName);
|
|
236
224
|
const signedFetch = createSignedFetch(
|
|
237
225
|
() => useConnectionsStore.getState().connections[connectionName]?.credentials,
|
|
@@ -264,7 +252,20 @@ export default function ObjectsRoute() {
|
|
|
264
252
|
);
|
|
265
253
|
}
|
|
266
254
|
|
|
267
|
-
//
|
|
255
|
+
// Distinguish "still loading" from "loaded but empty" to avoid flashing the
|
|
256
|
+
// empty state during the SSR → hydration handoff.
|
|
257
|
+
if (pendingClientLoad) {
|
|
258
|
+
return (
|
|
259
|
+
<div
|
|
260
|
+
role="status"
|
|
261
|
+
aria-live="polite"
|
|
262
|
+
className="flex h-full items-center justify-center p-8 text-slate-500"
|
|
263
|
+
>
|
|
264
|
+
Loading…
|
|
265
|
+
</div>
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
|
|
268
269
|
return (
|
|
269
270
|
<EmptyState
|
|
270
271
|
title="No objects found in this bucket."
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
import { _Object } from "@aws-sdk/client-s3";
|
|
2
2
|
import { H1 } from "@cytario/design";
|
|
3
|
-
import {
|
|
3
|
+
import { useEffect } from "react";
|
|
4
|
+
import { type ClientLoaderFunctionArgs, useLoaderData } from "react-router";
|
|
4
5
|
|
|
5
6
|
import type { ConnectionConfig } from "~/.generated/client";
|
|
6
|
-
import { authContext } from "~/.server/auth/authMiddleware";
|
|
7
|
-
import { getS3Client } from "~/.server/auth/getS3Client";
|
|
8
7
|
import { Section } from "~/components/Container";
|
|
9
8
|
import { buildDirectoryTree, TreeNode } from "~/components/DirectoryView/buildDirectoryTree";
|
|
10
9
|
import { DirectoryTree } from "~/components/DirectoryView/DirectoryViewTree";
|
|
11
|
-
import {
|
|
10
|
+
import { type NotificationInput } from "~/components/Notification/Notification.store";
|
|
11
|
+
import { toastBridge, toToastVariant } from "~/toast-bridge";
|
|
12
|
+
import { useConnectionsStore } from "~/utils/connectionsStore/useConnectionsStore";
|
|
13
|
+
import { mapWithConcurrency } from "~/utils/limitConcurrency";
|
|
14
|
+
import { listObjectsClient } from "~/utils/listObjectsClient";
|
|
12
15
|
import { getPrefix } from "~/utils/pathUtils";
|
|
16
|
+
import { CorsLikelyError } from "~/utils/signedFetch";
|
|
17
|
+
|
|
18
|
+
const SEARCH_CONCURRENCY = 6;
|
|
13
19
|
|
|
14
20
|
interface ConfigFiles {
|
|
15
21
|
config: ConnectionConfig;
|
|
@@ -20,38 +26,68 @@ interface ConfigFiles {
|
|
|
20
26
|
export interface SearchRouteLoaderResponse {
|
|
21
27
|
searchQuery: string;
|
|
22
28
|
nodes: TreeNode[];
|
|
29
|
+
notification?: NotificationInput;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
export const handle = {
|
|
26
33
|
breadcrumb: () => ({ label: "Search", to: "/search" }),
|
|
27
34
|
};
|
|
28
35
|
|
|
29
|
-
|
|
36
|
+
// Client-only loader — credentials already live in `ConnectionsStore`, so a
|
|
37
|
+
// server loader would only re-ship STS material. Auth still runs via the
|
|
38
|
+
// parent `protected.layout`.
|
|
39
|
+
export const clientLoader = async ({
|
|
30
40
|
request,
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const { user, credentials: connectionsCredentials, connectionConfigs } = context.get(authContext);
|
|
41
|
+
}: ClientLoaderFunctionArgs): Promise<SearchRouteLoaderResponse> => {
|
|
42
|
+
const searchQuery = new URL(request.url).searchParams.get("query") ?? "";
|
|
43
|
+
const connections = useConnectionsStore.getState().connections;
|
|
44
|
+
const signal = request.signal;
|
|
37
45
|
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
const perConnection = await mapWithConcurrency(
|
|
47
|
+
Object.values(connections),
|
|
48
|
+
SEARCH_CONCURRENCY,
|
|
49
|
+
async ({ connectionConfig: config, credentials }) => {
|
|
50
|
+
const prefix = getPrefix(config.prefix);
|
|
51
|
+
try {
|
|
52
|
+
const { contents, isCapped } = await listObjectsClient(config, credentials, {
|
|
53
|
+
query: searchQuery,
|
|
54
|
+
prefix,
|
|
55
|
+
recursive: true,
|
|
56
|
+
signal,
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
config,
|
|
60
|
+
files: contents,
|
|
61
|
+
prefix,
|
|
62
|
+
isCapped,
|
|
63
|
+
error: false,
|
|
64
|
+
corsBlocked: false,
|
|
65
|
+
};
|
|
66
|
+
} catch (error) {
|
|
67
|
+
console.error(`Search failed for connection "${config.name}":`, error);
|
|
68
|
+
return {
|
|
69
|
+
config,
|
|
70
|
+
files: [] as _Object[],
|
|
71
|
+
prefix,
|
|
72
|
+
isCapped: false,
|
|
73
|
+
error: true,
|
|
74
|
+
corsBlocked: error instanceof CorsLikelyError,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
);
|
|
46
79
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
80
|
+
const results: ConfigFiles[] = perConnection
|
|
81
|
+
.filter((r) => r.files.length > 0)
|
|
82
|
+
.map(({ config, files, prefix }) => ({ config, files, prefix }));
|
|
50
83
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
84
|
+
const cappedConnections = perConnection.filter((r) => r.isCapped).map((r) => r.config.name);
|
|
85
|
+
const failedConnections = perConnection
|
|
86
|
+
.filter((r) => r.error && !r.corsBlocked)
|
|
87
|
+
.map((r) => r.config.name);
|
|
88
|
+
const corsBlockedConnections = perConnection
|
|
89
|
+
.filter((r) => r.corsBlocked)
|
|
90
|
+
.map((r) => r.config.name);
|
|
55
91
|
|
|
56
92
|
const nodes: TreeNode[] = results.map(({ config, files, prefix }) => ({
|
|
57
93
|
id: `${config.name}/`,
|
|
@@ -62,11 +98,80 @@ export const loader = async ({
|
|
|
62
98
|
children: buildDirectoryTree(files as _Object[], config.name, prefix ?? ""),
|
|
63
99
|
}));
|
|
64
100
|
|
|
65
|
-
|
|
101
|
+
const notification = buildSearchNotification(
|
|
102
|
+
cappedConnections,
|
|
103
|
+
failedConnections,
|
|
104
|
+
corsBlockedConnections,
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
return { searchQuery, nodes, notification };
|
|
66
108
|
};
|
|
67
109
|
|
|
110
|
+
function quoteJoin(names: readonly string[]): string {
|
|
111
|
+
return names.map((n) => `"${n}"`).join(", ");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildSearchNotification(
|
|
115
|
+
cappedConnections: readonly string[],
|
|
116
|
+
failedConnections: readonly string[],
|
|
117
|
+
corsBlockedConnections: readonly string[],
|
|
118
|
+
): NotificationInput | undefined {
|
|
119
|
+
const hasCapped = cappedConnections.length > 0;
|
|
120
|
+
const hasFailed = failedConnections.length > 0;
|
|
121
|
+
const hasCors = corsBlockedConnections.length > 0;
|
|
122
|
+
|
|
123
|
+
if (!hasCapped && !hasFailed && !hasCors) return undefined;
|
|
124
|
+
|
|
125
|
+
if (hasCors) {
|
|
126
|
+
const parts: string[] = [
|
|
127
|
+
`Browser was blocked from reading ${quoteJoin(corsBlockedConnections)} — likely a CORS misconfiguration on the bucket. Re-check the bucket's CORS policy or contact your administrator.`,
|
|
128
|
+
];
|
|
129
|
+
if (hasFailed) {
|
|
130
|
+
parts.push(`Search also failed for ${quoteJoin(failedConnections)}.`);
|
|
131
|
+
}
|
|
132
|
+
if (hasCapped) {
|
|
133
|
+
parts.push(
|
|
134
|
+
`Results were truncated for ${quoteJoin(cappedConnections)} — refine your query to see more matches.`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return { status: "error", message: parts.join(" ") };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (hasCapped && hasFailed) {
|
|
141
|
+
return {
|
|
142
|
+
status: "error",
|
|
143
|
+
message:
|
|
144
|
+
`Search failed for ${quoteJoin(failedConnections)}. ` +
|
|
145
|
+
`Results were also truncated for ${quoteJoin(cappedConnections)} — refine your query to see more matches.`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (hasFailed) {
|
|
150
|
+
return {
|
|
151
|
+
status: "error",
|
|
152
|
+
message: `Search failed for ${quoteJoin(failedConnections)} — check your connection or try again.`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
status: "warning",
|
|
158
|
+
message: `Search results were truncated for ${quoteJoin(cappedConnections)} — refine your query to see more matches.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
clientLoader.hydrate = true;
|
|
163
|
+
|
|
68
164
|
export default function SearchRoute() {
|
|
69
|
-
const { searchQuery, nodes } = useLoaderData<typeof
|
|
165
|
+
const { searchQuery, nodes, notification } = useLoaderData<typeof clientLoader>();
|
|
166
|
+
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
if (notification) {
|
|
169
|
+
toastBridge.emit({
|
|
170
|
+
variant: toToastVariant(notification.status ?? "info"),
|
|
171
|
+
message: notification.message,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}, [notification]);
|
|
70
175
|
|
|
71
176
|
return (
|
|
72
177
|
<Section>
|