@cytario/web 2.1.8 → 2.2.1
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 +3 -3
- package/prisma/seed.ts +3 -3
- package/public/duckdb-extensions/checksums.json +12 -0
- package/public/duckdb-extensions/v1.4.3/wasm_eh/httpfs.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_eh/parquet.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_eh/spatial.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_mvp/httpfs.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_mvp/parquet.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_mvp/spatial.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_threads/httpfs.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_threads/parquet.duckdb_extension.wasm +0 -0
- package/public/duckdb-extensions/v1.4.3/wasm_threads/spatial.duckdb_extension.wasm +0 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { isAllowedS3Host } from "~/utils/s3HostAllowlist";
|
|
4
|
+
|
|
5
5
|
export const connectionNameSchema = z
|
|
6
6
|
.string()
|
|
7
7
|
.min(2, "Name must be at least 2 characters")
|
|
@@ -13,21 +13,75 @@ export const connectionNameSchema = z
|
|
|
13
13
|
.refine((val) => !val.includes("--"), "Name must not contain consecutive hyphens")
|
|
14
14
|
.refine((val) => !val.includes(" "), "Name must not contain consecutive spaces");
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
const isDevelopment = process.env.NODE_ENV === "development";
|
|
17
|
+
const HTTP_ALLOWED_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]);
|
|
18
|
+
|
|
19
|
+
// `https://` endpoints must also pass the S3 allowlist; without that gate the
|
|
20
|
+
// server-side CORS probe could fetch IMDS / RFC1918 / loopback URLs.
|
|
21
|
+
const endpointUrlSchema = z
|
|
22
|
+
.string()
|
|
23
|
+
.url("Invalid endpoint URL")
|
|
24
|
+
.refine(
|
|
25
|
+
(val) => {
|
|
26
|
+
let parsed: URL;
|
|
27
|
+
try {
|
|
28
|
+
parsed = new URL(val);
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (parsed.protocol === "https:") return true;
|
|
33
|
+
if (parsed.protocol !== "http:") return false;
|
|
34
|
+
return isDevelopment || HTTP_ALLOWED_HOSTS.has(parsed.hostname);
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
message: "Endpoint must use https:// (http:// only allowed for localhost, 127.0.0.1, or ::1)",
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
.refine(
|
|
41
|
+
(val) => {
|
|
42
|
+
// Allowlist gate is https-only; the dev http carve-out above bypasses it
|
|
43
|
+
// intentionally so local MinIO keeps working.
|
|
44
|
+
let parsed: URL;
|
|
45
|
+
try {
|
|
46
|
+
parsed = new URL(val);
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
if (parsed.protocol !== "https:") return true;
|
|
51
|
+
return isAllowedS3Host(val);
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
message:
|
|
55
|
+
"Endpoint host is not in the cytario S3 allowlist. Ask the operator to add it to CYTARIO_ALLOWED_S3_HOSTS.",
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
|
|
17
59
|
const arnPattern = /^arn:aws:iam::\d{12}:role\/[\w+=,.@-]+$/;
|
|
18
60
|
|
|
19
|
-
// S3 URI validation - extracts bucket name and optional prefix
|
|
20
61
|
const s3UriSchema = z
|
|
21
62
|
.string()
|
|
22
63
|
.min(1, "S3 URI is required")
|
|
23
64
|
.refine(
|
|
24
65
|
(val) => {
|
|
25
|
-
// Accept either "s3://bucket/path" or just "bucket/path" or "bucket"
|
|
26
66
|
const cleaned = val.replace(/^s3:\/\//, "");
|
|
27
67
|
const bucketName = cleaned.split("/")[0];
|
|
28
68
|
return bucketName.length >= 3 && bucketName.length <= 63;
|
|
29
69
|
},
|
|
30
70
|
{ message: "Invalid S3 URI - bucket name must be 3-63 characters" },
|
|
71
|
+
)
|
|
72
|
+
.refine(
|
|
73
|
+
(val) => {
|
|
74
|
+
// Reject IAM wildcards — a prefix like `tenant-a*` would expand the
|
|
75
|
+
// session policy's `StringLike` condition to neighbouring tenants.
|
|
76
|
+
const cleaned = val.replace(/^s3:\/\//, "");
|
|
77
|
+
const slashIdx = cleaned.indexOf("/");
|
|
78
|
+
if (slashIdx === -1) return true;
|
|
79
|
+
const prefix = cleaned.slice(slashIdx + 1);
|
|
80
|
+
return !/[*?]/.test(prefix);
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
message: "Prefix may not contain IAM wildcard characters (`*`, `?`)",
|
|
84
|
+
},
|
|
31
85
|
);
|
|
32
86
|
|
|
33
87
|
/** Auto-suggest a connection name from an S3 URI (e.g. "s3://my-bucket/path" → "my-bucket"). */
|
|
@@ -43,7 +97,6 @@ export function suggestName(s3Uri: string): string {
|
|
|
43
97
|
.slice(0, 60);
|
|
44
98
|
}
|
|
45
99
|
|
|
46
|
-
// Helper to parse S3 URI into bucket name and prefix
|
|
47
100
|
export const parseS3Uri = (uri: string): { bucketName: string; prefix: string } => {
|
|
48
101
|
const cleaned = uri.replace(/^s3:\/\//, "");
|
|
49
102
|
const [bucketName, ...prefixParts] = cleaned.split("/");
|
|
@@ -51,7 +104,6 @@ export const parseS3Uri = (uri: string): { bucketName: string; prefix: string }
|
|
|
51
104
|
return { bucketName, prefix };
|
|
52
105
|
};
|
|
53
106
|
|
|
54
|
-
// Combined schema for final submission - AWS provider
|
|
55
107
|
const awsFormSchema = z.object({
|
|
56
108
|
name: connectionNameSchema,
|
|
57
109
|
ownerScope: z.string().min(1, "Scope is required"),
|
|
@@ -62,7 +114,6 @@ const awsFormSchema = z.object({
|
|
|
62
114
|
bucketEndpoint: z.string().default(""),
|
|
63
115
|
});
|
|
64
116
|
|
|
65
|
-
// Combined schema for final submission - MinIO provider
|
|
66
117
|
const minioFormSchema = z.object({
|
|
67
118
|
name: connectionNameSchema,
|
|
68
119
|
ownerScope: z.string().min(1, "Scope is required"),
|
|
@@ -70,10 +121,9 @@ const minioFormSchema = z.object({
|
|
|
70
121
|
s3Uri: s3UriSchema,
|
|
71
122
|
bucketRegion: z.string().default(""),
|
|
72
123
|
roleArn: z.string().default(""),
|
|
73
|
-
bucketEndpoint:
|
|
124
|
+
bucketEndpoint: endpointUrlSchema,
|
|
74
125
|
});
|
|
75
126
|
|
|
76
|
-
// Discriminated union for type-safe conditional validation
|
|
77
127
|
export const connectionSchema = z.discriminatedUnion("providerType", [
|
|
78
128
|
awsFormSchema,
|
|
79
129
|
minioFormSchema,
|
|
@@ -81,7 +131,6 @@ export const connectionSchema = z.discriminatedUnion("providerType", [
|
|
|
81
131
|
|
|
82
132
|
export type ConnectBucketFormData = z.input<typeof connectionSchema>;
|
|
83
133
|
|
|
84
|
-
// Default values for the form
|
|
85
134
|
export const defaultFormValues: ConnectBucketFormData = {
|
|
86
135
|
name: "",
|
|
87
136
|
ownerScope: "",
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { _Object } from "@aws-sdk/client-s3";
|
|
2
|
+
import { Credentials } from "@aws-sdk/client-sts";
|
|
3
|
+
import { type ClientLoaderFunctionArgs } from "react-router";
|
|
4
|
+
|
|
5
|
+
import type { LoaderData, loadConnections } from "./connections.loader";
|
|
6
|
+
import type { ConnectionConfig } from "~/.generated/client";
|
|
7
|
+
import { TreeNode } from "~/components/DirectoryView/buildDirectoryTree";
|
|
8
|
+
import { isImageFile } from "~/utils/fileType";
|
|
9
|
+
import { mapWithConcurrency } from "~/utils/limitConcurrency";
|
|
10
|
+
import { listObjectsClient } from "~/utils/listObjectsClient";
|
|
11
|
+
import { getPrefix } from "~/utils/pathUtils";
|
|
12
|
+
import { CorsLikelyError } from "~/utils/signedFetch";
|
|
13
|
+
|
|
14
|
+
const PREVIEW_CONCURRENCY = 4;
|
|
15
|
+
|
|
16
|
+
const isImagePreview = (obj: _Object) => isImageFile(obj.Key ?? "");
|
|
17
|
+
|
|
18
|
+
interface ConnectionProbeResult {
|
|
19
|
+
previewObj?: _Object;
|
|
20
|
+
status: "connected" | "error";
|
|
21
|
+
errorMessage?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function probeConnection(
|
|
25
|
+
config: ConnectionConfig,
|
|
26
|
+
credentials: Credentials,
|
|
27
|
+
signal?: AbortSignal,
|
|
28
|
+
): Promise<ConnectionProbeResult> {
|
|
29
|
+
try {
|
|
30
|
+
const { contents } = await listObjectsClient(config, credentials, {
|
|
31
|
+
// Trailing slash required: the session policy only allows `<prefix>/`
|
|
32
|
+
// and `<prefix>/*`, so a bare `<prefix>` value 403s.
|
|
33
|
+
prefix: getPrefix(config.prefix),
|
|
34
|
+
recursive: true,
|
|
35
|
+
maxKeys: 100,
|
|
36
|
+
maxTotal: 100,
|
|
37
|
+
findFirst: isImagePreview,
|
|
38
|
+
signal,
|
|
39
|
+
});
|
|
40
|
+
return { previewObj: contents.find(isImagePreview), status: "connected" };
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error instanceof CorsLikelyError) {
|
|
43
|
+
return {
|
|
44
|
+
status: "error",
|
|
45
|
+
errorMessage: "Browser blocked from reading the bucket — check the bucket's CORS policy.",
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
49
|
+
return { status: "error", errorMessage: message };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Per-connection preview + health probe. One bounded-concurrency
|
|
55
|
+
* `ListObjectsV2` doubles as the bucket-card preview and the connection
|
|
56
|
+
* health check; `CorsLikelyError` becomes a card-level CORS warning.
|
|
57
|
+
*/
|
|
58
|
+
export async function enrichConnectionsWithPreviews({
|
|
59
|
+
request,
|
|
60
|
+
serverLoader,
|
|
61
|
+
}: ClientLoaderFunctionArgs): Promise<LoaderData> {
|
|
62
|
+
const server = await serverLoader<typeof loadConnections>();
|
|
63
|
+
const signal = request.signal;
|
|
64
|
+
|
|
65
|
+
const probes = await mapWithConcurrency(
|
|
66
|
+
server.connectionConfigs,
|
|
67
|
+
PREVIEW_CONCURRENCY,
|
|
68
|
+
async (config): Promise<ConnectionProbeResult> => {
|
|
69
|
+
if (signal.aborted) return { status: "connected" };
|
|
70
|
+
const creds = server.credentials[config.name];
|
|
71
|
+
if (!creds) {
|
|
72
|
+
return { status: "error", errorMessage: "No credentials available for this connection." };
|
|
73
|
+
}
|
|
74
|
+
return probeConnection(config, creds, signal);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const nodes: TreeNode[] = server.nodes.map((node, i) => {
|
|
79
|
+
const probe = probes[i];
|
|
80
|
+
return {
|
|
81
|
+
...node,
|
|
82
|
+
...(probe.previewObj ? { _Object: probe.previewObj } : {}),
|
|
83
|
+
connectionStatus: probe.status,
|
|
84
|
+
...(probe.errorMessage ? { connectionErrorMessage: probe.errorMessage } : {}),
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { ...server, nodes };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
enrichConnectionsWithPreviews.hydrate = true;
|
|
@@ -1,30 +1,12 @@
|
|
|
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 { ConnectionsCredentials } from "~/.server/auth/sessionStorage";
|
|
9
6
|
import { TreeNode } from "~/components/DirectoryView/buildDirectoryTree";
|
|
10
|
-
import { isImageFile } from "~/utils/fileType";
|
|
11
|
-
import { getObjects } from "~/utils/getObjects";
|
|
12
7
|
import { getPinnedPaths } from "~/utils/pinnedPaths.server";
|
|
13
8
|
import { getRecentlyViewed } from "~/utils/recentlyViewed.server";
|
|
14
9
|
|
|
15
|
-
/** Find the first image file in a connection for the bucket card preview. */
|
|
16
|
-
const fetchPreviewObject = async (
|
|
17
|
-
config: ConnectionConfig,
|
|
18
|
-
credentials: ConnectionsCredentials,
|
|
19
|
-
userId: string,
|
|
20
|
-
): Promise<_Object | undefined> => {
|
|
21
|
-
const creds = credentials[config.name];
|
|
22
|
-
if (!creds) return undefined;
|
|
23
|
-
const s3 = await getS3Client(config, creds, userId);
|
|
24
|
-
const objects = await getObjects(config, s3, null, config.prefix || undefined, 100);
|
|
25
|
-
return objects.find((obj) => isImageFile(obj.Key ?? ""));
|
|
26
|
-
};
|
|
27
|
-
|
|
28
10
|
export type SerializedRecentlyViewed = {
|
|
29
11
|
id: number;
|
|
30
12
|
connectionName: string;
|
|
@@ -43,7 +25,7 @@ export type SerializedPinnedPath = {
|
|
|
43
25
|
lastModified: string | null;
|
|
44
26
|
};
|
|
45
27
|
|
|
46
|
-
export interface
|
|
28
|
+
export interface ServerLoaderData {
|
|
47
29
|
nodes: TreeNode[];
|
|
48
30
|
credentials: Record<string, Credentials>;
|
|
49
31
|
connectionConfigs: ConnectionConfig[];
|
|
@@ -51,32 +33,28 @@ export interface LoaderData {
|
|
|
51
33
|
pinnedPaths: SerializedPinnedPath[];
|
|
52
34
|
}
|
|
53
35
|
|
|
36
|
+
export interface LoaderData extends ServerLoaderData {
|
|
37
|
+
/** Same shape as `nodes` but with per-bucket preview enrichment from the client probe. */
|
|
38
|
+
nodes: TreeNode[];
|
|
39
|
+
}
|
|
40
|
+
|
|
54
41
|
export async function loadConnections({ context }: LoaderFunctionArgs) {
|
|
55
42
|
const { connectionConfigs, credentials, user } = context.get(authContext);
|
|
56
43
|
const userId = user.sub;
|
|
57
44
|
|
|
58
|
-
const [
|
|
59
|
-
Promise.allSettled(
|
|
60
|
-
connectionConfigs.map((config) => fetchPreviewObject(config, credentials, userId)),
|
|
61
|
-
),
|
|
45
|
+
const [recentlyViewedRaw, pinnedPathsRaw] = await Promise.all([
|
|
62
46
|
getRecentlyViewed(userId, 20),
|
|
63
47
|
getPinnedPaths(userId),
|
|
64
48
|
]);
|
|
65
49
|
|
|
66
|
-
const nodes: TreeNode[] = connectionConfigs.map((config
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
type: "bucket" as const,
|
|
75
|
-
pathName: "",
|
|
76
|
-
children: [],
|
|
77
|
-
_Object: previewObj,
|
|
78
|
-
};
|
|
79
|
-
});
|
|
50
|
+
const nodes: TreeNode[] = connectionConfigs.map((config) => ({
|
|
51
|
+
id: `${config.name}/`,
|
|
52
|
+
connectionName: config.name,
|
|
53
|
+
name: config.name,
|
|
54
|
+
type: "bucket" as const,
|
|
55
|
+
pathName: "",
|
|
56
|
+
children: [],
|
|
57
|
+
}));
|
|
80
58
|
|
|
81
59
|
const recentlyViewed: SerializedRecentlyViewed[] = recentlyViewedRaw.map((item) => ({
|
|
82
60
|
id: item.id,
|
|
@@ -96,11 +74,13 @@ export async function loadConnections({ context }: LoaderFunctionArgs) {
|
|
|
96
74
|
lastModified: pin.lastModified ? pin.lastModified.toISOString() : null,
|
|
97
75
|
}));
|
|
98
76
|
|
|
99
|
-
|
|
77
|
+
const payload: ServerLoaderData = {
|
|
100
78
|
nodes,
|
|
101
79
|
credentials,
|
|
102
80
|
connectionConfigs,
|
|
103
81
|
recentlyViewed,
|
|
104
82
|
pinnedPaths,
|
|
105
|
-
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
return payload;
|
|
106
86
|
}
|
|
@@ -31,8 +31,13 @@ export const action = async (args: ActionFunctionArgs) => {
|
|
|
31
31
|
}
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
+
export { enrichConnectionsWithPreviews as clientLoader } from "./connections.clientLoader";
|
|
34
35
|
export { loadConnections as loader } from "./connections.loader";
|
|
35
36
|
|
|
37
|
+
// Response carries STS credentials — keep it out of every cache between origin
|
|
38
|
+
// and browser.
|
|
39
|
+
export const headers = () => ({ "Cache-Control": "no-store, private" });
|
|
40
|
+
|
|
36
41
|
const title = "Storage Connections";
|
|
37
42
|
|
|
38
43
|
export const meta: MetaFunction = () => [{ title: `${title} — Cytario` }];
|
|
@@ -5,10 +5,12 @@ import { Prisma } from "~/.generated/client";
|
|
|
5
5
|
import { authContext } from "~/.server/auth/authMiddleware";
|
|
6
6
|
import { sessionContext } from "~/.server/auth/sessionMiddleware";
|
|
7
7
|
import { sessionStorage } from "~/.server/auth/sessionStorage";
|
|
8
|
+
import { describeCorsFailure, describeCorsWarning, probeBucketCors } from "~/.server/corsPreflight";
|
|
8
9
|
import { prisma } from "~/.server/db/prisma";
|
|
10
|
+
import { cytarioConfig } from "~/config";
|
|
9
11
|
import { canCreate } from "~/utils/authorization";
|
|
12
|
+
import { constructS3Url } from "~/utils/resourceId";
|
|
10
13
|
|
|
11
|
-
/** Create a connection config (upserts on the composite unique key). */
|
|
12
14
|
export async function createConnection(
|
|
13
15
|
ownerScope: string,
|
|
14
16
|
createdBy: string,
|
|
@@ -84,6 +86,24 @@ export const createAction = async ({ request, context }: ActionFunctionArgs) =>
|
|
|
84
86
|
? `https://s3.${data.bucketRegion}.amazonaws.com`
|
|
85
87
|
: data.bucketEndpoint;
|
|
86
88
|
|
|
89
|
+
// Surface CORS misconfigurations at submit time rather than at first
|
|
90
|
+
// browser-side fetch (where they read as a generic "Failed to fetch").
|
|
91
|
+
const cytarioOrigin = cytarioConfig.endpoints.webapp;
|
|
92
|
+
const bucketUrl = constructS3Url({
|
|
93
|
+
bucketName,
|
|
94
|
+
region: data.providerType === "aws" ? data.bucketRegion : null,
|
|
95
|
+
endpoint,
|
|
96
|
+
});
|
|
97
|
+
const corsResult = await probeBucketCors(bucketUrl, cytarioOrigin);
|
|
98
|
+
if (!corsResult.ok) {
|
|
99
|
+
// CORS / allowlist failures span multiple fields (and AWS has no
|
|
100
|
+
// endpoint field) — surface as a form-level banner.
|
|
101
|
+
return {
|
|
102
|
+
formError: describeCorsFailure(corsResult, cytarioOrigin),
|
|
103
|
+
status: "error" as const,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
87
107
|
const newConfig = {
|
|
88
108
|
name: data.name,
|
|
89
109
|
bucketName,
|
|
@@ -96,10 +116,19 @@ export const createAction = async ({ request, context }: ActionFunctionArgs) =>
|
|
|
96
116
|
|
|
97
117
|
await createConnection(data.ownerScope, user.sub, newConfig);
|
|
98
118
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
119
|
+
if (corsResult.warnings.length > 0) {
|
|
120
|
+
session.set("notification", {
|
|
121
|
+
status: "warning",
|
|
122
|
+
message: `Connection added. ${corsResult.warnings
|
|
123
|
+
.map((w) => describeCorsWarning(w, cytarioOrigin))
|
|
124
|
+
.join(" ")}`,
|
|
125
|
+
});
|
|
126
|
+
} else {
|
|
127
|
+
session.set("notification", {
|
|
128
|
+
status: "success",
|
|
129
|
+
message: "Storage connection added successfully.",
|
|
130
|
+
});
|
|
131
|
+
}
|
|
103
132
|
|
|
104
133
|
return redirect(`/connections/${encodeURIComponent(data.name)}`, {
|
|
105
134
|
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
|
|
@@ -116,13 +145,10 @@ export const createAction = async ({ request, context }: ActionFunctionArgs) =>
|
|
|
116
145
|
|
|
117
146
|
console.error("Error creating connection:", error);
|
|
118
147
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
return redirect(`/`, {
|
|
125
|
-
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
|
|
126
|
-
});
|
|
148
|
+
// Keep the wizard mounted so the user's draft survives the error.
|
|
149
|
+
return {
|
|
150
|
+
formError: "Could not add the connection. Try again or check the server logs.",
|
|
151
|
+
status: "error" as const,
|
|
152
|
+
};
|
|
127
153
|
}
|
|
128
154
|
};
|
|
@@ -7,7 +7,6 @@ import { sessionStorage } from "~/.server/auth/sessionStorage";
|
|
|
7
7
|
import { prisma } from "~/.server/db/prisma";
|
|
8
8
|
import { canModify, canSee } from "~/utils/authorization";
|
|
9
9
|
|
|
10
|
-
/** Delete a connection config by name. Checks visibility and modify authorization. */
|
|
11
10
|
export async function deleteConnection(user: UserProfile, name: string) {
|
|
12
11
|
const config = await prisma.connectionConfig.findUnique({
|
|
13
12
|
where: { name },
|
|
@@ -49,6 +48,11 @@ export const deleteAction = async ({ request, context }: ActionFunctionArgs) =>
|
|
|
49
48
|
throw error;
|
|
50
49
|
}
|
|
51
50
|
|
|
51
|
+
// Drop cached STS credentials keyed by the removed name.
|
|
52
|
+
const credentials = session.get("credentials") ?? {};
|
|
53
|
+
delete credentials[connectionName];
|
|
54
|
+
session.set("credentials", credentials);
|
|
55
|
+
|
|
52
56
|
session.set("notification", {
|
|
53
57
|
status: "success",
|
|
54
58
|
message: "Storage connection deleted.",
|
|
@@ -3,14 +3,20 @@ import { type ActionFunctionArgs, redirect } from "react-router";
|
|
|
3
3
|
import { connectionSchema, parseS3Uri } from "./connection.schema";
|
|
4
4
|
import { Prisma } from "~/.generated/client";
|
|
5
5
|
import { authContext } from "~/.server/auth/authMiddleware";
|
|
6
|
-
import { invalidateS3ClientsForBucket } from "~/.server/auth/getS3Client";
|
|
7
6
|
import type { UserProfile } from "~/.server/auth/getUserInfo";
|
|
8
7
|
import { sessionContext } from "~/.server/auth/sessionMiddleware";
|
|
9
8
|
import { sessionStorage } from "~/.server/auth/sessionStorage";
|
|
9
|
+
import {
|
|
10
|
+
type CorsPreflightWarningReason,
|
|
11
|
+
describeCorsFailure,
|
|
12
|
+
describeCorsWarning,
|
|
13
|
+
probeBucketCors,
|
|
14
|
+
} from "~/.server/corsPreflight";
|
|
10
15
|
import { prisma } from "~/.server/db/prisma";
|
|
16
|
+
import { cytarioConfig } from "~/config";
|
|
11
17
|
import { canCreate, canModify, canSee } from "~/utils/authorization";
|
|
18
|
+
import { constructS3Url } from "~/utils/resourceId";
|
|
12
19
|
|
|
13
|
-
/** Update a connection config. Cascades name changes to related records. */
|
|
14
20
|
export async function updateConnection(
|
|
15
21
|
user: UserProfile,
|
|
16
22
|
originalName: string,
|
|
@@ -46,8 +52,7 @@ export async function updateConnection(
|
|
|
46
52
|
const previousName = config.name;
|
|
47
53
|
const previousBucketName = config.bucketName;
|
|
48
54
|
|
|
49
|
-
// FKs on recentlyViewed/pinnedPath
|
|
50
|
-
// so Postgres automatically cascades name changes to children.
|
|
55
|
+
// FKs on recentlyViewed / pinnedPath use ON UPDATE CASCADE.
|
|
51
56
|
const updated = await prisma.connectionConfig.update({
|
|
52
57
|
where: { id: config.id },
|
|
53
58
|
data: {
|
|
@@ -79,7 +84,6 @@ export const updateAction = async ({ request, context }: ActionFunctionArgs) =>
|
|
|
79
84
|
};
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
// Validate the form data with the same schema as create
|
|
83
87
|
const rawData = {
|
|
84
88
|
name: String(formData.get("name") ?? ""),
|
|
85
89
|
ownerScope: String(formData.get("ownerScope") ?? ""),
|
|
@@ -107,6 +111,31 @@ export const updateAction = async ({ request, context }: ActionFunctionArgs) =>
|
|
|
107
111
|
? `https://s3.${validated.bucketRegion}.amazonaws.com`
|
|
108
112
|
: validated.bucketEndpoint;
|
|
109
113
|
|
|
114
|
+
// Only re-probe CORS when the bucket URL actually changes — a transient
|
|
115
|
+
// bucket-side glitch must not block a pure name / scope / prefix edit.
|
|
116
|
+
const existingConfig = await prisma.connectionConfig.findUnique({
|
|
117
|
+
where: { name: originalName },
|
|
118
|
+
});
|
|
119
|
+
const endpointChanged = !existingConfig || existingConfig.endpoint !== endpoint;
|
|
120
|
+
const bucketNameChanged = !existingConfig || existingConfig.bucketName !== bucketName;
|
|
121
|
+
const cytarioOrigin = cytarioConfig.endpoints.webapp;
|
|
122
|
+
let corsWarnings: CorsPreflightWarningReason[] = [];
|
|
123
|
+
if (endpointChanged || bucketNameChanged) {
|
|
124
|
+
const bucketUrl = constructS3Url({
|
|
125
|
+
bucketName,
|
|
126
|
+
region: validated.providerType === "aws" ? validated.bucketRegion : null,
|
|
127
|
+
endpoint,
|
|
128
|
+
});
|
|
129
|
+
const corsResult = await probeBucketCors(bucketUrl, cytarioOrigin);
|
|
130
|
+
if (!corsResult.ok) {
|
|
131
|
+
return {
|
|
132
|
+
formError: describeCorsFailure(corsResult, cytarioOrigin),
|
|
133
|
+
status: "error" as const,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
corsWarnings = corsResult.warnings;
|
|
137
|
+
}
|
|
138
|
+
|
|
110
139
|
try {
|
|
111
140
|
const updatedConfig = await updateConnection(user, originalName, {
|
|
112
141
|
name: validated.name,
|
|
@@ -119,20 +148,27 @@ export const updateAction = async ({ request, context }: ActionFunctionArgs) =>
|
|
|
119
148
|
region: validated.providerType === "aws" ? validated.bucketRegion : null,
|
|
120
149
|
});
|
|
121
150
|
|
|
122
|
-
//
|
|
123
|
-
// client cache (keyed by bucket) for the previous identity.
|
|
151
|
+
// Drop cached credentials so authMiddleware re-mints under the new identity.
|
|
124
152
|
const credentials = session.get("credentials") ?? {};
|
|
125
153
|
delete credentials[updatedConfig.previousName];
|
|
126
154
|
if (updatedConfig.name !== updatedConfig.previousName) {
|
|
127
155
|
delete credentials[updatedConfig.name];
|
|
128
156
|
}
|
|
129
157
|
session.set("credentials", credentials);
|
|
130
|
-
invalidateS3ClientsForBucket(user.sub, updatedConfig.previousBucketName);
|
|
131
158
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
159
|
+
if (corsWarnings.length > 0) {
|
|
160
|
+
session.set("notification", {
|
|
161
|
+
status: "warning",
|
|
162
|
+
message: `Connection updated. ${corsWarnings
|
|
163
|
+
.map((w) => describeCorsWarning(w, cytarioOrigin))
|
|
164
|
+
.join(" ")}`,
|
|
165
|
+
});
|
|
166
|
+
} else {
|
|
167
|
+
session.set("notification", {
|
|
168
|
+
status: "success",
|
|
169
|
+
message: "Connection updated successfully.",
|
|
170
|
+
});
|
|
171
|
+
}
|
|
136
172
|
|
|
137
173
|
return redirect(`/connections/${encodeURIComponent(updatedConfig.name)}`, {
|
|
138
174
|
headers: { "Set-Cookie": await sessionStorage.commitSession(session) },
|
|
@@ -150,8 +186,9 @@ export const updateAction = async ({ request, context }: ActionFunctionArgs) =>
|
|
|
150
186
|
}
|
|
151
187
|
|
|
152
188
|
if (error instanceof Error) {
|
|
189
|
+
// Surface as a form-level banner — the user may have left step 1.
|
|
153
190
|
return {
|
|
154
|
-
|
|
191
|
+
formError: error.message,
|
|
155
192
|
status: "error" as const,
|
|
156
193
|
};
|
|
157
194
|
}
|
|
@@ -32,13 +32,17 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
|
|
32
32
|
defaultShouldRevalidate,
|
|
33
33
|
}) => {
|
|
34
34
|
if (formAction) return defaultShouldRevalidate;
|
|
35
|
-
// Revalidate when navigating back to home from another page
|
|
36
35
|
if (currentUrl.pathname !== nextUrl.pathname) return true;
|
|
37
36
|
return false;
|
|
38
37
|
};
|
|
39
38
|
|
|
39
|
+
export { enrichConnectionsWithPreviews as clientLoader } from "~/routes/connections/connections.clientLoader";
|
|
40
40
|
export { loadConnections as loader } from "~/routes/connections/connections.loader";
|
|
41
41
|
|
|
42
|
+
// Response carries STS credentials — keep it out of every cache between origin
|
|
43
|
+
// and browser.
|
|
44
|
+
export const headers = () => ({ "Cache-Control": "no-store, private" });
|
|
45
|
+
|
|
42
46
|
export default function HomeRoute() {
|
|
43
47
|
const { nodes, connectionConfigs, recentlyViewed, pinnedPaths } = useLoaderData<LoaderData>();
|
|
44
48
|
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Outlet,
|
|
3
|
+
useLoaderData,
|
|
4
|
+
type ClientLoaderFunctionArgs,
|
|
5
|
+
type LoaderFunctionArgs,
|
|
6
|
+
} from "react-router";
|
|
2
7
|
|
|
3
8
|
import { ModalOutlet } from "./ModalOutlet";
|
|
4
9
|
import { authContext, authMiddleware } from "~/.server/auth/authMiddleware";
|
|
@@ -6,11 +11,20 @@ import { useInitConnections } from "~/hooks/useInitConnections";
|
|
|
6
11
|
|
|
7
12
|
export const middleware = [authMiddleware];
|
|
8
13
|
|
|
14
|
+
// Response carries STS credentials — keep it out of every cache between origin
|
|
15
|
+
// and browser.
|
|
16
|
+
export const headers = () => ({ "Cache-Control": "no-store, private" });
|
|
17
|
+
|
|
9
18
|
export const loader = async ({ context }: LoaderFunctionArgs) => {
|
|
10
19
|
const { connectionConfigs, credentials } = context.get(authContext);
|
|
11
20
|
return { connectionConfigs, credentials };
|
|
12
21
|
};
|
|
13
22
|
|
|
23
|
+
// Identity clientLoader — see `app/root.tsx`; works around RR's bulk-fetch
|
|
24
|
+
// short-circuit during the initial `clientLoader.hydrate` pass.
|
|
25
|
+
export const clientLoader = ({ serverLoader }: ClientLoaderFunctionArgs) =>
|
|
26
|
+
serverLoader<typeof loader>();
|
|
27
|
+
|
|
14
28
|
export default function ProtectedLayout() {
|
|
15
29
|
const { connectionConfigs, credentials } = useLoaderData<typeof loader>();
|
|
16
30
|
useInitConnections(connectionConfigs, credentials);
|