@cytario/web 2.1.7 → 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.
Files changed (63) hide show
  1. package/README.md +1 -1
  2. package/app/.server/auth/README.md +0 -1
  3. package/app/.server/auth/authMiddleware.ts +6 -28
  4. package/app/.server/auth/getSessionCredentials.ts +11 -1
  5. package/app/.server/auth/sessionPolicy.ts +69 -0
  6. package/app/.server/corsPreflight.ts +136 -0
  7. package/app/.server/csp.ts +34 -0
  8. package/app/components/.client/ImageViewer/README.md +2 -2
  9. package/app/components/DirectoryView/DirectoryViewGrid.tsx +2 -11
  10. package/app/components/DirectoryView/DirectoryViewTree.tsx +18 -26
  11. package/app/components/DirectoryView/buildDirectoryTree.ts +89 -42
  12. package/app/components/DirectoryView/filterNodes.ts +4 -19
  13. package/app/components/DirectoryView/useLazyTreeNodes.ts +120 -0
  14. package/app/components/GlobalSearch/GlobalSearch.tsx +8 -3
  15. package/app/components/GlobalSearch/Suggestions.tsx +21 -3
  16. package/app/config.ts +0 -7
  17. package/app/entry.server.tsx +9 -4
  18. package/app/hooks/useInitConnections.ts +3 -3
  19. package/app/root.tsx +11 -6
  20. package/app/routes/connections/connection.form.tsx +9 -3
  21. package/app/routes/connections/connection.schema.ts +60 -11
  22. package/app/routes/connections/connections.clientLoader.ts +91 -0
  23. package/app/routes/connections/connections.loader.ts +19 -39
  24. package/app/routes/connections/connections.route.tsx +5 -0
  25. package/app/routes/connections/createConnection.action.ts +39 -13
  26. package/app/routes/connections/deleteConnection.action.ts +5 -1
  27. package/app/routes/connections/updateConnection.action.ts +50 -13
  28. package/app/routes/home/home.route.tsx +5 -1
  29. package/app/routes/layouts/protected.layout.tsx +15 -1
  30. package/app/routes/objects/objects.clientLoader.ts +73 -0
  31. package/app/routes/objects/objects.loader.ts +58 -92
  32. package/app/routes/objects/objects.route.tsx +41 -40
  33. package/app/routes/search.route.tsx +133 -28
  34. package/app/routes.ts +0 -4
  35. package/app/utils/connectionsStore/useConnectionsStore.ts +25 -62
  36. package/app/utils/credentialsRefresh.ts +53 -0
  37. package/app/utils/db/convertCsvToParquet.ts +24 -16
  38. package/app/utils/db/createDatabase.ts +25 -33
  39. package/app/utils/db/duckdbBundles.ts +42 -0
  40. package/app/utils/db/ensureSpatialLoaded.ts +28 -0
  41. package/app/utils/db/escapeSqlString.ts +4 -0
  42. package/app/utils/db/getBlobFromObjectNode.ts +15 -36
  43. package/app/utils/db/getTileDataWasm.ts +4 -5
  44. package/app/utils/db/sqlQueries.ts +6 -1
  45. package/app/utils/filterObjects.ts +2 -18
  46. package/app/utils/limitConcurrency.ts +28 -0
  47. package/app/utils/listObjectsClient.ts +318 -0
  48. package/app/utils/listingLimits.ts +7 -0
  49. package/app/utils/loadConnectionLevel.ts +50 -0
  50. package/app/utils/localFilesStore/useFileStore.ts +1 -6
  51. package/app/utils/pathUtils.ts +65 -0
  52. package/app/utils/resourceId.ts +14 -29
  53. package/app/utils/s3HostAllowlist.ts +116 -0
  54. package/app/utils/signedFetch.ts +159 -24
  55. package/package.json +2 -2
  56. package/prisma/seed.ts +3 -3
  57. package/public/duckdb-extensions/checksums.json +12 -0
  58. package/scripts/download-duckdb-extensions.mjs +188 -0
  59. package/scripts/prebuild.mjs +18 -10
  60. package/app/.server/auth/getPresignedUrl.ts +0 -21
  61. package/app/.server/auth/getS3Client.ts +0 -93
  62. package/app/routes/presign.route.tsx +0 -42
  63. 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 { getS3Client } from "~/.server/auth/getS3Client";
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 { getObjects } from "~/utils/getObjects";
12
- import { getName, getPrefix } from "~/utils/pathUtils";
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
- export interface BucketRouteLoaderResponse {
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 urlPath = params["*"] ?? "";
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 isPinned = await checkIsPinnedPath(user.sub, connectionName, urlPath);
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
- const s3Client = await getS3Client(connectionConfig, credentials, user.sub);
62
-
63
- // Check for zarr before listing objects — zarr directories can contain
64
- // thousands of chunk files, so we skip the expensive ListObjects call.
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
- const objects: Readonly<_Object>[] = await getObjects(
81
- connectionConfig,
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
- return {
91
- connectionName,
92
- credentials,
93
- connectionConfig,
94
- name,
95
- nodes,
96
- bucketName,
97
- urlPath,
98
- pathName,
99
- isPinned,
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
- // Single file — viewer or unsupported format
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
- export const meta: MetaFunction<typeof loader> = ({ loaderData }) => [
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
- * Revalidate only when the URL actually changes. React Router's default
70
- * is to revalidate every active loader after any action, which on this
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
- } = useLoaderData<typeof loader>();
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
- // Track recently viewed files and directories (DB-backed via server action).
119
- // Only track when urlPath is non-empty the connection root itself isn't a viewable item.
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
- // Intentionally depends only on resourceId it is derived from connectionName + pathName,
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 isPinned: flip while the pin request is in-flight
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
- // SDS-CY-010401 (no format-specific branching): gate on the
227
- // plugin-aware `isImageFile` predicate so any plugin-contributed
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` from objects.loader is ALREADY the full S3 key (connection
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
- // Render placeholder when no objects found
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 { type LoaderFunctionArgs, useLoaderData } from "react-router";
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 { getObjects } from "~/utils/getObjects";
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
- export const loader = async ({
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
- context,
32
- }: LoaderFunctionArgs): Promise<SearchRouteLoaderResponse> => {
33
- const url = new URL(request.url);
34
- const searchQuery = url.searchParams.get("query") ?? "";
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 results: ConfigFiles[] = [];
39
-
40
- for (const connectionConfig of connectionConfigs) {
41
- const credentials = connectionsCredentials[connectionConfig.name];
42
- if (!credentials) {
43
- console.warn(`No credentials for connection: ${connectionConfig.name}`);
44
- continue;
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
- const s3Client = await getS3Client(connectionConfig, credentials, user.sub);
48
- const prefix = getPrefix(connectionConfig.prefix);
49
- const files = (await getObjects(connectionConfig, s3Client, searchQuery, prefix)) ?? [];
80
+ const results: ConfigFiles[] = perConnection
81
+ .filter((r) => r.files.length > 0)
82
+ .map(({ config, files, prefix }) => ({ config, files, prefix }));
50
83
 
51
- if (files.length > 0) {
52
- results.push({ config: connectionConfig, files, prefix });
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
- return { searchQuery, nodes };
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 loader>();
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>
package/app/routes.ts CHANGED
@@ -84,10 +84,6 @@ const apiRoutes = [
84
84
  path: "/api/pinned",
85
85
  file: "routes/api/pinned.ts",
86
86
  },
87
- {
88
- path: "/presign/:name/*",
89
- file: "routes/presign.route.tsx",
90
- },
91
87
  ];
92
88
 
93
89
  export default [