@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.
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
@@ -1,15 +1,11 @@
1
1
  import { Credentials } from "@aws-sdk/client-sts";
2
2
  import { create } from "zustand";
3
- import { createJSONStorage, devtools, persist } from "zustand/middleware";
3
+ import { devtools } from "zustand/middleware";
4
4
  import { immer } from "zustand/middleware/immer";
5
5
 
6
6
  import type { ConnectionConfig } from "~/.generated/client";
7
- import { createMigrate } from "~/utils/persistMigration";
8
7
 
9
- /**
10
- * A connection joins the static config (DB metadata) with the credentials
11
- * (STS-minted) needed to make signed requests.
12
- */
8
+ /** Static config + STS credentials needed to sign requests for one connection. */
13
9
  export interface Connection {
14
10
  connectionConfig: ConnectionConfig;
15
11
  credentials: Credentials;
@@ -18,74 +14,41 @@ export interface Connection {
18
14
  /**
19
15
  * Connections store. Single map keyed by `config.name`.
20
16
  *
21
- * Note: credentials are stored per-connection, not per-bucket. STS dedup
22
- * happens server-side at mint time (`getAllSessionCredentials`); on the
23
- * client we keep a flat per-connection mapping so connections that share
24
- * a bucket but differ in role can hold distinct credentials.
17
+ * Deliberately not persisted: STS credentials never leave in-memory state
18
+ * any script in the realm can read `sessionStorage` / `localStorage`.
25
19
  */
26
20
  export interface ConnectionsStore {
27
21
  connections: Record<string, Connection>;
28
- /**
29
- * Replace the whole store contents in a single write. Both inputs are
30
- * keyed by connection name (server's `getAllSessionCredentials` mints one
31
- * set of credentials per connection). Prunes entries for connections
32
- * deleted server-side.
33
- */
22
+ /** Replace the whole store in one write; prunes entries removed server-side. */
34
23
  setConnections: (configs: ConnectionConfig[], credentials: Record<string, Credentials>) => void;
35
24
  }
36
25
 
37
26
  const name = "ConnectionsStore";
38
27
 
39
- const FALLBACK_STATE: Pick<ConnectionsStore, "connections"> = {
40
- connections: {},
41
- };
42
-
43
28
  export const useConnectionsStore = create<ConnectionsStore>()(
44
29
  devtools(
45
- persist(
46
- immer((set) => ({
47
- connections: {},
30
+ immer((set) => ({
31
+ connections: {},
48
32
 
49
- setConnections: (configs, credentials) => {
50
- set(
51
- (state) => {
52
- const next: Record<string, Connection> = {};
53
- for (const connectionConfig of configs) {
54
- const creds = credentials[connectionConfig.name];
55
- if (!creds) continue;
56
- next[connectionConfig.name] = {
57
- connectionConfig,
58
- credentials: creds,
59
- };
60
- }
61
- state.connections = next;
62
- },
63
- false,
64
- "setConnections",
65
- );
66
- },
67
- })),
68
- {
69
- name: "connections-storage",
70
- storage: createJSONStorage(() => sessionStorage),
71
- version: 5,
72
- migrate: createMigrate<typeof FALLBACK_STATE>(
73
- {
74
- 1: () => FALLBACK_STATE,
75
- 2: () => FALLBACK_STATE,
76
- 3: () => FALLBACK_STATE,
77
- 4: () => FALLBACK_STATE,
33
+ setConnections: (configs, credentials) => {
34
+ set(
35
+ (state) => {
36
+ const next: Record<string, Connection> = {};
37
+ for (const connectionConfig of configs) {
38
+ const creds = credentials[connectionConfig.name];
39
+ if (!creds) continue;
40
+ next[connectionConfig.name] = {
41
+ connectionConfig,
42
+ credentials: creds,
43
+ };
44
+ }
45
+ state.connections = next;
78
46
  },
79
- FALLBACK_STATE,
80
- ),
81
- partialize: (state) => ({
82
- connections: state.connections,
83
- }),
84
- onRehydrateStorage: () => (_state, error) => {
85
- if (error) console.error("[ConnectionsStore] Rehydration failed:", error);
86
- },
47
+ false,
48
+ "setConnections",
49
+ );
87
50
  },
88
- ),
89
- { name },
51
+ })),
52
+ { name, enabled: process.env.NODE_ENV !== "production" },
90
53
  ),
91
54
  );
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Module-level singleton so utility code (signedFetch, listObjectsClient) can
3
+ * ask for fresh STS credentials without importing React. A route/component
4
+ * that owns a `useRevalidator` installs the refresher at mount.
5
+ */
6
+
7
+ import type { Credentials } from "@aws-sdk/client-sts";
8
+
9
+ /** Thrown when no refresher is installed or the refresh+retry still fails. */
10
+ export class ExpiredCredentialsError extends Error {
11
+ public readonly connectionName?: string;
12
+
13
+ constructor(message: string, connectionName?: string, cause?: unknown) {
14
+ super(message);
15
+ this.name = "ExpiredCredentialsError";
16
+ this.connectionName = connectionName;
17
+ if (cause !== undefined) {
18
+ (this as { cause?: unknown }).cause = cause;
19
+ }
20
+ }
21
+ }
22
+
23
+ export type CredentialsRefresher = (connectionName: string) => Promise<Credentials>;
24
+
25
+ let installed: CredentialsRefresher | undefined;
26
+
27
+ /** Install the refresher. Returns an uninstall function for React cleanup. */
28
+ export function setCredentialsRefresher(refresher: CredentialsRefresher): () => void {
29
+ installed = refresher;
30
+ return () => {
31
+ if (installed === refresher) installed = undefined;
32
+ };
33
+ }
34
+
35
+ /** Trigger a refresh; throws `ExpiredCredentialsError` if none is installed or it fails. */
36
+ export async function requestCredentialsRefresh(connectionName: string): Promise<Credentials> {
37
+ if (!installed) {
38
+ throw new ExpiredCredentialsError(
39
+ "STS credentials expired and no refresher is installed.",
40
+ connectionName,
41
+ );
42
+ }
43
+ try {
44
+ return await installed(connectionName);
45
+ } catch (error) {
46
+ throw new ExpiredCredentialsError("Failed to refresh STS credentials.", connectionName, error);
47
+ }
48
+ }
49
+
50
+ /** Test-only — module state survives `vi.resetModules()` unless explicitly wiped. */
51
+ export function __resetCredentialsRefresher(): void {
52
+ installed = undefined;
53
+ }
@@ -1,11 +1,7 @@
1
- import {
2
- getJsDelivrBundles,
3
- selectBundle,
4
- createWorker,
5
- AsyncDuckDB,
6
- ConsoleLogger,
7
- } from "@duckdb/duckdb-wasm";
1
+ import { selectBundle, createWorker, AsyncDuckDB, ConsoleLogger } from "@duckdb/duckdb-wasm";
8
2
 
3
+ import { getLocalDuckDbBundles } from "./duckdbBundles";
4
+ import { escapeSqlString } from "./escapeSqlString";
9
5
  import { getUint8ArrayForResourceId } from "./getBlobFromObjectNode";
10
6
  import { buildCreateTableQuery } from "./sqlQueries";
11
7
  import { resolveResourceId } from "../connectionsStore/selectors";
@@ -17,8 +13,7 @@ export async function convertCsvToParquet(resourceId: string) {
17
13
  let conn: Awaited<ReturnType<AsyncDuckDB["connect"]>> | null = null;
18
14
 
19
15
  try {
20
- const JSDELIVR_BUNDLES = getJsDelivrBundles();
21
- const bundle = await selectBundle(JSDELIVR_BUNDLES);
16
+ const bundle = await selectBundle(getLocalDuckDbBundles());
22
17
 
23
18
  if (!bundle.mainWorker) {
24
19
  throw new Error("DuckDB WASM worker is not available");
@@ -27,9 +22,17 @@ export async function convertCsvToParquet(resourceId: string) {
27
22
  const worker = await createWorker(bundle.mainWorker);
28
23
  db = new AsyncDuckDB(new ConsoleLogger(4), worker);
29
24
  await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
25
+ // See `createDatabase.ts` for the unsigned-extensions rationale.
26
+ await db.open({ allowUnsignedExtensions: true });
30
27
 
31
28
  conn = await db.connect();
32
29
 
30
+ // Pin the extension loader at the cytario origin (see `createDatabase.ts`).
31
+ if (typeof window !== "undefined") {
32
+ const repo = `${window.location.origin}/duckdb-extensions`;
33
+ await conn.query(`SET custom_extension_repository='${repo}'`);
34
+ }
35
+
33
36
  await conn.query(`INSTALL httpfs;`);
34
37
  await conn.query(`LOAD httpfs;`);
35
38
  await conn.query(`INSTALL spatial;`);
@@ -38,21 +41,26 @@ export async function convertCsvToParquet(resourceId: string) {
38
41
  const csvBytes = await getUint8ArrayForResourceId(resourceId);
39
42
  await db.registerFileBuffer(resourceId, csvBytes);
40
43
 
41
- // Create table `geometries`
42
44
  const createTableSQL = buildCreateTableQuery(resourceId, "polygon");
43
45
  await conn.query(createTableSQL);
44
46
 
45
47
  const { credentials, connectionConfig, s3Uri } = resolveResourceId(resourceId);
46
48
 
47
- await conn.query(`SET s3_access_key_id='${credentials.AccessKeyId}'`);
48
- await conn.query(`SET s3_secret_access_key='${credentials.SecretAccessKey}'`);
49
+ // Single-quote-escape every interpolated value — credentials, region,
50
+ // and S3 keys can carry `'` on non-AWS providers.
51
+ await conn.query(`SET s3_access_key_id='${escapeSqlString(credentials.AccessKeyId ?? "")}'`);
52
+ await conn.query(
53
+ `SET s3_secret_access_key='${escapeSqlString(credentials.SecretAccessKey ?? "")}'`,
54
+ );
49
55
  if (credentials.SessionToken) {
50
- await conn.query(`SET s3_session_token='${credentials.SessionToken}'`);
56
+ await conn.query(`SET s3_session_token='${escapeSqlString(credentials.SessionToken)}'`);
51
57
  }
52
- await conn.query(`SET s3_region='${connectionConfig.region ?? "eu-central-1"}'`);
58
+ await conn.query(
59
+ `SET s3_region='${escapeSqlString(connectionConfig.region ?? "eu-central-1")}'`,
60
+ );
53
61
 
54
- // Write to Parquet with WKB geometry
55
62
  const parquetDestination = `${s3Uri}.parquet`;
63
+ const escapedParquetDestination = escapeSqlString(parquetDestination);
56
64
 
57
65
  console.log(`[CSV→Parquet] Writing to S3 as Parquet (ZSTD compression, 500k row groups)...`);
58
66
  console.log(`[CSV→Parquet] → Destination: ${parquetDestination}`);
@@ -70,7 +78,7 @@ export async function convertCsvToParquet(resourceId: string) {
70
78
  COLUMNS('marker_positive_.*')
71
79
  FROM geometries
72
80
  )
73
- TO '${parquetDestination}'
81
+ TO '${escapedParquetDestination}'
74
82
  (FORMAT PARQUET, COMPRESSION ZSTD);
75
83
  `);
76
84
 
@@ -1,22 +1,13 @@
1
1
  import { Credentials } from "@aws-sdk/client-sts";
2
- import {
3
- getJsDelivrBundles,
4
- selectBundle,
5
- createWorker,
6
- AsyncDuckDB,
7
- ConsoleLogger,
8
- } from "@duckdb/duckdb-wasm";
2
+ import { selectBundle, createWorker, AsyncDuckDB, ConsoleLogger } from "@duckdb/duckdb-wasm";
9
3
 
10
4
  import { createSingleton } from "./createSingleton";
5
+ import { getLocalDuckDbBundles } from "./duckdbBundles";
6
+ import { escapeSqlString } from "./escapeSqlString";
11
7
  import { shouldUseSSL, getEndpointHostname } from "../s3Provider";
12
8
  import { ConnectionConfig } from "~/.generated/client";
13
9
 
14
- /**
15
- * Initialize DuckDB WASM with S3 support (singleton per resourceId)
16
- * @param resourceId - S3 resource identifier (bucketName/pathName)
17
- * @param credentials - AWS credentials
18
- * @param connectionConfig - Optional bucket configuration for S3-compatible services
19
- */
10
+ /** Initialize a DuckDB WASM connection with S3 support (singleton per resourceId). */
20
11
  const createDatabaseInternal = async (
21
12
  resourceId: string,
22
13
  credentials: Credentials,
@@ -24,9 +15,7 @@ const createDatabaseInternal = async (
24
15
  ) => {
25
16
  console.info("[getTileDataWasm] Initializing DuckDB WASM with S3 support...");
26
17
 
27
- // Load DuckDB WASM bundle
28
- const JSDELIVR_BUNDLES = getJsDelivrBundles();
29
- const bundle = await selectBundle(JSDELIVR_BUNDLES);
18
+ const bundle = await selectBundle(getLocalDuckDbBundles());
30
19
 
31
20
  if (!bundle.mainWorker) {
32
21
  throw new Error("DuckDB WASM worker is not available");
@@ -36,37 +25,41 @@ const createDatabaseInternal = async (
36
25
  const db = new AsyncDuckDB(new ConsoleLogger(4), worker);
37
26
  await db.instantiate(bundle.mainModule, bundle.pthreadWorker);
38
27
 
28
+ // Must be set before `open` (no SQL toggle). The mirror serves the same
29
+ // upstream binary verified at build time, but signatures are tied to
30
+ // `extensions.duckdb.org` so signature validation must be skipped.
31
+ await db.open({ allowUnsignedExtensions: true });
32
+
39
33
  const connection = await db.connect();
40
34
 
41
- // Use experimental HTTPFS for S3 access
42
- // see https://github.com/duckdb/duckdb-wasm/discussions/2107
35
+ // Pin the extension loader at the cytario origin — going to
36
+ // `extensions.duckdb.org` would leak the user's IP and is blocked by CSP.
37
+ if (typeof window !== "undefined") {
38
+ const repo = `${window.location.origin}/duckdb-extensions`;
39
+ await connection.query(`SET custom_extension_repository='${repo}'`);
40
+ }
41
+
42
+ // Use experimental HTTPFS for S3 — see duckdb-wasm discussion #2107.
43
43
  await connection.query("SET builtin_httpfs = false;");
44
44
  await connection.query("LOAD httpfs;");
45
45
 
46
- // Install and load spatial extension for geometry operations
47
- await connection.query("INSTALL spatial;");
48
- await connection.query("LOAD spatial;");
49
-
50
- // Enable caching for parquet metadata and HTTP connections
51
46
  await connection.query("SET enable_object_cache = true;");
52
47
  await connection.query("SET http_keep_alive = true;");
53
48
 
54
- // Configure S3 credentials
49
+ // Single-quote-escape every interpolated value — non-AWS providers may carry `'`.
55
50
  const { AccessKeyId, SecretAccessKey, SessionToken } = credentials;
56
- await connection.query(`SET s3_access_key_id='${AccessKeyId}'`);
57
- await connection.query(`SET s3_secret_access_key='${SecretAccessKey}'`);
58
- await connection.query(`SET s3_session_token='${SessionToken}'`);
51
+ await connection.query(`SET s3_access_key_id='${escapeSqlString(AccessKeyId ?? "")}'`);
52
+ await connection.query(`SET s3_secret_access_key='${escapeSqlString(SecretAccessKey ?? "")}'`);
53
+ await connection.query(`SET s3_session_token='${escapeSqlString(SessionToken ?? "")}'`);
59
54
 
60
- // Configure S3 endpoint. Always path-style: works for every bucket shape
61
- // (dotted names break the vhost wildcard cert `*.s3.<region>.amazonaws.com`)
62
- // and keeps a single URL form across AWS and S3-compatible endpoints.
55
+ // Always path-style: dotted bucket names break the vhost wildcard cert.
63
56
  const endpoint = connectionConfig?.endpoint;
64
57
  const region = connectionConfig?.region ?? "eu-central-1";
65
58
  const useSSL = shouldUseSSL(endpoint);
66
59
  const hostname = getEndpointHostname(endpoint);
67
60
 
68
- await connection.query(`SET s3_region='${region}'`);
69
- await connection.query(`SET s3_endpoint='${hostname}'`);
61
+ await connection.query(`SET s3_region='${escapeSqlString(region)}'`);
62
+ await connection.query(`SET s3_endpoint='${escapeSqlString(hostname)}'`);
70
63
  await connection.query(`SET s3_url_style='path'`);
71
64
  await connection.query(`SET s3_use_ssl=${useSSL}`);
72
65
 
@@ -75,5 +68,4 @@ const createDatabaseInternal = async (
75
68
  return connection;
76
69
  };
77
70
 
78
- // Wrap with singleton pattern to prevent multiple initializations
79
71
  export const createDatabase = createSingleton(createDatabaseInternal);
@@ -0,0 +1,42 @@
1
+ // Bundled locally rather than fetched from cdn.jsdelivr.net so we do not leak
2
+ // every user's IP to a third-party CDN (also blocked by CSP).
3
+ import type { DuckDBBundles } from "@duckdb/duckdb-wasm";
4
+ import duckdbCoiPthreadWorker from "@duckdb/duckdb-wasm/dist/duckdb-browser-coi.pthread.worker.js?url";
5
+ import duckdbCoiWorker from "@duckdb/duckdb-wasm/dist/duckdb-browser-coi.worker.js?url";
6
+ import duckdbEhWorker from "@duckdb/duckdb-wasm/dist/duckdb-browser-eh.worker.js?url";
7
+ import duckdbMvpWorker from "@duckdb/duckdb-wasm/dist/duckdb-browser-mvp.worker.js?url";
8
+ import duckdbCoiModule from "@duckdb/duckdb-wasm/dist/duckdb-coi.wasm?url";
9
+ import duckdbEhModule from "@duckdb/duckdb-wasm/dist/duckdb-eh.wasm?url";
10
+ import duckdbMvpModule from "@duckdb/duckdb-wasm/dist/duckdb-mvp.wasm?url";
11
+
12
+ /**
13
+ * Resolve a Vite-emitted URL to an absolute URL. DuckDB's worker runs in a
14
+ * `blob:` origin where `new Request("/...")` throws — we must pre-resolve.
15
+ */
16
+ function absoluteUrl(path: string): string {
17
+ if (typeof window === "undefined") {
18
+ throw new Error(
19
+ "getLocalDuckDbBundles() must not be called during SSR — DuckDB-WASM is a browser-only module.",
20
+ );
21
+ }
22
+ return new URL(path, window.location.origin).href;
23
+ }
24
+
25
+ /** Drop-in replacement for `getJsDelivrBundles()` pointing at the local assets. */
26
+ export function getLocalDuckDbBundles(): DuckDBBundles {
27
+ return {
28
+ mvp: {
29
+ mainModule: absoluteUrl(duckdbMvpModule),
30
+ mainWorker: absoluteUrl(duckdbMvpWorker),
31
+ },
32
+ eh: {
33
+ mainModule: absoluteUrl(duckdbEhModule),
34
+ mainWorker: absoluteUrl(duckdbEhWorker),
35
+ },
36
+ coi: {
37
+ mainModule: absoluteUrl(duckdbCoiModule),
38
+ mainWorker: absoluteUrl(duckdbCoiWorker),
39
+ pthreadWorker: absoluteUrl(duckdbCoiPthreadWorker),
40
+ },
41
+ };
42
+ }
@@ -0,0 +1,28 @@
1
+ type Connection = {
2
+ query: (sql: string) => Promise<unknown>;
3
+ };
4
+
5
+ const loaded = new WeakMap<Connection, Promise<void>>();
6
+
7
+ /**
8
+ * Lazily install + load the DuckDB `spatial` extension. ~23 MB, so kept off
9
+ * `createDatabase`'s critical path. Idempotent per connection via WeakMap.
10
+ */
11
+ export function ensureSpatialLoaded<T extends Connection>(connection: T): Promise<void> {
12
+ const cached = loaded.get(connection);
13
+ if (cached) return cached;
14
+
15
+ // Evict on rejection so a transient failure does not poison the connection
16
+ // until full reload.
17
+ const promise = (async () => {
18
+ try {
19
+ await connection.query("INSTALL spatial;");
20
+ await connection.query("LOAD spatial;");
21
+ } catch (error) {
22
+ loaded.delete(connection);
23
+ throw error;
24
+ }
25
+ })();
26
+ loaded.set(connection, promise);
27
+ return promise;
28
+ }
@@ -0,0 +1,4 @@
1
+ /** Double every `'` so the value is safe to interpolate in a single-quoted DuckDB literal. */
2
+ export function escapeSqlString(value: string): string {
3
+ return value.replaceAll("'", "''");
4
+ }
@@ -1,17 +1,14 @@
1
+ import { resolveResourceId } from "../connectionsStore/selectors";
1
2
  import { useFileStore, type DownloadProgress } from "../localFilesStore/useFileStore";
2
- import { parseResourceId } from "../resourceId";
3
+ import { createSignedFetch } from "../signedFetch";
3
4
 
4
5
  export type ProgressCallback = (progress: DownloadProgress) => void;
5
6
 
6
- /**
7
- * Download a file from URL with progress tracking
8
- */
9
- async function downloadFileWithProgress(
10
- url: string,
7
+ /** Stream a response body into a `Uint8Array`, emitting progress when possible. */
8
+ async function readStreamWithProgress(
9
+ response: Response,
11
10
  onProgress?: ProgressCallback,
12
11
  ): Promise<Uint8Array> {
13
- const response = await fetch(url);
14
-
15
12
  if (!response.ok) {
16
13
  throw new Error(`Failed to fetch file: ${response.statusText}`);
17
14
  }
@@ -44,7 +41,6 @@ async function downloadFileWithProgress(
44
41
  }
45
42
  }
46
43
 
47
- // Combine all chunks into a single Uint8Array
48
44
  const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
49
45
  const combined = new Uint8Array(totalLength);
50
46
  let offset = 0;
@@ -55,39 +51,22 @@ async function downloadFileWithProgress(
55
51
  return combined;
56
52
  }
57
53
 
58
- /**
59
- * Get presigned URL for a given resourceId.
60
- */
61
- async function getPresignedUrl(resourceId: string): Promise<string> {
62
- const { connectionName, pathName } = parseResourceId(resourceId);
63
- const response = await fetch(`/presign/${connectionName}/${pathName}`);
64
- const data = await response.json();
65
- return data.url;
66
- }
67
-
68
- /**
69
- * Get file data for a resourceId, with caching and progress tracking
70
- * @param resourceId - S3 resource identifier (provider/bucketName/pathName)
71
- */
54
+ /** Cached SigV4 GetObject for a resourceId, with progress tracking. */
72
55
  export const getUint8ArrayForResourceId = async (resourceId: string): Promise<Uint8Array> => {
73
56
  const { getFile, saveFile, setFileProgress } = useFileStore.getState();
74
57
 
75
- // Check cache first
76
58
  const cachedData = await getFile(resourceId);
59
+ if (cachedData) return cachedData;
77
60
 
78
- if (cachedData) {
79
- return cachedData;
80
- } else {
81
- const url = await getPresignedUrl(resourceId);
61
+ const { connectionConfig, credentials, httpsUrl } = resolveResourceId(resourceId);
62
+ const signedFetch = createSignedFetch(() => credentials, connectionConfig);
82
63
 
83
- // Download file with progress, updating store
84
- const data = await downloadFileWithProgress(url, (progress) => {
85
- setFileProgress(resourceId, progress);
86
- });
64
+ const response = await signedFetch(httpsUrl);
65
+ const data = await readStreamWithProgress(response, (progress) => {
66
+ setFileProgress(resourceId, progress);
67
+ });
87
68
 
88
- // Save to cache
89
- await saveFile(resourceId, data);
69
+ await saveFile(resourceId, data);
90
70
 
91
- return data;
92
- }
71
+ return data;
93
72
  };
@@ -1,6 +1,7 @@
1
1
  import { type Table } from "apache-arrow";
2
2
 
3
3
  import { createDatabase } from "./createDatabase";
4
+ import { ensureSpatialLoaded } from "./ensureSpatialLoaded";
4
5
  import { getGeomQuery } from "./getGeomQuery";
5
6
  import { resolveResourceId } from "../connectionsStore/selectors";
6
7
 
@@ -15,9 +16,6 @@ export interface PointRow extends Record<string, unknown> {
15
16
  y: number;
16
17
  }
17
18
 
18
- /**
19
- * Fetch tile data from DuckDB-WASM database on S3.
20
- */
21
19
  export async function getTileDataWasm(
22
20
  resourceId: string,
23
21
  tileIndex: TileIndex,
@@ -26,6 +24,7 @@ export async function getTileDataWasm(
26
24
  try {
27
25
  const { credentials, connectionConfig, s3Uri } = resolveResourceId(resourceId);
28
26
  const connection = await createDatabase(resourceId, credentials, connectionConfig);
27
+ await ensureSpatialLoaded(connection);
29
28
  const tileQuery = getGeomQuery(s3Uri, tileIndex, markerColumns);
30
29
  const arrowTable = await connection.query(tileQuery);
31
30
 
@@ -33,8 +32,8 @@ export async function getTileDataWasm(
33
32
  return null;
34
33
  }
35
34
 
36
- // Type assertion needed: DuckDB-WASM bundles its own apache-arrow version
37
- // which is compatible at runtime but TypeScript sees them as different types
35
+ // DuckDB-WASM bundles its own apache-arrow; runtime-compatible but
36
+ // structurally different to TypeScript.
38
37
  return arrowTable as unknown as Table;
39
38
  } catch (error) {
40
39
  console.error(`[getTileDataWasm] Error fetching tile data:`, error);
@@ -1,4 +1,9 @@
1
+ import { escapeSqlString } from "./escapeSqlString";
2
+
1
3
  export function buildCreateTableQuery(id: string, geometryColumn: string = "polygon"): string {
4
+ // S3 keys may contain `'`. `geometryColumn` is an unquoted identifier — callers
5
+ // must keep it to known-safe values.
6
+ const escapedId = escapeSqlString(id);
2
7
  return /*sql*/ `
3
8
  CREATE TABLE IF NOT EXISTS geometries AS
4
9
  SELECT
@@ -9,7 +14,7 @@ export function buildCreateTableQuery(id: string, geometryColumn: string = "poly
9
14
  ST_YMin(geom) as y
10
15
  -- ST_X(ST_Centroid(geom)) AS x,
11
16
  -- ST_Y(ST_Centroid(geom)) AS y
12
- FROM read_csv_auto('${id}', HEADER=TRUE, comment='#');
17
+ FROM read_csv_auto('${escapedId}', HEADER=TRUE, comment='#');
13
18
  `;
14
19
  }
15
20
 
@@ -1,9 +1,6 @@
1
1
  import { _Object } from "@aws-sdk/client-s3";
2
2
 
3
3
  import { search } from "~/components/GlobalSearch/search";
4
- import { cytarioConfig } from "~/config";
5
-
6
- export const allowedFilesPattern = new RegExp(cytarioConfig.setup.allowedFiles, "i");
7
4
 
8
5
  export const filterObjects = (
9
6
  objects: Readonly<_Object>[] = [],
@@ -11,28 +8,15 @@ export const filterObjects = (
11
8
  ): _Object[] => {
12
9
  return objects
13
10
  .reduce((acc, item) => {
14
- // does not match allowed file pattern
15
- if (!allowedFilesPattern.test(item.Key!)) {
11
+ if (!item.Key) {
16
12
  return acc;
17
13
  }
18
14
 
19
- // does not match provided search query
20
15
  if (query && !search(query, item.Key)) {
21
16
  return acc;
22
17
  }
23
18
 
24
- // add item to array
25
19
  return [...acc, item];
26
20
  }, [] as _Object[])
27
- .sort((a, b) => {
28
- const aIsDir = a.Key!.includes("/");
29
- const bIsDir = b.Key!.includes("/");
30
-
31
- // First, ensure directories come before files
32
- if (aIsDir && !bIsDir) return -1;
33
- if (!aIsDir && bIsDir) return 1;
34
-
35
- // Then, if both are either directories or files, sort them alphabetically
36
- return a.Key!.localeCompare(b.Key!);
37
- });
21
+ .sort((a, b) => a.Key!.localeCompare(b.Key!));
38
22
  };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Bounded-concurrency map: at most `limit` promises in flight, results in
3
+ * input order. Errors propagate like `Promise.all`; callers wanting per-item
4
+ * error visibility should wrap `fn` and return a tagged result.
5
+ */
6
+ export async function mapWithConcurrency<T, R>(
7
+ items: readonly T[],
8
+ limit: number,
9
+ fn: (item: T, index: number) => Promise<R>,
10
+ ): Promise<R[]> {
11
+ if (items.length === 0) return [];
12
+ const effectiveLimit = Math.max(1, Math.min(limit, items.length));
13
+
14
+ const results = new Array<R>(items.length);
15
+ let nextIndex = 0;
16
+
17
+ const worker = async (): Promise<void> => {
18
+ while (true) {
19
+ const current = nextIndex++;
20
+ if (current >= items.length) return;
21
+ results[current] = await fn(items[current], current);
22
+ }
23
+ };
24
+
25
+ const workers = Array.from({ length: effectiveLimit }, () => worker());
26
+ await Promise.all(workers);
27
+ return results;
28
+ }