Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion helm-chart/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,4 @@ type: application

# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
version: 11.7.2
version: 11.8.0-rc.1
10 changes: 5 additions & 5 deletions helm-chart/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ ingress:
- host: datalab.my-domain.net
EOF

helm install onyxia onyxia/onyxia --version "11.7.2" -f onyxia-values.yaml
helm install onyxia onyxia/onyxia --version "11.8.0-rc.1" -f onyxia-values.yaml
```

To expose Onyxia with the Kubernetes Gateway API instead of an Ingress, use an `HTTPRoute`.
Expand All @@ -40,7 +40,7 @@ httpRoute:
- datalab.my-domain.net
EOF

helm install onyxia onyxia/onyxia --version "11.7.2" -f onyxia-values.yaml
helm install onyxia onyxia/onyxia --version "11.8.0-rc.1" -f onyxia-values.yaml
```

### Using the Keycloak Theme (Optional)
Expand All @@ -62,7 +62,7 @@ extraInitContainers: |
args:
- -c
- |
curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.7.2/keycloak-theme.jar
curl -L -f -S -o /extensions/onyxia.jar https://github.com/InseeFrLab/onyxia/releases/download/v11.8.0-rc.1/keycloak-theme.jar
volumeMounts:
- name: extensions
mountPath: /extensions
Expand Down Expand Up @@ -97,7 +97,7 @@ api:
```

- [The REST API (`api`)](https://github.com/InseeFrLab/onyxia-api/blob/v4.12.0/README.md#configuration)
- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.2/web/.env)
- [The Web Application (`web`)](https://github.com/InseeFrLab/onyxia/blob/web-v5.8.0-rc.1/web/.env)

Below is a sample `onyxia-values.yaml` file that illustrates where to specify the `api` and `web` configuration parameters.

Expand Down Expand Up @@ -150,4 +150,4 @@ httpRoute:
If you are building your own service catalog for Onyxia ([learn how](https://docs.onyxia.sh/catalog-of-services)).
Here are defined the onyxia reserved parameter and the structure of the dynamic context:

[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v5.7.2/web/src/core/ports/OnyxiaApi/XOnyxia.ts)
[`values.schema.json` `"x-onyxia"` specifications](https://github.com/InseeFrLab/onyxia/blob/web-v5.8.0-rc.1/web/src/core/ports/OnyxiaApi/XOnyxia.ts)
2 changes: 1 addition & 1 deletion helm-chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ web:
replicaCount: 1
image:
repository: inseefrlab/onyxia-web
tag: 5.7.2
tag: 5.8.0-rc.1
pullPolicy: IfNotPresent

imagePullSecrets: []
Expand Down
3 changes: 2 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "onyxia-web",
"homepage": "https://onyxia.sh",
"type": "module",
"version": "5.7.2",
"version": "5.8.0-rc.1",
"license": "MIT",
"scripts": {
"postinstall": "yarn install-git-hooks && yarn postinstall:code-gen",
Expand All @@ -23,6 +23,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.828.0",
"@aws-sdk/lib-storage": "^3.828.0",
"@aws-sdk/s3-presigned-post": "3.828.0",
"@aws-sdk/s3-request-presigner": "^3.828.0",
"@aws-sdk/client-sts": "^3.907.0",
"@babel/runtime": "7.26.0",
Expand Down
75 changes: 73 additions & 2 deletions web/src/core/adapters/s3Client/s3Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,15 @@ export function createS3Client(
import("@aws-sdk/client-s3").S3Client
>();

async function getAwsS3Client() {
type Token = NonNullable<
Awaited<ReturnType<typeof getNewlyRequestedOrCachedToken>>
>;

async function getAwsS3Client(options?: { token: Token }) {
const [tokens, AwsS3Client] = await Promise.all([
getNewlyRequestedOrCachedToken(),
options === undefined
? getNewlyRequestedOrCachedToken()
: Promise.resolve(options.token),
import("@aws-sdk/client-s3").then(({ S3Client }) => S3Client)
] as const);

Expand Down Expand Up @@ -550,6 +556,71 @@ export function createS3Client(

return downloadUrl;
},
createPresignedPost: async ({
s3Uri,
validityDurationSecond,
maxObjectSizeInBytes
}) => {
assert(
!isAnonymousProfile,
"Trying to generate a presigned POST with a public client"
);

const { getAwsS3Client, getNewlyRequestedOrCachedToken } = await prApi;

// This is the only recoverable boundary in this operation: obtaining
// temporary credentials can fail when the identity or STS service is
// unreachable. Signing the POST below is otherwise a local operation.
const tokenResult = await getNewlyRequestedOrCachedToken().then(
token => ({ isSuccess: true as const, token }),
error => ({
isSuccess: false as const,
errorMessage: error instanceof Error ? error.message : String(error)
})
);

if (!tokenResult.isSuccess) {
return tokenResult;
}

const { token } = tokenResult;

assert(token !== undefined);

const { awsS3Client } = await getAwsS3Client({ token });

const now = Date.now();
const requestedExpirationTime = now + validityDurationSecond * 1_000;
const expirationTime = Math.min(
requestedExpirationTime,
token.expirationTime ?? requestedExpirationTime
);
const expiresInSecond = Math.max(
1,
Math.floor((expirationTime - now) / 1_000)
);

const { url, fields } = await (
await import("@aws-sdk/s3-presigned-post")
).createPresignedPost(awsS3Client, {
Bucket: s3Uri.bucket,
Key: `${getS3UriKey(s3Uri)}\${filename}`,
Expires: expiresInSecond,
Conditions:
maxObjectSizeInBytes === undefined
? []
: [["content-length-range", 0, maxObjectSizeInBytes]]
});

return {
isSuccess: true,
presignedPost: {
url,
fields,
expirationTime: now + expiresInSecond * 1_000
}
};
},

getObjectContent: async ({ s3Uri, range }) => {
const { getAwsS3Client } = await prApi;
Expand Down
34 changes: 34 additions & 0 deletions web/src/core/ports/S3Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ export type S3Client = {
isForDirectDownload: boolean;
}) => Promise<string>;

/**
* Creates a form that can be used without S3 credentials to upload objects
* whose keys start with the key of `s3Uri`.
*
* `maxObjectSizeInBytes` applies to each object independently. Enforcing a
* maximum size across all objects uploaded with the form requires a stateful
* service in front of S3.
*
* A failure value represents an expected failure to acquire temporary S3
* credentials. Invalid state or unexpected signing errors are not converted
* into this result and still throw.
*/
createPresignedPost: (params: {
s3Uri: S3Uri.TerminatedByDelimiter;
validityDurationSecond: number;
maxObjectSizeInBytes: number | undefined;
}) => Promise<S3Client.CreatePresignedPostReturn>;

getUnsignedObjectHttpUrl: (params: {
s3Uri: S3Uri.NonTerminatedByDelimiter;
isForDirectDownload: boolean;
Expand Down Expand Up @@ -77,6 +95,22 @@ export type S3Client = {
export namespace S3Client {
export type BucketPolicies = Record<string, unknown>;

export type PresignedPost = {
url: string;
fields: Record<string, string>;
expirationTime: number;
};

export type CreatePresignedPostReturn =
| {
isSuccess: true;
presignedPost: PresignedPost;
}
| {
isSuccess: false;
errorMessage: string;
};

export type ListObjectsReturn = ListObjectsReturn.Error | ListObjectsReturn.Success;

export namespace ListObjectsReturn {
Expand Down
6 changes: 5 additions & 1 deletion web/src/core/usecases/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import * as s3ProfilesManagement from "./s3ProfilesManagement";
import * as s3ShareObjectUiController from "./s3ShareObjectUiController";
import * as s3ProfilesCreationUiController from "./s3ProfilesCreationUiController";
import * as s3ExplorerUiController from "./s3ExplorerUiController";
import * as s3FileRequestUiController from "./s3FileRequestUiController";
import * as s3FileRequestCreationUiController from "./s3FileRequestCreationUiController";

export const usecases = {
autoLogoutCountdown,
Expand Down Expand Up @@ -51,5 +53,7 @@ export const usecases = {
s3ProfilesManagement,
s3ShareObjectUiController,
s3ProfilesCreationUiController,
s3ExplorerUiController
s3ExplorerUiController,
s3FileRequestUiController,
s3FileRequestCreationUiController
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type BucketPolicies = Record<string, unknown>;

assert<Equals<S3Client.BucketPolicies, BucketPolicies>>;

type BucketPoliciesByBucket = Record<
export type BucketPoliciesByBucket = Record<
string,
{ bucketPolicies: BucketPolicies | undefined } | undefined
>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ export function computeUploadStatusAtPrefix(params: {
displayName,
s3Uri: s3Uri_newItem,
isDeleting: false,
policy: { isPublic: false, canBeMadePublic: false },
profileNameForSharing: undefined,
publicAccessAction: undefined,
shouldShowShareAction: false,
uploadProgressPercent: NaN
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import type { State } from "../state";
import {
type BucketPoliciesByBucket,
getHasPrefixBeMadePublic,
getIsWithinPrefixThatHasBeenMadePublic
} from "./bucketPolicies";
import type { MainView } from "../selectors";
import { assert, id, type Equals } from "tsafe";
import memoize from "memoizee";

export const stateItemToSelectorItem = (params: {
item: State.ListedPrefix.Item;
isSharingPublicFolderFeatureEnabled: boolean;
bucketPoliciesByBucket: BucketPoliciesByBucket;
isAnonymousS3Profile: boolean;
}): MainView.Item => {
const {
item,
isSharingPublicFolderFeatureEnabled,
bucketPoliciesByBucket,
isAnonymousS3Profile
} = params;

switch (item.type) {
case "object":
return id<MainView.Item.Object>({
type: "object",
displayName: (() => {
const keyBasename = item.s3Uri.keySegments.at(-1);

assert(keyBasename !== undefined);

return keyBasename;
})(),
s3Uri: item.s3Uri,
uploadProgressPercent: undefined,
isDeleting: false,
lastModified: item.lastModified,
size: item.size
});
case "prefix": {
const hasBeenMadePublic = getHasPrefixBeMadePublic({
s3Uri: item.s3Uri,
bucketPoliciesByBucket
});

const getIsWithinPrefixThatHasBeenMadePublic_local = memoize(
() =>
getIsWithinPrefixThatHasBeenMadePublic({
s3Uri: item.s3Uri,
bucketPoliciesByBucket
}).isWithinPrefixThatHasBeenMadePublic
);

return id<MainView.Item.PrefixSegment>({
type: "prefix segment",
displayName: (() => {
const lastSegment = item.s3Uri.keySegments.at(-1);

assert(lastSegment !== undefined);

return lastSegment;
})(),
s3Uri: item.s3Uri,
uploadProgressPercent: undefined,
isDeleting: false,
publicAccessAction: (() => {
if (isAnonymousS3Profile) {
return undefined;
}

if (hasBeenMadePublic) {
return "make private";
}

if (getIsWithinPrefixThatHasBeenMadePublic_local()) {
return undefined;
}

return "make public";
})(),
shouldShowShareAction:
isSharingPublicFolderFeatureEnabled &&
(isAnonymousS3Profile ||
hasBeenMadePublic ||
getIsWithinPrefixThatHasBeenMadePublic_local())
});
}
default:
assert<Equals<typeof item, never>>(false);
}
};
Loading
Loading