@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
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ For the hosted product, see [cytario.com](https://www.cytario.com).
|
|
|
23
23
|
| Styling | Tailwind CSS, [@cytario/design](https://github.com/cytario/cytario-design) |
|
|
24
24
|
| Auth | OAuth 2.0 via Keycloak, STS for S3 credentials |
|
|
25
25
|
| Database | PostgreSQL (Prisma ORM), Redis/Valkey (sessions) |
|
|
26
|
-
| Cloud | AWS SDK v3 (S3, STS)
|
|
26
|
+
| Cloud | AWS SDK v3 (S3, STS) |
|
|
27
27
|
| CI/CD | GitHub Actions, semantic-release, GHCR |
|
|
28
28
|
|
|
29
29
|
## Plugin model
|
|
@@ -8,5 +8,4 @@ OAuth 2.0 Authorization Code Flow against Keycloak. Session cookies are httpOnly
|
|
|
8
8
|
| `authMiddleware.ts` | Refresh tokens if needed; populate `authContext` for downstream loaders. |
|
|
9
9
|
| `getSessionCredentials.ts` | Mint STS credentials per connection. |
|
|
10
10
|
| `verifyIdToken.ts` | JWKS-based idToken signature/expiry verification. |
|
|
11
|
-
| `getS3Client.ts` | Build an SDK-v3 S3 client with the connection's signed credentials. |
|
|
12
11
|
| `keycloakAdmin/` | Service-account-backed admin API for user/group management. |
|
|
@@ -16,10 +16,6 @@ export interface AuthContextData extends SessionData {
|
|
|
16
16
|
|
|
17
17
|
export const authContext = createContext<AuthContextData>();
|
|
18
18
|
|
|
19
|
-
/**
|
|
20
|
-
* Lightweight expiry check for refresh tokens (opaque to clients).
|
|
21
|
-
* Only checks the `exp` claim — no signature verification needed.
|
|
22
|
-
*/
|
|
23
19
|
const isRefreshTokenValid = (token?: string): boolean => {
|
|
24
20
|
if (!token) return false;
|
|
25
21
|
|
|
@@ -38,11 +34,6 @@ const isComplete = (data: Partial<SessionData>): boolean => {
|
|
|
38
34
|
|
|
39
35
|
const label = createLabel("authorize", "green");
|
|
40
36
|
|
|
41
|
-
/**
|
|
42
|
-
* Fetches all connection configs and credentials for the user.
|
|
43
|
-
* Only fetches credentials for connections with missing or expired credentials.
|
|
44
|
-
* Returns updated session data and connection configs.
|
|
45
|
-
*/
|
|
46
37
|
const fetchAllCredentials = async (
|
|
47
38
|
sessionData: SessionData,
|
|
48
39
|
): Promise<{ sessionData: SessionData; connectionConfigs: ConnectionConfig[] }> => {
|
|
@@ -59,12 +50,6 @@ const fetchAllCredentials = async (
|
|
|
59
50
|
};
|
|
60
51
|
};
|
|
61
52
|
|
|
62
|
-
/**
|
|
63
|
-
* Middleware that validates and refreshes authentication tokens.
|
|
64
|
-
* Fetches connection configs and credentials for all visible connections.
|
|
65
|
-
* Sets validated session data in authContext for downstream use.
|
|
66
|
-
* Export this from protected routes that require authentication.
|
|
67
|
-
*/
|
|
68
53
|
export const authMiddleware: MiddlewareFunction = async ({ request, context }, next) => {
|
|
69
54
|
console.info(`${label} ${request.method} ${request.url}`);
|
|
70
55
|
|
|
@@ -80,7 +65,6 @@ export const authMiddleware: MiddlewareFunction = async ({ request, context }, n
|
|
|
80
65
|
let updatedSessionData = sessionData as SessionData;
|
|
81
66
|
const { authTokens } = updatedSessionData;
|
|
82
67
|
|
|
83
|
-
// Verify idToken signature via JWKS
|
|
84
68
|
const idTokenPayload = await verifyIdToken(authTokens.idToken);
|
|
85
69
|
|
|
86
70
|
if (idTokenPayload) {
|
|
@@ -88,7 +72,6 @@ export const authMiddleware: MiddlewareFunction = async ({ request, context }, n
|
|
|
88
72
|
await fetchAllCredentials(updatedSessionData);
|
|
89
73
|
updatedSessionData = withCredentials;
|
|
90
74
|
|
|
91
|
-
// Only commit session if credentials changed
|
|
92
75
|
if (updatedSessionData.credentials !== sessionData.credentials) {
|
|
93
76
|
session.set("credentials", updatedSessionData.credentials);
|
|
94
77
|
await sessionStorage.commitSession(session);
|
|
@@ -98,7 +81,6 @@ export const authMiddleware: MiddlewareFunction = async ({ request, context }, n
|
|
|
98
81
|
return next();
|
|
99
82
|
}
|
|
100
83
|
|
|
101
|
-
// If idToken is invalid but refreshToken is valid, refresh tokens
|
|
102
84
|
if (isRefreshTokenValid(authTokens.refreshToken)) {
|
|
103
85
|
console.info(`${label} Fetch new tokens and credentials`);
|
|
104
86
|
|
|
@@ -127,21 +109,17 @@ export const authMiddleware: MiddlewareFunction = async ({ request, context }, n
|
|
|
127
109
|
}
|
|
128
110
|
}
|
|
129
111
|
|
|
130
|
-
|
|
131
|
-
await logout(request.url, session);
|
|
112
|
+
return logout(request.url, session);
|
|
132
113
|
};
|
|
133
114
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
* @throws Redirect to login page
|
|
139
|
-
*/
|
|
140
|
-
const logout = async (url: string, session: CytarioSession) => {
|
|
115
|
+
// Return the redirect rather than throwing it: under RR's middleware single-fetch
|
|
116
|
+
// path a thrown redirect Response is caught and re-encoded as a 500, which
|
|
117
|
+
// surfaces as `SingleFetchNoResultError` in the root ErrorBoundary.
|
|
118
|
+
const logout = async (url: string, session: CytarioSession): Promise<Response> => {
|
|
141
119
|
console.info(`${label} Delete session and redirect to login`);
|
|
142
120
|
const requestUrl = new URL(url);
|
|
143
121
|
const relativeUrl = requestUrl.pathname + requestUrl.search;
|
|
144
|
-
|
|
122
|
+
return redirect(`/login?redirect=${encodeURIComponent(relativeUrl)}`, {
|
|
145
123
|
headers: {
|
|
146
124
|
"Set-Cookie": await sessionStorage.destroySession(session),
|
|
147
125
|
},
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AssumeRoleWithWebIdentityCommand, Credentials, STSClient } from "@aws-sdk/client-sts";
|
|
2
2
|
|
|
3
|
+
import { buildSessionPolicy } from "./sessionPolicy";
|
|
3
4
|
import { type ConnectionsCredentials, type SessionData } from "./sessionStorage";
|
|
4
5
|
import { ConnectionConfig } from "~/.generated/client";
|
|
5
6
|
import { createLabel } from "~/.server/logging";
|
|
@@ -34,7 +35,7 @@ const fetchTemporaryCredentials = async (
|
|
|
34
35
|
idToken: string,
|
|
35
36
|
roleSessionName: string,
|
|
36
37
|
): Promise<Credentials> => {
|
|
37
|
-
const { region, endpoint, roleArn } = connectionConfig;
|
|
38
|
+
const { region, endpoint, roleArn, provider, bucketName, prefix } = connectionConfig;
|
|
38
39
|
|
|
39
40
|
const actualRegion = region ?? "eu-central-1";
|
|
40
41
|
const providerConfig = getS3ProviderConfig(endpoint, actualRegion);
|
|
@@ -45,11 +46,20 @@ const fetchTemporaryCredentials = async (
|
|
|
45
46
|
region: actualRegion,
|
|
46
47
|
});
|
|
47
48
|
|
|
49
|
+
// Inline session policy is an AWS-specific STS feature: STS intersects it
|
|
50
|
+
// with the role's attached policy, so the minted credential cannot exceed
|
|
51
|
+
// the configured prefix scope even if the role itself is broader.
|
|
52
|
+
// Non-AWS providers (e.g. MinIO) may ignore or reject the `Policy` field,
|
|
53
|
+
// so we omit it there — the role's intrinsic scope is the only bound.
|
|
54
|
+
const sessionPolicy =
|
|
55
|
+
provider === "aws" ? buildSessionPolicy({ bucketName, prefix }) : undefined;
|
|
56
|
+
|
|
48
57
|
const command = new AssumeRoleWithWebIdentityCommand({
|
|
49
58
|
RoleArn: roleArn ?? undefined,
|
|
50
59
|
RoleSessionName: roleSessionName,
|
|
51
60
|
WebIdentityToken: idToken,
|
|
52
61
|
DurationSeconds: 60 * 60 * 1, // 1 hour
|
|
62
|
+
...(sessionPolicy ? { Policy: sessionPolicy } : {}),
|
|
53
63
|
});
|
|
54
64
|
|
|
55
65
|
const { Credentials } = await stsClient.send(command);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds an inline IAM session policy for `AssumeRoleWithWebIdentityCommand`.
|
|
3
|
+
*
|
|
4
|
+
* STS intersects this policy with the role's attached policy, so even if the
|
|
5
|
+
* underlying role permits more, the minted credential cannot escape the
|
|
6
|
+
* configured connection prefix.
|
|
7
|
+
*
|
|
8
|
+
* AWS-specific: non-AWS providers (MinIO) may ignore or reject `Policy`;
|
|
9
|
+
* guard the attachment behind `provider === "aws"`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface SessionPolicyArgs {
|
|
13
|
+
bucketName: string;
|
|
14
|
+
prefix: string | null | undefined;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const stripSlashes = (prefix: string): string => prefix.replace(/^\/+|\/+$/g, "");
|
|
18
|
+
|
|
19
|
+
/** Build an inline IAM session policy for `AssumeRoleWithWebIdentityCommand`. */
|
|
20
|
+
export const buildSessionPolicy = ({ bucketName, prefix }: SessionPolicyArgs): string => {
|
|
21
|
+
const normalised = typeof prefix === "string" ? stripSlashes(prefix) : "";
|
|
22
|
+
// Defense-in-depth: refuse wildcards here so the schema is not the only gate
|
|
23
|
+
// protecting cross-tenant `StringLike` conditions.
|
|
24
|
+
if (/[*?]/.test(normalised)) {
|
|
25
|
+
throw new Error("Prefix may not contain IAM wildcard characters (`*`, `?`)");
|
|
26
|
+
}
|
|
27
|
+
const hasPrefix = normalised.length > 0;
|
|
28
|
+
|
|
29
|
+
const bucketArn = `arn:aws:s3:::${bucketName}`;
|
|
30
|
+
const objectArn = hasPrefix ? `${bucketArn}/${normalised}/*` : `${bucketArn}/*`;
|
|
31
|
+
|
|
32
|
+
// Empty-prefix listing must omit `Condition`: AWS evaluates an absent
|
|
33
|
+
// `prefix` query parameter as `""`, and `StringLike "*"` does not match it.
|
|
34
|
+
// Allowed values must anchor on `/`, otherwise IAM allows
|
|
35
|
+
// `ListBucket prefix=foo` which S3 expands to siblings like `foobar.txt`.
|
|
36
|
+
const listStatement = hasPrefix
|
|
37
|
+
? {
|
|
38
|
+
Sid: "ListBucketScopedToPrefix",
|
|
39
|
+
Effect: "Allow",
|
|
40
|
+
Action: "s3:ListBucket",
|
|
41
|
+
Resource: bucketArn,
|
|
42
|
+
Condition: {
|
|
43
|
+
StringLike: {
|
|
44
|
+
"s3:prefix": [`${normalised}/`, `${normalised}/*`],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
: {
|
|
49
|
+
Sid: "ListBucketWholeBucket",
|
|
50
|
+
Effect: "Allow",
|
|
51
|
+
Action: "s3:ListBucket",
|
|
52
|
+
Resource: bucketArn,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const policy = {
|
|
56
|
+
Version: "2012-10-17",
|
|
57
|
+
Statement: [
|
|
58
|
+
listStatement,
|
|
59
|
+
{
|
|
60
|
+
Sid: "GetObjectScopedToPrefix",
|
|
61
|
+
Effect: "Allow",
|
|
62
|
+
Action: "s3:GetObject",
|
|
63
|
+
Resource: objectArn,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return JSON.stringify(policy);
|
|
69
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side CORS preflight probe run at connection create / update.
|
|
3
|
+
*
|
|
4
|
+
* Cytario's data plane is browser-direct, so the bucket must advertise a CORS
|
|
5
|
+
* policy that matches the cytario origin. Probe outcomes:
|
|
6
|
+
* - `ok: false` — the browser will hard-block reads; the form rejects.
|
|
7
|
+
* - `ok: true, warnings: ["wildcard_origin"]` — `ACAO: *` works today (the
|
|
8
|
+
* read path is non-credentialed) but is flagged for operator hygiene.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { isAllowedS3Host } from "~/utils/s3HostAllowlist";
|
|
12
|
+
import { SIGNED_REQUEST_HEADERS } from "~/utils/signedFetch";
|
|
13
|
+
|
|
14
|
+
export type CorsPreflightFailureReason =
|
|
15
|
+
| "network"
|
|
16
|
+
| "missing_origin_header"
|
|
17
|
+
| "preflight_status"
|
|
18
|
+
| "host_not_allowed";
|
|
19
|
+
|
|
20
|
+
export type CorsPreflightWarningReason = "wildcard_origin";
|
|
21
|
+
|
|
22
|
+
export interface CorsPreflightResult {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
reason?: CorsPreflightFailureReason;
|
|
25
|
+
warnings: CorsPreflightWarningReason[];
|
|
26
|
+
detail?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const PREFLIGHT_TIMEOUT_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Strips IPv4 / IPv6 literals from an error `detail` so the resolved IP of an
|
|
33
|
+
* upstream endpoint never reaches the operator UI — denies the probe as a
|
|
34
|
+
* port-scan oracle.
|
|
35
|
+
*/
|
|
36
|
+
export function redactIpLiterals(message: string): string {
|
|
37
|
+
let result = message.replace(/\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g, "[redacted]");
|
|
38
|
+
result = result.replace(
|
|
39
|
+
/(?:[0-9a-fA-F]{1,4}:){1,7}(?::[0-9a-fA-F]{1,4}){1,7}|(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|::1|::/g,
|
|
40
|
+
"[redacted]",
|
|
41
|
+
);
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Issue a CORS preflight against the bucket; wildcard ACAO surfaces as a warning. */
|
|
46
|
+
export async function probeBucketCors(
|
|
47
|
+
bucketUrl: string,
|
|
48
|
+
cytarioOrigin: string,
|
|
49
|
+
): Promise<CorsPreflightResult> {
|
|
50
|
+
// Refuse out-of-allowlist URLs so the probe can never be an SSRF oracle
|
|
51
|
+
// against IMDS / RFC1918 / loopback, independent of upstream form validation.
|
|
52
|
+
if (!isAllowedS3Host(bucketUrl)) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: "host_not_allowed",
|
|
56
|
+
warnings: [],
|
|
57
|
+
detail: "Host is not in the S3 allowlist",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let response: Response;
|
|
62
|
+
try {
|
|
63
|
+
response = await globalThis.fetch(bucketUrl, {
|
|
64
|
+
method: "OPTIONS",
|
|
65
|
+
headers: {
|
|
66
|
+
Origin: cytarioOrigin,
|
|
67
|
+
"Access-Control-Request-Method": "GET",
|
|
68
|
+
"Access-Control-Request-Headers": SIGNED_REQUEST_HEADERS.join(", "),
|
|
69
|
+
},
|
|
70
|
+
// Block redirects — a 30x would leak the Origin header onward and make
|
|
71
|
+
// the probe lie about the configured bucket.
|
|
72
|
+
redirect: "error",
|
|
73
|
+
signal: AbortSignal.timeout(PREFLIGHT_TIMEOUT_MS),
|
|
74
|
+
});
|
|
75
|
+
} catch (error) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: "network",
|
|
79
|
+
warnings: [],
|
|
80
|
+
detail: redactIpLiterals(error instanceof Error ? error.message : String(error)),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (response.status < 200 || response.status >= 300) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
reason: "preflight_status",
|
|
88
|
+
warnings: [],
|
|
89
|
+
detail: `Preflight returned HTTP ${response.status}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const allowOrigin = response.headers.get("access-control-allow-origin");
|
|
94
|
+
if (!allowOrigin) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: "missing_origin_header",
|
|
98
|
+
warnings: [],
|
|
99
|
+
detail: "Response did not include Access-Control-Allow-Origin",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const warnings: CorsPreflightWarningReason[] = [];
|
|
104
|
+
if (allowOrigin.trim() === "*") {
|
|
105
|
+
warnings.push("wildcard_origin");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return { ok: true, warnings };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Human-readable failure message shown on the connection form. */
|
|
112
|
+
export function describeCorsFailure(result: CorsPreflightResult, cytarioOrigin: string): string {
|
|
113
|
+
switch (result.reason) {
|
|
114
|
+
case "network":
|
|
115
|
+
return "Could not reach the bucket from the cytario server. Check the endpoint URL and DNS.";
|
|
116
|
+
case "missing_origin_header":
|
|
117
|
+
return `Bucket does not advertise CORS for cytario. Configure the bucket's CORS policy to allow Origin ${cytarioOrigin}.`;
|
|
118
|
+
case "preflight_status":
|
|
119
|
+
return `Bucket rejected the cytario CORS preflight (${result.detail ?? "non-2xx response"}). Configure the bucket's CORS policy to allow Origin ${cytarioOrigin}.`;
|
|
120
|
+
case "host_not_allowed":
|
|
121
|
+
return `Bucket host is not in the cytario S3 allowlist. Ask the operator to add it to CYTARIO_ALLOWED_S3_HOSTS.`;
|
|
122
|
+
default:
|
|
123
|
+
return `Bucket CORS preflight failed: ${result.detail ?? "unknown error"}.`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Human-readable message for a non-blocking warning. */
|
|
128
|
+
export function describeCorsWarning(
|
|
129
|
+
warning: CorsPreflightWarningReason,
|
|
130
|
+
cytarioOrigin: string,
|
|
131
|
+
): string {
|
|
132
|
+
switch (warning) {
|
|
133
|
+
case "wildcard_origin":
|
|
134
|
+
return `Bucket allows any origin (CORS: *). Consider restricting to ${cytarioOrigin}.`;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { getAllowedS3Hosts } from "~/utils/s3HostAllowlist";
|
|
2
|
+
|
|
3
|
+
export { getAllowedS3Hosts };
|
|
4
|
+
|
|
5
|
+
/** Build the CSP header value. Callers feed the env so tests can override it. */
|
|
6
|
+
export function buildContentSecurityPolicy(
|
|
7
|
+
env: Record<string, string | undefined> = process.env,
|
|
8
|
+
): string {
|
|
9
|
+
const connectSrc = ["'self'", ...getAllowedS3Hosts(env)].join(" ");
|
|
10
|
+
|
|
11
|
+
const directives: Record<string, string> = {
|
|
12
|
+
"default-src": "'self'",
|
|
13
|
+
"connect-src": connectSrc,
|
|
14
|
+
// `'unsafe-inline'` is required by React Router's streamed hydration
|
|
15
|
+
// bootstrap script. `'unsafe-eval'` is required by numcodecs' emscripten
|
|
16
|
+
// dyncall trampolines (Zarr v2 blosc / lz4 / zstd). `'wasm-unsafe-eval'`
|
|
17
|
+
// covers DuckDB WASM. TODO: drop `'unsafe-eval'` once codecs go CSP-clean.
|
|
18
|
+
"script-src": "'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'",
|
|
19
|
+
"style-src": "'self' 'unsafe-inline'",
|
|
20
|
+
"img-src": "'self' data: blob:",
|
|
21
|
+
"font-src": "'self' data:",
|
|
22
|
+
// DuckDB WASM spawns its main worker from a same-origin asset and uses
|
|
23
|
+
// `blob:` URLs internally for in-worker file buffers.
|
|
24
|
+
"worker-src": "'self' blob:",
|
|
25
|
+
"base-uri": "'self'",
|
|
26
|
+
"form-action": "'self'",
|
|
27
|
+
"object-src": "'none'",
|
|
28
|
+
"frame-ancestors": "'none'",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return Object.entries(directives)
|
|
32
|
+
.map(([directive, value]) => `${directive} ${value}`)
|
|
33
|
+
.join("; ");
|
|
34
|
+
}
|
|
@@ -201,8 +201,8 @@ The viewer supports **offset sidecar files** (`.offsets.json`) for faster OME-TI
|
|
|
201
201
|
```
|
|
202
202
|
Route Loader (server)
|
|
203
203
|
├── Detects OME-TIFF via getOffsetKeyForOmeTiff()
|
|
204
|
-
├──
|
|
205
|
-
├──
|
|
204
|
+
├── Provides signedFetch for direct SigV4-signed GetObject of image
|
|
205
|
+
├── Provides signedFetch for direct SigV4-signed GetObject of .offsets.json (in parallel)
|
|
206
206
|
└── Returns { url, offsetsUrl } to client
|
|
207
207
|
|
|
208
208
|
ImageViewer (client)
|
|
@@ -35,7 +35,6 @@ const gridClasses: Partial<Record<ViewMode, string>> = {
|
|
|
35
35
|
grid: "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6",
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
-
/** Create a signedFetch that lazily resolves credentials from the connections store. */
|
|
39
38
|
function useSignedFetch(connectionName: string) {
|
|
40
39
|
const connectionConfig = useConnectionsStore(select.connectionConfig(connectionName));
|
|
41
40
|
|
|
@@ -57,8 +56,6 @@ function BucketCardGridItem({ node, connectionName }: { node: TreeNode; connecti
|
|
|
57
56
|
const to = buildConnectionPath(connectionName, node.pathName);
|
|
58
57
|
const handlePress = useCallback(() => navigate(to), [navigate, to]);
|
|
59
58
|
|
|
60
|
-
// Bucket nodes carry the first-image key from the connections loader on
|
|
61
|
-
// `_Object.Key` (already absolute — includes any configured prefix).
|
|
62
59
|
const previewKey = node._Object?.Key ?? null;
|
|
63
60
|
const hasPreview = !!previewKey && isImageFile(previewKey) && !!signedFetch;
|
|
64
61
|
const s3Url = hasPreview && connectionConfig ? constructS3Url(connectionConfig, previewKey) : "";
|
|
@@ -66,9 +63,8 @@ function BucketCardGridItem({ node, connectionName }: { node: TreeNode; connecti
|
|
|
66
63
|
return (
|
|
67
64
|
<StorageConnectionCard
|
|
68
65
|
name={node.name}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
status="connected"
|
|
66
|
+
status={node.connectionStatus ?? "loading"}
|
|
67
|
+
errorMessage={node.connectionErrorMessage}
|
|
72
68
|
meta={
|
|
73
69
|
connectionConfig && (
|
|
74
70
|
<>
|
|
@@ -106,11 +102,6 @@ function FileCardGridItem({
|
|
|
106
102
|
const handleInfo = useNodeInfoModal(node);
|
|
107
103
|
|
|
108
104
|
const { connectionConfig: config, signedFetch } = useSignedFetch(connectionName);
|
|
109
|
-
// `_Object.Key` is absolute (prefix already applied) and set for both file
|
|
110
|
-
// nodes (from listing) and directory nodes (first image inside, via
|
|
111
|
-
// buildDirectoryTree). File nodes without `_Object` — e.g. recently-viewed
|
|
112
|
-
// entries reconstructed from DB — fall back to the resolver URL for the
|
|
113
|
-
// node's own resourceId.
|
|
114
105
|
const explicitKey = node._Object?.Key ?? null;
|
|
115
106
|
const resolvedHttpsUrl = useConnectionsStore(selectHttpsUrl(node.id));
|
|
116
107
|
|
|
@@ -5,18 +5,14 @@ import { Link, useNavigate } from "react-router";
|
|
|
5
5
|
import { type TreeNode } from "./buildDirectoryTree";
|
|
6
6
|
import type { DirectoryKind } from "./DirectoryView";
|
|
7
7
|
import { DirectoryViewEmptyState } from "./DirectoryViewEmptyState";
|
|
8
|
+
import { useLazyTreeNodes } from "./useLazyTreeNodes";
|
|
8
9
|
import { TooltipSpan } from "../Tooltip/TooltipSpan";
|
|
9
10
|
import { getFileTypeIcon } from "~/utils/fileType";
|
|
10
11
|
import { buildConnectionPath } from "~/utils/resourceId";
|
|
11
12
|
|
|
12
|
-
/* ------------------------------------------------------------------ */
|
|
13
|
-
/* DirectoryViewTree */
|
|
14
|
-
/* ------------------------------------------------------------------ */
|
|
15
|
-
|
|
16
13
|
interface DirectoryViewTreeProps {
|
|
17
14
|
/** The full (unfiltered) tree of nodes. Filtering is done via searchTerm. */
|
|
18
15
|
nodes: TreeNode[];
|
|
19
|
-
/** Pass-through search term for the Tree component's built-in filtering. */
|
|
20
16
|
searchTerm?: string;
|
|
21
17
|
kind: DirectoryKind;
|
|
22
18
|
}
|
|
@@ -34,20 +30,20 @@ export function NodeLinkIcon({ node }: { node: TreeNode }) {
|
|
|
34
30
|
}
|
|
35
31
|
|
|
36
32
|
/**
|
|
37
|
-
* Thin wrapper around `@cytario/design`'s `<Tree>`
|
|
38
|
-
*
|
|
39
|
-
* `DirectoryView` and `recent.route.tsx`.
|
|
33
|
+
* Thin wrapper around `@cytario/design`'s `<Tree>` that navigates on row
|
|
34
|
+
* activation.
|
|
40
35
|
*
|
|
41
|
-
* @deprecated Planned for removal as part of tree consolidation —
|
|
42
|
-
* [C-150](https://app.plane.so/cytario/browse/C-150/). Once the design-system
|
|
43
|
-
* `<Tree>` supports auto-height + controllable open state (or is rewritten),
|
|
44
|
-
* this wrapper and {@link DirectoryTree} should collapse into a single
|
|
45
|
-
* component used across the app.
|
|
36
|
+
* @deprecated Planned for removal as part of tree consolidation — C-150.
|
|
46
37
|
*/
|
|
47
|
-
export function DirectoryViewTree({
|
|
38
|
+
export function DirectoryViewTree({
|
|
39
|
+
nodes: initialNodes,
|
|
40
|
+
searchTerm,
|
|
41
|
+
kind,
|
|
42
|
+
}: DirectoryViewTreeProps) {
|
|
48
43
|
const navigate = useNavigate();
|
|
44
|
+
const { nodes, loadChildren } = useLazyTreeNodes(initialNodes);
|
|
49
45
|
|
|
50
|
-
if (
|
|
46
|
+
if (initialNodes.length === 0) return <DirectoryViewEmptyState kind={kind} />;
|
|
51
47
|
|
|
52
48
|
return (
|
|
53
49
|
<div className="overflow-hidden rounded-[var(--border-radius-md)] border border-[var(--color-border-default)]">
|
|
@@ -55,21 +51,20 @@ export function DirectoryViewTree({ nodes, searchTerm, kind }: DirectoryViewTree
|
|
|
55
51
|
aria-label="Directory tree"
|
|
56
52
|
data={nodes}
|
|
57
53
|
selectionMode="none"
|
|
58
|
-
openByDefault
|
|
54
|
+
openByDefault={false}
|
|
59
55
|
size="comfortable"
|
|
60
56
|
height={600}
|
|
61
57
|
searchTerm={searchTerm}
|
|
62
58
|
searchMatch={(node, term) => node.name.toLowerCase().includes(term.toLowerCase())}
|
|
59
|
+
onToggle={(node) => {
|
|
60
|
+
void loadChildren(node).catch(() => {});
|
|
61
|
+
}}
|
|
63
62
|
onActivate={(node) => navigate(buildConnectionPath(node.connectionName, node.pathName))}
|
|
64
63
|
/>
|
|
65
64
|
</div>
|
|
66
65
|
);
|
|
67
66
|
}
|
|
68
67
|
|
|
69
|
-
/* ------------------------------------------------------------------ */
|
|
70
|
-
/* Lightweight recursive tree (used by GlobalSearch / Suggestions) */
|
|
71
|
-
/* ------------------------------------------------------------------ */
|
|
72
|
-
|
|
73
68
|
interface DirectoryTreeProps {
|
|
74
69
|
nodes: TreeNode[];
|
|
75
70
|
action?: (node: TreeNode) => void;
|
|
@@ -119,13 +114,10 @@ function DirectoryTreeRecursive({ nodes, action, className }: DirectoryTreeProps
|
|
|
119
114
|
}
|
|
120
115
|
|
|
121
116
|
/**
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* doesn't fit. Used by `search.route.tsx` and `GlobalSearch/Suggestions.tsx`.
|
|
117
|
+
* Recursive tree for lightweight contexts where the design-system `<Tree>`
|
|
118
|
+
* (fixed height + virtualization) doesn't fit.
|
|
125
119
|
*
|
|
126
|
-
* @deprecated Planned for removal as part of tree consolidation —
|
|
127
|
-
* [C-150](https://app.plane.so/cytario/browse/C-150/). One unified tree
|
|
128
|
-
* component should replace both this and {@link DirectoryViewTree}.
|
|
120
|
+
* @deprecated Planned for removal as part of tree consolidation — C-150.
|
|
129
121
|
*/
|
|
130
122
|
export function DirectoryTree(props: DirectoryTreeProps) {
|
|
131
123
|
return <DirectoryTreeRecursive {...props} />;
|