@opencloning/utils 1.9.2 → 2.0.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/CHANGELOG.md +58 -0
- package/package.json +5 -3
- package/src/utils/httpClientAuth.js +16 -3
- package/src/utils/httpClientAuth.test.js +41 -9
- package/src/utils/httpClients.js +48 -0
- package/src/utils/httpClients.test.js +71 -0
- package/src/utils/index.js +1 -1
- package/src/utils/ncbiRequests.js +2 -2
- package/src/utils/readNwrite.js +13 -4
- package/src/utils/thunks.js +4 -3
- package/src/utils/urlUtils.js +23 -0
- package/src/utils/urlUtils.test.js +49 -0
- package/src/utils/verificationFileStore.js +86 -0
- package/src/utils/verificationFileStore.test.js +101 -0
- package/src/utils/getHttpClient.js +0 -31
- package/src/utils/getHttpClient.test.js +0 -19
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# @opencloning/utils
|
|
2
2
|
|
|
3
|
+
## 2.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- [#778](https://github.com/OpenCloning/OpenCloning_frontend/pull/778) [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394) Thanks [@manulera](https://github.com/manulera)! - Only send the auth token to the backend, and rename the http client APIs.
|
|
8
|
+
|
|
9
|
+
- `@opencloning/utils`: `@opencloning/utils/httpClients` exports `getBackendHttpClient(backendUrl, { authenticated })`, a client that can only reach the backend (`allowAbsoluteUrls: false`) and is the only one carrying the auth token, and `getExternalHttpClient()` for whitelisted external urls. Replaces `@opencloning/utils/getHttpClient` (`getHttpClient`, `getAuthenticatedHttpClient`). `attachAuthInterceptors` refuses clients that could send the token elsewhere.
|
|
10
|
+
- `@opencloning/ui` (breaking): `useHttpClient` is renamed `useBackendHttpClient` and takes urls relative to `backendUrl`; `useBackendRoute` is removed; `useBackendAPI` is renamed `useSourceRequest` and `sendPostRequest` is renamed `sendSourceRequest`.
|
|
11
|
+
- `@opencloning/opencloningdb`, `@opencloning/opencloning-elabftw`: clients can no longer be pointed at other hosts.
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- [#778](https://github.com/OpenCloning/OpenCloning_frontend/pull/778) [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394) Thanks [@manulera](https://github.com/manulera)! - Encode values interpolated into request URL paths to prevent client-side path traversal. A sample UID such as `../workspaces/1/users/5` could make a user's own request hit a different endpoint, e.g. "delete UID" removing a workspace member.
|
|
16
|
+
|
|
17
|
+
- `@opencloning/utils`: new `encodePathSegment` (`@opencloning/utils/urlUtils`), which applies `encodeURIComponent` and rejects `.` and `..`.
|
|
18
|
+
- `@opencloning/opencloningdb`: all path parameters in `endpoints.js` are encoded.
|
|
19
|
+
- `@opencloning/opencloning-elabftw`: eLabFTW item and upload paths are encoded.
|
|
20
|
+
- `@opencloning/ui`: the `example`, `key` and `template` URL parameters are encoded when loading examples and templates.
|
|
21
|
+
|
|
22
|
+
### Patch Changes
|
|
23
|
+
|
|
24
|
+
- [#778](https://github.com/OpenCloning/OpenCloning_frontend/pull/778) [`fe1d81c`](https://github.com/OpenCloning/OpenCloning_frontend/commit/fe1d81c6f4b85f6317a2a9e46cc68c571c79c394) Thanks [@manulera](https://github.com/manulera)! - Safer white-list url checking
|
|
25
|
+
|
|
26
|
+
- Updated dependencies []:
|
|
27
|
+
- @opencloning/store@2.0.0
|
|
28
|
+
|
|
29
|
+
## 1.10.0
|
|
30
|
+
|
|
31
|
+
### Patch Changes
|
|
32
|
+
|
|
33
|
+
- Updated dependencies []:
|
|
34
|
+
- @opencloning/store@1.10.0
|
|
35
|
+
|
|
36
|
+
## 1.9.5
|
|
37
|
+
|
|
38
|
+
### Patch Changes
|
|
39
|
+
|
|
40
|
+
- [#771](https://github.com/OpenCloning/OpenCloning_frontend/pull/771) [`73363e7`](https://github.com/OpenCloning/OpenCloning_frontend/commit/73363e7a7f7e494e20a54bd87e5dba97f8a9197e) Thanks [@manulera](https://github.com/manulera)! - Keep OIDC sessions signed in by requesting a token on each API call instead of reusing a stored JWT
|
|
41
|
+
|
|
42
|
+
- Updated dependencies []:
|
|
43
|
+
- @opencloning/store@1.9.5
|
|
44
|
+
|
|
45
|
+
## 1.9.4
|
|
46
|
+
|
|
47
|
+
### Patch Changes
|
|
48
|
+
|
|
49
|
+
- Updated dependencies []:
|
|
50
|
+
- @opencloning/store@1.9.4
|
|
51
|
+
|
|
52
|
+
## 1.9.3
|
|
53
|
+
|
|
54
|
+
### Patch Changes
|
|
55
|
+
|
|
56
|
+
- [#762](https://github.com/OpenCloning/OpenCloning_frontend/pull/762) [`df69a1b`](https://github.com/OpenCloning/OpenCloning_frontend/commit/df69a1bfb3b3128e93d89eef48a29f81a54ebb82) Thanks [@manulera](https://github.com/manulera)! - Fix QuotaExceededError when adding more than ~10 AB1/sequencing files (OpenCloning/OpenCloning#82). The raw contents of the verification files used to be persisted in Web Storage (localStorage, later sessionStorage), which has a quota of ~5 MB, so submitting around 10+ AB1 files failed with `QuotaExceededError` and the files/alignments disappeared. The contents are now kept in an in-memory store for the duration of the tab session, which has no quota.
|
|
57
|
+
|
|
58
|
+
- Updated dependencies [[`df69a1b`](https://github.com/OpenCloning/OpenCloning_frontend/commit/df69a1bfb3b3128e93d89eef48a29f81a54ebb82)]:
|
|
59
|
+
- @opencloning/store@1.9.3
|
|
60
|
+
|
|
3
61
|
## 1.9.2
|
|
4
62
|
|
|
5
63
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"license": "MIT",
|
|
3
3
|
"name": "@opencloning/utils",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "2.0.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/utils/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/utils/index.js",
|
|
9
9
|
"./readNwrite": "./src/utils/readNwrite.js",
|
|
10
|
+
"./verificationFileStore": "./src/utils/verificationFileStore.js",
|
|
10
11
|
"./network": "./src/utils/network.js",
|
|
11
|
-
"./
|
|
12
|
+
"./httpClients": "./src/utils/httpClients.js",
|
|
12
13
|
"./httpClientAuth": "./src/utils/httpClientAuth.js",
|
|
14
|
+
"./urlUtils": "./src/utils/urlUtils.js",
|
|
13
15
|
"./sourceFunctions": "./src/utils/sourceFunctions.js",
|
|
14
16
|
"./error2String": "./src/utils/error2String.js",
|
|
15
17
|
"./enzyme_utils": "./src/utils/enzyme_utils.js",
|
|
@@ -31,7 +33,7 @@
|
|
|
31
33
|
"directory": "packages/utils"
|
|
32
34
|
},
|
|
33
35
|
"dependencies": {
|
|
34
|
-
"@opencloning/store": "
|
|
36
|
+
"@opencloning/store": "2.0.0",
|
|
35
37
|
"@teselagen/bio-parsers": "^0.4.38",
|
|
36
38
|
"@teselagen/sequence-utils": "^0.3.42",
|
|
37
39
|
"@zip.js/zip.js": "^2.8.26",
|
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
const AUTH_INTERCEPTORS_ATTACHED = '__opencloningHttpClientAuthAttached';
|
|
2
2
|
|
|
3
3
|
let unauthorizedHandler = null;
|
|
4
|
+
let tokenGetter = null;
|
|
4
5
|
|
|
5
6
|
export function setHttpClientUnauthorizedHandler(fn) {
|
|
6
7
|
unauthorizedHandler = fn;
|
|
7
8
|
}
|
|
8
9
|
|
|
9
|
-
export function
|
|
10
|
+
export function setHttpClientTokenGetter(fn) {
|
|
11
|
+
tokenGetter = fn;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Attach the bearer token to requests, and call the unauthorized handler on 401s.
|
|
16
|
+
* The client must have a baseURL and allowAbsoluteUrls: false, so that every request
|
|
17
|
+
* goes to the backend and the token can never be sent to a third party.
|
|
18
|
+
*/
|
|
19
|
+
export function attachAuthInterceptors(client) {
|
|
10
20
|
if (client[AUTH_INTERCEPTORS_ATTACHED]) {
|
|
11
21
|
return client;
|
|
12
22
|
}
|
|
23
|
+
if (!client.defaults.baseURL || client.defaults.allowAbsoluteUrls !== false) {
|
|
24
|
+
throw new Error('Auth interceptors require a client with baseURL and allowAbsoluteUrls: false');
|
|
25
|
+
}
|
|
13
26
|
|
|
14
27
|
client[AUTH_INTERCEPTORS_ATTACHED] = true;
|
|
15
28
|
|
|
16
|
-
client.interceptors.request.use((config) => {
|
|
17
|
-
const token =
|
|
29
|
+
client.interceptors.request.use(async (config) => {
|
|
30
|
+
const token = await tokenGetter?.();
|
|
18
31
|
if (token) {
|
|
19
32
|
config.headers = config.headers ?? {};
|
|
20
33
|
config.headers.Authorization = `Bearer ${token}`;
|
|
@@ -1,34 +1,60 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
3
|
|
|
4
|
-
import { attachAuthInterceptors, setHttpClientUnauthorizedHandler } from './httpClientAuth';
|
|
4
|
+
import { attachAuthInterceptors, setHttpClientTokenGetter, setHttpClientUnauthorizedHandler } from './httpClientAuth';
|
|
5
|
+
|
|
6
|
+
const createClient = () => axios.create({ baseURL: 'http://localhost:8000/db', allowAbsoluteUrls: false });
|
|
5
7
|
|
|
6
8
|
describe('httpClientAuth', () => {
|
|
7
9
|
beforeEach(() => {
|
|
8
|
-
localStorage.clear();
|
|
9
10
|
setHttpClientUnauthorizedHandler(null);
|
|
11
|
+
setHttpClientTokenGetter(null);
|
|
10
12
|
});
|
|
11
13
|
|
|
12
14
|
afterEach(() => {
|
|
13
|
-
localStorage.clear();
|
|
14
15
|
setHttpClientUnauthorizedHandler(null);
|
|
16
|
+
setHttpClientTokenGetter(null);
|
|
15
17
|
});
|
|
16
18
|
|
|
17
|
-
it('
|
|
18
|
-
const client = attachAuthInterceptors(
|
|
19
|
+
it('leaves request headers unchanged when no token getter is set', async () => {
|
|
20
|
+
const client = attachAuthInterceptors(createClient());
|
|
19
21
|
const requestFulfilled = client.interceptors.request.handlers[0].fulfilled;
|
|
20
22
|
const config = { headers: {} };
|
|
21
23
|
|
|
22
|
-
|
|
24
|
+
const result = await requestFulfilled(config);
|
|
23
25
|
|
|
24
|
-
|
|
26
|
+
expect(result).toBe(config);
|
|
27
|
+
expect(config.headers.Authorization).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('adds the bearer token from a sync token getter', async () => {
|
|
31
|
+
const client = attachAuthInterceptors(createClient());
|
|
32
|
+
const requestFulfilled = client.interceptors.request.handlers[0].fulfilled;
|
|
33
|
+
const config = { headers: {} };
|
|
34
|
+
|
|
35
|
+
setHttpClientTokenGetter(() => '__TEST_TOKEN__');
|
|
36
|
+
|
|
37
|
+
const result = await requestFulfilled(config);
|
|
25
38
|
|
|
26
39
|
expect(result).toBe(config);
|
|
27
40
|
expect(config.headers.Authorization).toBe('Bearer __TEST_TOKEN__');
|
|
28
41
|
});
|
|
29
42
|
|
|
43
|
+
it('adds the bearer token from an async token getter', async () => {
|
|
44
|
+
const client = attachAuthInterceptors(createClient());
|
|
45
|
+
const requestFulfilled = client.interceptors.request.handlers[0].fulfilled;
|
|
46
|
+
const config = { headers: {} };
|
|
47
|
+
|
|
48
|
+
setHttpClientTokenGetter(async () => 'fresh-token');
|
|
49
|
+
|
|
50
|
+
const result = await requestFulfilled(config);
|
|
51
|
+
|
|
52
|
+
expect(result).toBe(config);
|
|
53
|
+
expect(config.headers.Authorization).toBe('Bearer fresh-token');
|
|
54
|
+
});
|
|
55
|
+
|
|
30
56
|
it('calls the shared unauthorized handler for 401 responses', async () => {
|
|
31
|
-
const client = attachAuthInterceptors(
|
|
57
|
+
const client = attachAuthInterceptors(createClient());
|
|
32
58
|
const responseRejected = client.interceptors.response.handlers[0].rejected;
|
|
33
59
|
const handler = vi.fn();
|
|
34
60
|
const error = { response: { status: 401 } };
|
|
@@ -40,8 +66,14 @@ describe('httpClientAuth', () => {
|
|
|
40
66
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
41
67
|
});
|
|
42
68
|
|
|
69
|
+
it('refuses clients that could send the token to other hosts', () => {
|
|
70
|
+
expect(() => attachAuthInterceptors(axios.create())).toThrow('allowAbsoluteUrls');
|
|
71
|
+
expect(() => attachAuthInterceptors(axios.create({ baseURL: 'http://localhost:8000' }))).toThrow('allowAbsoluteUrls');
|
|
72
|
+
expect(() => attachAuthInterceptors(axios.create({ allowAbsoluteUrls: false }))).toThrow('allowAbsoluteUrls');
|
|
73
|
+
});
|
|
74
|
+
|
|
43
75
|
it('does not attach duplicate interceptors to the same client', () => {
|
|
44
|
-
const client =
|
|
76
|
+
const client = createClient();
|
|
45
77
|
|
|
46
78
|
attachAuthInterceptors(client);
|
|
47
79
|
attachAuthInterceptors(client);
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import { attachAuthInterceptors } from './httpClientAuth';
|
|
3
|
+
import { urlMatchesPrefix } from './urlUtils';
|
|
4
|
+
import urlWhitelist from '../config/urlWhitelist';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Client for requests that do not go to the backend (external services, static files):
|
|
8
|
+
* no auth token, and only whitelisted urls (plus the current origin) are allowed.
|
|
9
|
+
* For the backend, use getBackendHttpClient.
|
|
10
|
+
*/
|
|
11
|
+
export function getExternalHttpClient() {
|
|
12
|
+
const baseURL = import.meta.env.BASE_URL || null;
|
|
13
|
+
const whitelist = [...urlWhitelist, window.location.origin];
|
|
14
|
+
if (baseURL) {
|
|
15
|
+
whitelist.push(baseURL);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const client = axios.create();
|
|
19
|
+
|
|
20
|
+
client.interceptors.request.use((config) => {
|
|
21
|
+
const url = new URL(config.url, config.baseURL || window.location.origin);
|
|
22
|
+
|
|
23
|
+
if (!whitelist.some((whitelistedUrl) => urlMatchesPrefix(url.href, whitelistedUrl))) {
|
|
24
|
+
return Promise.reject(new Error(`Request blocked: URL not in whitelist ${url.href}`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return config;
|
|
28
|
+
}, (error) => Promise.reject(error));
|
|
29
|
+
|
|
30
|
+
return client;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Client that can only reach the backend: urls are relative to backendUrl, and
|
|
35
|
+
* allowAbsoluteUrls: false makes axios append even absolute urls to backendUrl.
|
|
36
|
+
* Only these clients carry the auth token. Use getExternalHttpClient() for external urls.
|
|
37
|
+
*/
|
|
38
|
+
export function getBackendHttpClient(backendUrl, { authenticated = false } = {}) {
|
|
39
|
+
if (!backendUrl) {
|
|
40
|
+
throw new Error('backendUrl is required to create a backend http client');
|
|
41
|
+
}
|
|
42
|
+
// Handles both absolute and relative urls
|
|
43
|
+
const client = axios.create({ baseURL: new URL(backendUrl, window.location.origin).href, allowAbsoluteUrls: false });
|
|
44
|
+
if (authenticated) {
|
|
45
|
+
attachAuthInterceptors(client);
|
|
46
|
+
}
|
|
47
|
+
return client;
|
|
48
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { getExternalHttpClient, getBackendHttpClient } from './httpClients';
|
|
4
|
+
|
|
5
|
+
describe('getExternalHttpClient', () => {
|
|
6
|
+
it('creates a generic client without auth interceptors', () => {
|
|
7
|
+
const client = getExternalHttpClient();
|
|
8
|
+
|
|
9
|
+
expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(1);
|
|
10
|
+
expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
it('blocks urls that only look like whitelisted ones', async () => {
|
|
15
|
+
const client = getExternalHttpClient();
|
|
16
|
+
const requestFulfilled = client.interceptors.request.handlers[0].fulfilled;
|
|
17
|
+
|
|
18
|
+
const allowed = { url: 'https://assets.opencloning.org/OpenCloning-submission/index.json' };
|
|
19
|
+
expect(await requestFulfilled(allowed)).toBe(allowed);
|
|
20
|
+
|
|
21
|
+
for (const url of [
|
|
22
|
+
'https://assets.opencloning.org.evil.org/OpenCloning-submission/index.json',
|
|
23
|
+
'https://assets.opencloning.org@evil.org/OpenCloning-submission/index.json',
|
|
24
|
+
'https://assets-opencloning.org/OpenCloning-submission/index.json',
|
|
25
|
+
]) {
|
|
26
|
+
await expect(requestFulfilled({ url })).rejects.toThrow('Request blocked');
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('getBackendHttpClient', () => {
|
|
32
|
+
it('creates an authenticated backend client locked to the backend url', () => {
|
|
33
|
+
const client = getBackendHttpClient('http://localhost:8000/cloning', { authenticated: true });
|
|
34
|
+
|
|
35
|
+
expect(client.defaults.baseURL).toBe('http://localhost:8000/cloning');
|
|
36
|
+
expect(client.defaults.allowAbsoluteUrls).toBe(false);
|
|
37
|
+
expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(1);
|
|
38
|
+
expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('creates an unauthenticated backend client without auth interceptors', () => {
|
|
42
|
+
const client = getBackendHttpClient('http://localhost:8000/cloning');
|
|
43
|
+
|
|
44
|
+
expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(0);
|
|
45
|
+
expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps every request under the backend url, even absolute ones', () => {
|
|
49
|
+
const client = getBackendHttpClient('http://localhost:8000/cloning', { authenticated: true });
|
|
50
|
+
|
|
51
|
+
expect(client.getUri({ url: 'version' })).toBe('http://localhost:8000/cloning/version');
|
|
52
|
+
expect(client.getUri({ url: 'https://evil.org/x' })).toBe('http://localhost:8000/cloning/https://evil.org/x');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('throws when the backend url is not set', () => {
|
|
56
|
+
expect(() => getBackendHttpClient(undefined)).toThrow('backendUrl is required');
|
|
57
|
+
expect(() => getBackendHttpClient('', { authenticated: true })).toThrow('backendUrl is required');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('resolves relative backend urls against the current origin and keeps absolute ones', () => {
|
|
61
|
+
const { origin } = window.location;
|
|
62
|
+
|
|
63
|
+
const relative = getBackendHttpClient('/cloning');
|
|
64
|
+
expect(relative.defaults.baseURL).toBe(`${origin}/cloning`);
|
|
65
|
+
expect(relative.getUri({ url: 'version' })).toBe(`${origin}/cloning/version`);
|
|
66
|
+
expect(relative.getUri({ url: 'https://evil.org/x' })).toBe(`${origin}/cloning/https://evil.org/x`);
|
|
67
|
+
|
|
68
|
+
const absolute = getBackendHttpClient('https://api.example.com/cloning');
|
|
69
|
+
expect(absolute.defaults.baseURL).toBe('https://api.example.com/cloning');
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/utils/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
export * from './readNwrite.js';
|
|
5
5
|
export * from './network.js';
|
|
6
|
-
export * from './
|
|
6
|
+
export * from './httpClients.js';
|
|
7
7
|
export * from './httpClientAuth.js';
|
|
8
8
|
export * from './sourceFunctions.js';
|
|
9
9
|
export { default as error2String } from './error2String.js';
|
package/src/utils/readNwrite.js
CHANGED
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
} from '@zip.js/zip.js';
|
|
11
11
|
import { tidyUpSequenceData } from '@teselagen/sequence-utils';
|
|
12
12
|
import { isEqual } from 'lodash-es';
|
|
13
|
+
import {
|
|
14
|
+
getVerificationFileContent,
|
|
15
|
+
setVerificationFileContent,
|
|
16
|
+
} from './verificationFileStore';
|
|
13
17
|
|
|
14
18
|
configure({
|
|
15
19
|
useWebWorkers: false,
|
|
@@ -95,10 +99,10 @@ export const downloadStateAsZip = async (cloningState, zipFileName = 'cloning_st
|
|
|
95
99
|
const files2write = [
|
|
96
100
|
{ name: 'cloning_strategy.json', reader: new TextReader(prettyPrintJson(output)) },
|
|
97
101
|
...fileNames.map((fileName) => {
|
|
98
|
-
const base64Content =
|
|
102
|
+
const base64Content = getVerificationFileContent(fileName);
|
|
99
103
|
if (!base64Content) {
|
|
100
104
|
const nameOnly = fileName.replace(/verification-\d+-/, '');
|
|
101
|
-
throw new Error(`File ${nameOnly} not found in
|
|
105
|
+
throw new Error(`File ${nameOnly} not found in the verification file store`);
|
|
102
106
|
}
|
|
103
107
|
return { name: fileName, reader: new BlobReader(base64ToBlob(base64Content)) };
|
|
104
108
|
}),
|
|
@@ -233,14 +237,19 @@ export async function parseHistoryFile(file) {
|
|
|
233
237
|
return { cloningStrategy: newCloningStrategy, verificationFiles };
|
|
234
238
|
}
|
|
235
239
|
|
|
236
|
-
export const
|
|
240
|
+
export const loadFilesToVerificationStore = async (files, idShift = 0) => {
|
|
237
241
|
await Promise.all(files.map(async (file) => {
|
|
238
242
|
const fileContent = await file2base64(file);
|
|
239
243
|
const filename = file.name.replace(/verification-(\d+)-/, (match, num) => `verification-${parseInt(num, 10) + idShift}-`);
|
|
240
|
-
|
|
244
|
+
setVerificationFileContent(filename, fileContent);
|
|
241
245
|
}));
|
|
242
246
|
};
|
|
243
247
|
|
|
248
|
+
// Deprecated alias kept for backwards compatibility with the published
|
|
249
|
+
// @opencloning/utils package. The contents are now kept in an in-memory store
|
|
250
|
+
// instead of sessionStorage (see verificationFileStore.js).
|
|
251
|
+
export const loadFilesToSessionStorage = loadFilesToVerificationStore;
|
|
252
|
+
|
|
244
253
|
export function getVerificationFileName({ sequence_id, file_name }) {
|
|
245
254
|
return `verification-${sequence_id}-${file_name}`;
|
|
246
255
|
}
|
package/src/utils/thunks.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { cloneDeep } from 'lodash-es';
|
|
2
|
-
import { base64ToBlob, downloadStateAsJson, downloadStateAsZip,
|
|
2
|
+
import { base64ToBlob, downloadStateAsJson, downloadStateAsZip, loadFilesToVerificationStore } from './readNwrite';
|
|
3
3
|
import { cloningActions } from '@opencloning/store/cloning';
|
|
4
4
|
import { collectParentSequencesAndSources, getSubState, mergeStates } from './network';
|
|
5
5
|
import { getVerificationFileName } from './readNwrite';
|
|
6
|
+
import { getVerificationFileContent } from './verificationFileStore';
|
|
6
7
|
import { getUsedPrimerIds } from '@opencloning/store/cloning_utils';
|
|
7
8
|
|
|
8
9
|
const { setState: setCloningState } = cloningActions;
|
|
@@ -39,11 +40,11 @@ export const CopySequenceThunk = (sequenceId) => async (dispatch, getState) => {
|
|
|
39
40
|
files: filesToCopy,
|
|
40
41
|
});
|
|
41
42
|
const files = filesToCopy.map((f) => new File(
|
|
42
|
-
[base64ToBlob(
|
|
43
|
+
[base64ToBlob(getVerificationFileContent(getVerificationFileName(f)))],
|
|
43
44
|
getVerificationFileName(f),
|
|
44
45
|
{ type: 'application/octet-stream' },
|
|
45
46
|
));
|
|
46
47
|
const { mergedState, idShift } = mergeStates(newState, state.cloning, false);
|
|
47
48
|
dispatch(setCloningState(mergedState));
|
|
48
|
-
await
|
|
49
|
+
await loadFilesToVerificationStore(files, idShift);
|
|
49
50
|
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `url` starts with `prefix`, comparing the normalized urls (relative urls are
|
|
3
|
+
* resolved against the current origin). Normalizing makes a host-only prefix end in "/",
|
|
4
|
+
* so https://example.com matches https://example.com/path but not
|
|
5
|
+
* https://example.com.evil.org or https://example.com@evil.org.
|
|
6
|
+
*/
|
|
7
|
+
export function urlMatchesPrefix(url, prefix) {
|
|
8
|
+
return new URL(url, window.location.origin).href.startsWith(new URL(prefix, window.location.origin).href);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Encode a value to be inserted as a single segment of a URL path, e.g.
|
|
13
|
+
* `/sequences/${encodePathSegment(id)}`. Prevents client-side path traversal (CSPT):
|
|
14
|
+
* "/", "?" and "#" are encoded, and "." / ".." are rejected because
|
|
15
|
+
* encodeURIComponent leaves them unchanged and the browser would resolve them.
|
|
16
|
+
*/
|
|
17
|
+
export function encodePathSegment(value) {
|
|
18
|
+
const segment = String(value);
|
|
19
|
+
if (segment === '.' || segment === '..') {
|
|
20
|
+
throw new Error(`Invalid URL path segment: "${segment}"`);
|
|
21
|
+
}
|
|
22
|
+
return encodeURIComponent(segment);
|
|
23
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { encodePathSegment, urlMatchesPrefix } from './urlUtils';
|
|
4
|
+
|
|
5
|
+
describe('urlMatchesPrefix', () => {
|
|
6
|
+
it('matches urls under the prefix', () => {
|
|
7
|
+
expect(urlMatchesPrefix('https://example.com/a/b', 'https://example.com/a')).toBe(true);
|
|
8
|
+
expect(urlMatchesPrefix('https://example.com/a', 'https://example.com/a')).toBe(true);
|
|
9
|
+
expect(urlMatchesPrefix('https://example.com/a?x=1', 'https://example.com/a')).toBe(true);
|
|
10
|
+
expect(urlMatchesPrefix('https://example.com/a/b', 'https://example.com')).toBe(true);
|
|
11
|
+
expect(urlMatchesPrefix('https://example.com/esearch.fcgi?db=x', 'https://example.com/esearch.fcgi')).toBe(true);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('does not match lookalike hosts or paths', () => {
|
|
15
|
+
expect(urlMatchesPrefix('https://example.com.evil.org/a', 'https://example.com')).toBe(false);
|
|
16
|
+
expect(urlMatchesPrefix('https://example.com@evil.org/a', 'https://example.com')).toBe(false);
|
|
17
|
+
expect(urlMatchesPrefix('https://exampleXcom/a', 'https://example.com')).toBe(false);
|
|
18
|
+
expect(urlMatchesPrefix('https://other.com/https://example.com/a', 'https://example.com/a')).toBe(false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('resolves relative urls and prefixes against the current origin', () => {
|
|
22
|
+
const { origin } = window.location;
|
|
23
|
+
expect(urlMatchesPrefix('/backend/db/sequences', '/backend/db')).toBe(true);
|
|
24
|
+
expect(urlMatchesPrefix(`${origin}/backend/db/sequences`, '/backend/db')).toBe(true);
|
|
25
|
+
expect(urlMatchesPrefix('https://evil.org/backend/db/sequences', '/backend/db')).toBe(false);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('encodePathSegment', () => {
|
|
30
|
+
it('encodes characters that would change the path', () => {
|
|
31
|
+
expect(encodePathSegment('../workspaces/1/users/5')).toBe('..%2Fworkspaces%2F1%2Fusers%2F5');
|
|
32
|
+
expect(encodePathSegment('a?b#c')).toBe('a%3Fb%23c');
|
|
33
|
+
expect(encodePathSegment(12)).toBe('12');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('rejects dot segments', () => {
|
|
37
|
+
expect(() => encodePathSegment('.')).toThrow();
|
|
38
|
+
expect(() => encodePathSegment('..')).toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps the request path when resolved by the browser', () => {
|
|
42
|
+
for (const value of ['../workspaces/1', '..%2F..', 'a/../../b', '...']) {
|
|
43
|
+
const url = new URL(`/db/sequence_samples/${encodePathSegment(value)}`, 'https://example.com');
|
|
44
|
+
expect(url.pathname.startsWith('/db/sequence_samples/')).toBe(true);
|
|
45
|
+
// Only has 3 "/" > "/db/sequence_samples/"
|
|
46
|
+
expect(url.pathname.split('/')).toHaveLength(4);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory storage for the contents of verification (sequencing) files.
|
|
3
|
+
*
|
|
4
|
+
* The raw contents of uploaded sequencing files (e.g. AB1 files) are kept in
|
|
5
|
+
* memory as base64 strings, keyed by `verification-${sequence_id}-${file_name}`
|
|
6
|
+
* (see getVerificationFileName in readNwrite.js).
|
|
7
|
+
*
|
|
8
|
+
* These contents used to be persisted in Web Storage (localStorage in early
|
|
9
|
+
* versions, sessionStorage since the monorepo switch). Both have a quota of
|
|
10
|
+
* roughly 5 MB per origin, so submitting around 10+ AB1 files exceeded the
|
|
11
|
+
* quota, failed with a QuotaExceededError and made the files/alignments
|
|
12
|
+
* silently disappear:
|
|
13
|
+
* https://github.com/OpenCloning/OpenCloning/issues/82
|
|
14
|
+
*
|
|
15
|
+
* The contents are only needed while the tab is open (to display alignments,
|
|
16
|
+
* download files, export zips or realign when new files are submitted). After
|
|
17
|
+
* a page reload the cloning strategy has to be loaded from a file or the
|
|
18
|
+
* database again, and the verification files are loaded along with it, so an
|
|
19
|
+
* in-memory store with the same tab-lifetime semantics but no quota is
|
|
20
|
+
* sufficient.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const fileContents = new Map();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Store the base64 content of a verification file.
|
|
27
|
+
* @param {string} key verification file key (see getVerificationFileName)
|
|
28
|
+
* @param {string} base64Content base64-encoded file content
|
|
29
|
+
*/
|
|
30
|
+
export function setVerificationFileContent(key, base64Content) {
|
|
31
|
+
fileContents.set(key, base64Content);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get the base64 content of a verification file, or null if it is not stored.
|
|
36
|
+
* @param {string} key verification file key (see getVerificationFileName)
|
|
37
|
+
*/
|
|
38
|
+
export function getVerificationFileContent(key) {
|
|
39
|
+
return fileContents.has(key) ? fileContents.get(key) : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Remove the content of a single verification file.
|
|
44
|
+
* @param {string} key verification file key (see getVerificationFileName)
|
|
45
|
+
*/
|
|
46
|
+
export function removeVerificationFileContent(key) {
|
|
47
|
+
fileContents.delete(key);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Remove the contents of all verification files associated to a sequence.
|
|
52
|
+
* @param {number} sequenceId id of the sequence
|
|
53
|
+
* @param {string} [fileName] if given, only files whose key starts with
|
|
54
|
+
* `verification-${sequenceId}-${fileName}` are removed (matches the previous
|
|
55
|
+
* sessionStorage-based behaviour)
|
|
56
|
+
*/
|
|
57
|
+
export function removeVerificationFileContents(sequenceId, fileName = null) {
|
|
58
|
+
const prefix = `verification-${sequenceId}-`;
|
|
59
|
+
const query = fileName ? prefix + fileName : prefix;
|
|
60
|
+
for (const key of [...fileContents.keys()]) {
|
|
61
|
+
if (key.startsWith(query)) {
|
|
62
|
+
fileContents.delete(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Remove all stored verification file contents.
|
|
69
|
+
*/
|
|
70
|
+
export function clearVerificationFileContents() {
|
|
71
|
+
fileContents.clear();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Expose the store when running in Cypress, following the existing pattern of
|
|
75
|
+
// exposing window.store in apps/opencloning/src/index.jsx. This lets e2e tests
|
|
76
|
+
// assert on the stored file contents.
|
|
77
|
+
if (typeof window !== 'undefined' && window.Cypress) {
|
|
78
|
+
window.verificationFileStore = {
|
|
79
|
+
get: getVerificationFileContent,
|
|
80
|
+
set: setVerificationFileContent,
|
|
81
|
+
remove: removeVerificationFileContent,
|
|
82
|
+
removeBySequence: removeVerificationFileContents,
|
|
83
|
+
clear: clearVerificationFileContents,
|
|
84
|
+
size: () => fileContents.size,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
setVerificationFileContent,
|
|
3
|
+
getVerificationFileContent,
|
|
4
|
+
removeVerificationFileContent,
|
|
5
|
+
removeVerificationFileContents,
|
|
6
|
+
clearVerificationFileContents,
|
|
7
|
+
} from './verificationFileStore';
|
|
8
|
+
|
|
9
|
+
describe('verificationFileStore (in-memory verification file storage)', () => {
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
clearVerificationFileContents();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('stores and retrieves a file content', () => {
|
|
15
|
+
setVerificationFileContent('verification-1-file.ab1', 'aGVsbG8=');
|
|
16
|
+
expect(getVerificationFileContent('verification-1-file.ab1')).toBe('aGVsbG8=');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('returns null for a missing key', () => {
|
|
20
|
+
expect(getVerificationFileContent('verification-1-missing.ab1')).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('overwrites an existing key', () => {
|
|
24
|
+
setVerificationFileContent('verification-1-file.ab1', 'first');
|
|
25
|
+
setVerificationFileContent('verification-1-file.ab1', 'second');
|
|
26
|
+
expect(getVerificationFileContent('verification-1-file.ab1')).toBe('second');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('keeps different sequences with the same file name separate', () => {
|
|
30
|
+
setVerificationFileContent('verification-1-file.ab1', 'seq1');
|
|
31
|
+
setVerificationFileContent('verification-2-file.ab1', 'seq2');
|
|
32
|
+
expect(getVerificationFileContent('verification-1-file.ab1')).toBe('seq1');
|
|
33
|
+
expect(getVerificationFileContent('verification-2-file.ab1')).toBe('seq2');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('removes a single file content', () => {
|
|
37
|
+
setVerificationFileContent('verification-1-file.ab1', 'a');
|
|
38
|
+
setVerificationFileContent('verification-1-other.ab1', 'b');
|
|
39
|
+
removeVerificationFileContent('verification-1-file.ab1');
|
|
40
|
+
expect(getVerificationFileContent('verification-1-file.ab1')).toBeNull();
|
|
41
|
+
expect(getVerificationFileContent('verification-1-other.ab1')).toBe('b');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('removes all file contents for a sequence', () => {
|
|
45
|
+
setVerificationFileContent('verification-1-file.ab1', 'a');
|
|
46
|
+
setVerificationFileContent('verification-1-other.ab1', 'b');
|
|
47
|
+
// We use 10 because of the startswith deletion logic in removeVerificationFileContents
|
|
48
|
+
setVerificationFileContent('verification-10-file.ab1', 'c');
|
|
49
|
+
removeVerificationFileContents(1);
|
|
50
|
+
expect(getVerificationFileContent('verification-1-file.ab1')).toBeNull();
|
|
51
|
+
expect(getVerificationFileContent('verification-1-other.ab1')).toBeNull();
|
|
52
|
+
expect(getVerificationFileContent('verification-10-file.ab1')).toBe('c');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('removes a specific file within a sequence when fileName is given', () => {
|
|
56
|
+
setVerificationFileContent('verification-1-file.ab1', 'a');
|
|
57
|
+
setVerificationFileContent('verification-1-file2.ab1', 'b');
|
|
58
|
+
removeVerificationFileContents(1, 'file.ab1');
|
|
59
|
+
expect(getVerificationFileContent('verification-1-file.ab1')).toBeNull();
|
|
60
|
+
expect(getVerificationFileContent('verification-1-file2.ab1')).toBe('b');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('clears all contents', () => {
|
|
64
|
+
setVerificationFileContent('verification-1-file.ab1', 'a');
|
|
65
|
+
setVerificationFileContent('verification-2-file.ab1', 'b');
|
|
66
|
+
clearVerificationFileContents();
|
|
67
|
+
expect(getVerificationFileContent('verification-1-file.ab1')).toBeNull();
|
|
68
|
+
expect(getVerificationFileContent('verification-2-file.ab1')).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Regression test for https://github.com/OpenCloning/OpenCloning/issues/82
|
|
72
|
+
// Storing ~12 AB1 files of ~350 KB each (3.9 MiB total, ~5.2 MB in base64)
|
|
73
|
+
// exceeded the ~5 MB Web Storage quota. The in-memory store has no quota, so
|
|
74
|
+
// all files must be stored and retrievable.
|
|
75
|
+
it('stores many large files without any quota limit (issue #82)', () => {
|
|
76
|
+
const nFiles = 12;
|
|
77
|
+
const totalBytes = 3.9 * 1024 * 1024; // 3.9 MiB as reported in the issue
|
|
78
|
+
const perFile = Math.floor(totalBytes / nFiles);
|
|
79
|
+
const base64Length = Math.ceil((perFile * 4) / 3);
|
|
80
|
+
const content = 'A'.repeat(base64Length);
|
|
81
|
+
|
|
82
|
+
const keys = [];
|
|
83
|
+
for (let i = 1; i <= nFiles; i++) {
|
|
84
|
+
const key = `verification-17-KKF080_${70250800 + i}_70250801.ab1`;
|
|
85
|
+
keys.push(key);
|
|
86
|
+
setVerificationFileContent(key, content);
|
|
87
|
+
}
|
|
88
|
+
// Total stored in base64 is ~5.2 MB, well above the 5 MB Web Storage quota
|
|
89
|
+
expect(keys.reduce((sum, k) => sum + getVerificationFileContent(k).length, 0))
|
|
90
|
+
.toBeGreaterThan(5 * 1024 * 1024);
|
|
91
|
+
keys.forEach((key) => {
|
|
92
|
+
expect(getVerificationFileContent(key)).toBe(content);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// And a sequence-level cleanup still empties the store for that sequence
|
|
96
|
+
removeVerificationFileContents(17);
|
|
97
|
+
keys.forEach((key) => {
|
|
98
|
+
expect(getVerificationFileContent(key)).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
import axios from 'axios';
|
|
2
|
-
import { attachAuthInterceptors } from './httpClientAuth';
|
|
3
|
-
import urlWhitelist from '../config/urlWhitelist';
|
|
4
|
-
|
|
5
|
-
export default function getHttpClient(extraUrls = []) {
|
|
6
|
-
const baseURL = import.meta.env.BASE_URL || null;
|
|
7
|
-
const whitelist = [...urlWhitelist, window.location.origin, ...extraUrls];
|
|
8
|
-
if (baseURL) {
|
|
9
|
-
whitelist.push(baseURL);
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const client = axios.create();
|
|
13
|
-
|
|
14
|
-
client.interceptors.request.use((config) => {
|
|
15
|
-
const url = new URL(config.url, config.baseURL || window.location.origin);
|
|
16
|
-
|
|
17
|
-
if (!whitelist.some((whitelistedUrl) => url.href.match(new RegExp(`^${whitelistedUrl}`)))) {
|
|
18
|
-
return Promise.reject(new Error(`Request blocked: URL not in whitelist ${url.href}`));
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return config;
|
|
22
|
-
}, (error) => Promise.reject(error));
|
|
23
|
-
|
|
24
|
-
return client;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function getAuthenticatedHttpClient(extraUrls = []) {
|
|
28
|
-
const client = getHttpClient(extraUrls);
|
|
29
|
-
attachAuthInterceptors(client);
|
|
30
|
-
return client;
|
|
31
|
-
}
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import getHttpClient, { getAuthenticatedHttpClient } from './getHttpClient';
|
|
4
|
-
|
|
5
|
-
describe('getHttpClient', () => {
|
|
6
|
-
it('creates a generic client without auth interceptors', () => {
|
|
7
|
-
const client = getHttpClient();
|
|
8
|
-
|
|
9
|
-
expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(1);
|
|
10
|
-
expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(0);
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it('creates an authenticated client with auth interceptors', () => {
|
|
14
|
-
const client = getAuthenticatedHttpClient();
|
|
15
|
-
|
|
16
|
-
expect(client.interceptors.request.handlers.filter(Boolean)).toHaveLength(2);
|
|
17
|
-
expect(client.interceptors.response.handlers.filter(Boolean)).toHaveLength(1);
|
|
18
|
-
});
|
|
19
|
-
});
|