@controleonline/ui-common 1.2.103 → 1.2.104

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@controleonline/ui-common",
3
- "version": "1.2.103",
3
+ "version": "1.2.104",
4
4
  "author": "ControleOnline",
5
5
  "description": "ControleOnline Common Quasar UI Component",
6
6
  "license": "MIT",
@@ -38,7 +38,7 @@
38
38
  "last 1 version, not dead, ie >= 11"
39
39
  ],
40
40
  "dependencies": {
41
- "@controleonline/ui-default": "*",
41
+ "@controleonline/ui-default": "1.0.281",
42
42
  "md5": "^2.3.0"
43
43
  }
44
44
  }
@@ -294,6 +294,7 @@ export function useNativeDeviceConfigFetch({device, isLogged, currentCompany, se
294
294
  currentCompany &&
295
295
  Object.entries(currentCompany).length > 0
296
296
  ) {
297
+ let active = true;
297
298
  setDeviceConfigFetched(false);
298
299
  setDeviceRuntimeConfigSynced(false);
299
300
  deviceConfigsActions
@@ -303,6 +304,7 @@ export function useNativeDeviceConfigFetch({device, isLogged, currentCompany, se
303
304
  type: runtimeDeviceType,
304
305
  })
305
306
  .then(data => {
307
+ if (!active) return;
306
308
  if (Array.isArray(data) && data.length > 0) {
307
309
  const nextItem = {
308
310
  ...data[0],
@@ -316,8 +318,9 @@ export function useNativeDeviceConfigFetch({device, isLogged, currentCompany, se
316
318
  })
317
319
  .catch(() => {})
318
320
  .finally(() => {
319
- setDeviceConfigFetched(true);
321
+ if (active) setDeviceConfigFetched(true);
320
322
  });
323
+ return () => { active = false; };
321
324
  }
322
325
  }, [currentCompany?.id, isLogged, device?.id, runtimeDeviceType]);
323
326
  }
@@ -8,6 +8,7 @@ export function useWebDeviceConfigFetch({device, isLogged, deviceConfigPeopleIri
8
8
  return;
9
9
  }
10
10
 
11
+ let active = true;
11
12
  setDeviceConfigFetched(false);
12
13
  setDeviceRuntimeConfigSynced(false);
13
14
  deviceConfigsActions.setItem({});
@@ -19,6 +20,7 @@ export function useWebDeviceConfigFetch({device, isLogged, deviceConfigPeopleIri
19
20
  type: runtimeDeviceType,
20
21
  })
21
22
  .then(data => {
23
+ if (!active) return;
22
24
  const selectedConfig = selectRuntimeDeviceConfig({
23
25
  items: data,
24
26
  deviceId: device.id,
@@ -39,8 +41,9 @@ export function useWebDeviceConfigFetch({device, isLogged, deviceConfigPeopleIri
39
41
  })
40
42
  .catch(() => {})
41
43
  .finally(() => {
42
- setDeviceConfigFetched(true);
44
+ if (active) setDeviceConfigFetched(true);
43
45
  });
46
+ return () => { active = false; };
44
47
  }, [
45
48
  device?.id,
46
49
  deviceConfigPeopleIri,
@@ -250,38 +253,44 @@ export function useWebCompanies({device, isLogged, peopleActions}) {
250
253
  }, [isLogged, device?.id]);
251
254
  }
252
255
 
253
- export function useWebMenus({isLogged, currentCompany, actions, api, appType, normalizeRuntimeMenuResponse}) {
256
+ export function useWebMenus({isLogged, currentCompany, actions, api, appType, normalizeRuntimeMenuResponse, menuContextKey, setMenuLoad, includeToolbar = false}) {
254
257
  useEffect(() => {
255
258
  if (!isLogged || !currentCompany?.id) {
256
259
  actions.setMenus([]);
260
+ setMenuLoad?.({key: menuContextKey, status: 'idle'});
257
261
  return undefined;
258
262
  }
259
263
 
260
264
  let cancelled = false;
265
+ setMenuLoad?.({key: menuContextKey, status: 'loading'});
261
266
 
262
- api
263
- .fetch('menus-people', {
264
- params: {
265
- myCompany: currentCompany.id,
266
- appType,
267
- menuType: 'home',
268
- },
269
- })
270
- .then(result => {
271
- if (!cancelled) {
272
- actions.setMenus(normalizeRuntimeMenuResponse(result, {appType}));
273
- }
274
- })
275
- .catch(() => {
276
- if (!cancelled) {
277
- actions.setMenus(normalizeRuntimeMenuResponse(null, {appType}));
278
- }
279
- });
267
+ const fetchMenus = menuType => api.fetch('menus-people', {
268
+ params: {myCompany: currentCompany.id, appType, menuType},
269
+ });
270
+ const request = includeToolbar
271
+ ? Promise.all([fetchMenus('home'), fetchMenus('toolbar')])
272
+ : fetchMenus('home').then(result => [result]);
273
+ request.then(results => {
274
+ if (cancelled) return;
275
+ if (results.some(result => result?.response?.success === false)) {
276
+ actions.setMenus([]);
277
+ setMenuLoad?.({key: menuContextKey, status: 'error'});
278
+ return;
279
+ }
280
+ actions.setMenus(results.flatMap(result => normalizeRuntimeMenuResponse(result, {
281
+ appType, ...(includeToolbar ? {allowFallback: false} : {}),
282
+ }) || []));
283
+ setMenuLoad?.({key: menuContextKey, status: 'ready'});
284
+ }).catch(() => {
285
+ if (cancelled) return;
286
+ actions.setMenus(includeToolbar ? [] : normalizeRuntimeMenuResponse(null, {appType}));
287
+ setMenuLoad?.({key: menuContextKey, status: 'error'});
288
+ });
280
289
 
281
290
  return () => {
282
291
  cancelled = true;
283
292
  };
284
- }, [actions, appType, currentCompany?.id, isLogged]);
293
+ }, [actions, appType, currentCompany?.id, isLogged, menuContextKey, setMenuLoad, includeToolbar]);
285
294
  }
286
295
 
287
296
  export function useWebThemeFetch({api, parseThemeCss, setBaseThemeColors, actions, currentCompany, mainCompany, device}) {
@@ -3,6 +3,7 @@ import {useNavigationState} from '@react-navigation/native';
3
3
  import {useStore} from '@store';
4
4
  import {api} from '@controleonline/ui-common/src/api';
5
5
  import {app_type} from '@appType';
6
+ import {useTheme} from './DefaultProvider';
6
7
  import BottomNavigationBar from '@controleonline/ui-common/src/react/components/BottomNavigationBar';
7
8
  import {
8
9
  getBottomNavigationPreset,
@@ -33,6 +34,8 @@ const RuntimeBottomNavigationBar = ({
33
34
  testID = 'bottom-navigation',
34
35
  useModernWebChromeProps = false,
35
36
  }) => {
37
+ const {runtimeMenus: providerMenus} = useTheme?.() || {};
38
+ const managedMenus = providerMenus?.managed === true;
36
39
  const state = useNavigationState(current => current);
37
40
  const activeRouteName =
38
41
  activeRouteNameProp || state?.routes?.[state.index]?.name || 'HomePage';
@@ -48,10 +51,15 @@ const RuntimeBottomNavigationBar = ({
48
51
  const routeAliases = routeAliasesProp || preset?.routeAliases || {};
49
52
  const appType = normalizeAppType(app_type);
50
53
  const menuScope = JSON.stringify([appType, currentCompany?.id, menuType, presetKey]);
51
- const runtimeMenus = menuState.scope === menuScope ? menuState.menus : [];
54
+ const runtimeMenus = managedMenus
55
+ ? (providerMenus.ready ? resolveToolbarRuntimeMenus({
56
+ apiMenus: providerMenus.modules, preset, presetKey, menuType, translate: global.t?.t,
57
+ }) : [])
58
+ : menuState.scope === menuScope ? menuState.menus : [];
52
59
 
53
60
  // Always consult menus-people for role-aware toolbar; preset is fallback only.
54
61
  useEffect(() => {
62
+ if (managedMenus) return undefined;
55
63
  const setRuntimeMenus = menus => setMenuState({scope: menuScope, menus});
56
64
  if (!currentCompany?.id || !menuType) {
57
65
  setRuntimeMenus(
@@ -109,7 +117,7 @@ const RuntimeBottomNavigationBar = ({
109
117
  return () => {
110
118
  cancelled = true;
111
119
  };
112
- }, [appType, currentCompany?.id, menuType, preset, presetKey, menuScope]);
120
+ }, [appType, currentCompany?.id, menuType, preset, presetKey, menuScope, managedMenus]);
113
121
 
114
122
  const colors = useMemo(() => {
115
123
  if (colorsOverride) {
@@ -1,5 +1,5 @@
1
1
  import {readStoredSessionToken} from '../../utils/storedSessionToken';
2
- import {isTrustedApiUrl, filterApiHeaders} from '../../utils/trustedApiUrl';
2
+ import {isTrustedApiUrl, filterApiHeaders, resolveApiFileProtocol} from '../../utils/trustedApiUrl';
3
3
  import React, {useEffect, useMemo, useState} from 'react';
4
4
  import {Image, Platform, StyleSheet, Text, View} from 'react-native';
5
5
  import {env as APP_ENV} from '@env';
@@ -55,99 +55,90 @@ const probeGravatar = async url => {
55
55
  * - Gravatar (d=404) on web: probe first; never set <img src> on expected 404.
56
56
  * - Other URLs / native: pass through (native Image onError handles 404).
57
57
  */
58
- const useDisplayUri = uri => {
59
- const [displayUri, setDisplayUri] = useState('');
60
- const [resolved, setResolved] = useState(false);
58
+ const useDisplayUri = (uri, imageAuthContext) => {
59
+ const [result, setResult] = useState(null);
61
60
 
62
61
  useEffect(() => {
63
62
  let cancelled = false;
64
63
  let objectUrl = '';
65
- const next = normalizeUrl(uri);
66
-
67
- setResolved(false);
68
- setDisplayUri('');
64
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
65
+ const next = Platform.OS === 'web'
66
+ ? resolveApiFileProtocol(normalizeUrl(uri), APP_ENV?.API_ENTRYPOINT)
67
+ : normalizeUrl(uri);
68
+ const finish = displayUri => {
69
+ if (!cancelled) setResult({uri, imageAuthContext, displayUri, resolved: true});
70
+ };
69
71
 
70
72
  const run = async () => {
71
- if (!next) {
72
- if (!cancelled) {
73
- setDisplayUri('');
74
- setResolved(true);
75
- }
76
- return;
77
- }
78
-
79
- const token = readStoredSessionToken();
73
+ if (!next) { finish(''); return; }
80
74
 
81
- // Authenticated backend download on web
75
+ // An explicitly unresolved store session must not reuse an older stored token.
76
+ const token = imageAuthContext === undefined
77
+ ? readStoredSessionToken() : normalizeUrl(imageAuthContext);
82
78
  if (isBackendDownloadUrl(next) && isTrustedApiUrl(next, APP_ENV?.API_ENTRYPOINT)
83
- && Platform.OS === 'web' && token) {
84
- try {
85
- const headers = {
86
- Accept: '*/*',
87
- 'API-TOKEN': token,
88
- };
89
- const host =
90
- normalizeUrl(APP_ENV?.DOMAIN) ||
91
- (typeof location !== 'undefined' ? location.host : '');
92
- if (host) {
93
- headers['App-Domain'] = host;
79
+ && Platform.OS === 'web') {
80
+ // Never expose a private download directly to an unauthenticated <Image>.
81
+ if (!token) { finish(''); return; }
82
+ const headers = {Accept: '*/*', 'API-TOKEN': token};
83
+ const host = normalizeUrl(APP_ENV?.DOMAIN) ||
84
+ (globalThis.location?.host || '');
85
+ if (host) headers['App-Domain'] = host;
86
+
87
+ for (let attempt = 0; attempt < 2 && !cancelled; attempt += 1) {
88
+ let response;
89
+ try {
90
+ response = await fetch(next, {
91
+ method: 'GET', headers, redirect: 'error', signal: controller?.signal,
92
+ });
93
+ } catch (error) {
94
+ if (cancelled || error?.name === 'AbortError') return;
95
+ if (attempt === 0) continue;
96
+ finish(''); return;
94
97
  }
95
-
96
- const response = await fetch(next, {method: 'GET', headers, redirect: 'error'});
98
+ if (cancelled) return;
97
99
  if (!response.ok) {
98
- if (!cancelled) {
99
- setDisplayUri('');
100
- setResolved(true);
101
- }
102
- return;
100
+ // One recovery attempt for transient server failures, never auth failures.
101
+ if (attempt === 0 && response.status >= 500 && response.status < 600) continue;
102
+ finish(''); return;
103
103
  }
104
-
105
- const blob = await response.blob();
106
- objectUrl = URL.createObjectURL(blob);
107
- if (!cancelled) {
108
- setDisplayUri(objectUrl);
109
- setResolved(true);
110
- }
111
- } catch {
112
- if (!cancelled) {
113
- setDisplayUri('');
114
- setResolved(true);
104
+ try {
105
+ const blob = await response.blob();
106
+ if (cancelled) return;
107
+ objectUrl = URL.createObjectURL(blob);
108
+ finish(objectUrl);
109
+ } catch {
110
+ finish('');
115
111
  }
112
+ return;
116
113
  }
117
114
  return;
118
115
  }
119
116
 
120
- // Gravatar d=404: probe on web so <img> never receives a known-missing URL
121
117
  if (isGravatarUrl(next) && Platform.OS === 'web') {
122
118
  const ok = await probeGravatar(next);
123
- if (!cancelled) {
124
- setDisplayUri(ok ? next : '');
125
- setResolved(true);
126
- }
119
+ finish(ok ? next : '');
127
120
  return;
128
121
  }
129
-
130
- // Default: use as-is (native, or non-special web URLs)
131
- if (!cancelled) {
132
- setDisplayUri(next);
133
- setResolved(true);
134
- }
122
+ finish(next);
135
123
  };
136
-
137
124
  run();
138
125
 
139
126
  return () => {
140
127
  cancelled = true;
128
+ controller?.abort();
141
129
  if (objectUrl) URL.revokeObjectURL(objectUrl);
142
130
  };
143
- }, [uri]);
131
+ }, [uri, imageAuthContext]);
144
132
 
145
- return {displayUri, resolved};
133
+ // Do not show a previous session's blob while the next effect is starting.
134
+ return result && result.uri === uri && result.imageAuthContext === imageAuthContext
135
+ ? result : {displayUri: '', resolved: false};
146
136
  };
147
137
 
148
138
  const UserAvatar = ({
149
139
  imageUrl,
150
140
  imageHeaders,
141
+ imageAuthContext,
151
142
  email,
152
143
  name,
153
144
  size = 40,
@@ -170,10 +161,10 @@ const UserAvatar = ({
170
161
 
171
162
  useEffect(() => {
172
163
  setSourceIndex(0);
173
- }, [sources]);
164
+ }, [sources, imageAuthContext]);
174
165
 
175
166
  const currentSource = sources[sourceIndex];
176
- const {displayUri, resolved} = useDisplayUri(currentSource);
167
+ const {displayUri, resolved} = useDisplayUri(currentSource, imageAuthContext);
177
168
 
178
169
  // After probe resolves empty for gravatar/backend → try next source or initials
179
170
  useEffect(() => {
@@ -0,0 +1,102 @@
1
+ import React from 'react';
2
+ import renderer, {act} from 'react-test-renderer';
3
+ import UserAvatar from '@controleonline/ui-common/src/react/components/UserAvatar';
4
+ jest.mock('@env', () => ({env: {API_ENTRYPOINT: 'https://api.synthetic.test', DOMAIN: 'synthetic.test'}}));
5
+ jest.mock('@store', () => ({useStore: jest.fn()}));
6
+ jest.mock('react-native', () => ({Platform: {OS: 'web'}, Image: 'Image', Text: 'Text', View: 'View', StyleSheet: {create: value => value}}));
7
+ const {useStore} = require('@store');
8
+ const imageUrl = 'https://api.synthetic.test/files/42/download';
9
+ const ok = () => ({ok: true, status: 200, blob: async () => ({})});
10
+ let tree;
11
+ let original;
12
+ const render = async props => {await act(async () => {tree = renderer.create(<UserAvatar imageUrl={imageUrl} useGravatar={false} {...props}/>);});};
13
+ beforeEach(() => {
14
+ global.IS_REACT_ACT_ENVIRONMENT = true;
15
+ original = {fetch: global.fetch, storage: global.localStorage, create: URL.createObjectURL, revoke: URL.revokeObjectURL};
16
+ global.localStorage = {getItem: () => null};
17
+ global.fetch = jest.fn().mockResolvedValue(ok());
18
+ URL.createObjectURL = jest.fn(() => 'blob:synthetic');
19
+ URL.revokeObjectURL = jest.fn();
20
+ });
21
+ afterEach(() => {
22
+ if (tree) act(() => tree.unmount());
23
+ tree = undefined;
24
+ global.fetch = original.fetch; global.localStorage = original.storage;
25
+ URL.createObjectURL = original.create; URL.revokeObjectURL = original.revoke;
26
+ });
27
+ it('renders initials before an image and authentication context exist', async () => {
28
+ await render({imageUrl: undefined, imageAuthContext: undefined, name: 'Leandro'});
29
+ expect(tree.root.findAllByType('Image')).toHaveLength(0);
30
+ expect(tree.root.findByType('Text').props.children).toBe('L');
31
+ expect(fetch).not.toHaveBeenCalled();
32
+ });
33
+ it('waits for session then fetches the same private URL', async () => {
34
+ await render({imageAuthContext: ''});
35
+ expect(tree.root.findAllByType('Image')).toHaveLength(0);
36
+ expect(fetch).not.toHaveBeenCalled();
37
+ await act(async () => {tree.update(<UserAvatar imageUrl={imageUrl} useGravatar={false} imageAuthContext="session-ready"/>);});
38
+ expect(fetch).toHaveBeenCalledTimes(1);
39
+ expect(fetch.mock.calls[0][1].headers['API-TOKEN']).toBe('session-ready');
40
+ });
41
+ it('uses the configured protocol for private images from the same API authority', async () => {
42
+ await render({imageUrl: imageUrl.replace('https:', 'http:'), imageAuthContext: 'ready'});
43
+ expect(fetch).toHaveBeenCalledTimes(1);
44
+ expect(fetch.mock.calls[0][0]).toBe(imageUrl);
45
+ expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
46
+ });
47
+ it.each([401, 403, 404])('does not retry status %s', async status => {
48
+ fetch.mockResolvedValue({ok: false, status});
49
+ await render({imageAuthContext: 'ready'});
50
+ expect(fetch).toHaveBeenCalledTimes(1);
51
+ expect(tree.root.findAllByType('Image')).toHaveLength(0);
52
+ });
53
+ it.each(['network', 503])('recovers once from %s', async failure => {
54
+ if (failure === 'network') fetch.mockRejectedValueOnce(new TypeError('network'));
55
+ else fetch.mockResolvedValueOnce({ok: false, status: failure});
56
+ await render({imageAuthContext: 'ready'});
57
+ expect(fetch).toHaveBeenCalledTimes(2);
58
+ expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
59
+ });
60
+ it('bounds repeated failures to two attempts', async () => {
61
+ fetch.mockRejectedValue(new TypeError('network'));
62
+ await render({imageAuthContext: 'ready'});
63
+ expect(fetch).toHaveBeenCalledTimes(2);
64
+ });
65
+ it('resets Image.onError after session changes', async () => {
66
+ await render({imageAuthContext: 'first'});
67
+ await act(async () => {tree.root.findByType('Image').props.onError();});
68
+ expect(tree.root.findAllByType('Image')).toHaveLength(0);
69
+ await act(async () => {tree.update(<UserAvatar imageUrl={imageUrl} useGravatar={false} imageAuthContext="second"/>);});
70
+ expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
71
+ expect(fetch.mock.calls.at(-1)[1].headers['API-TOKEN']).toBe('second');
72
+ });
73
+ it('never fetches credentials for an untrusted origin', async () => {
74
+ await render({imageUrl: 'https://external.synthetic.test/files/42/download', imageAuthContext: 'private'});
75
+ expect(fetch).not.toHaveBeenCalled();
76
+ });
77
+ it('aborts unmounted request and avoids a late blob URL', async () => {
78
+ let resolveBlob;
79
+ fetch.mockResolvedValue({ok: true, blob: () => new Promise(resolve => {resolveBlob = resolve;})});
80
+ await render({imageAuthContext: 'ready'});
81
+ const signal = fetch.mock.calls[0][1].signal;
82
+ await act(async () => {tree.unmount(); tree = undefined;});
83
+ await act(async () => {resolveBlob({});});
84
+ expect(signal.aborted).toBe(true);
85
+ expect(URL.createObjectURL).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it('revokes successful blobs when unmounted', async () => {
89
+ await render({imageAuthContext: 'ready'});
90
+ expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
91
+ await act(async () => {tree.unmount(); tree = undefined;});
92
+ expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:synthetic');
93
+ });
94
+ it('ignores the earlier session response after a context change', async () => {
95
+ let resolveOld;
96
+ fetch.mockImplementationOnce(() => new Promise(resolve => {resolveOld = resolve;}));
97
+ await render({imageAuthContext: 'first'});
98
+ await act(async () => {tree.update(<UserAvatar imageUrl={imageUrl} useGravatar={false} imageAuthContext="second"/>);});
99
+ await act(async () => {resolveOld(ok());});
100
+ expect(URL.createObjectURL).toHaveBeenCalledTimes(1);
101
+ expect(tree.root.findByType('Image').props.source.uri).toBe('blob:synthetic');
102
+ });
@@ -0,0 +1,18 @@
1
+ import React from 'react';
2
+ import renderer, {act} from 'react-test-renderer';
3
+ import {useNativeDeviceConfigFetch} from '@controleonline/ui-common/src/react/components/DefaultProvider.native.lifecycle';
4
+ import {useWebDeviceConfigFetch} from '@controleonline/ui-common/src/react/components/DefaultProvider.web.configuration';
5
+ it.each([['web',useWebDeviceConfigFetch],['native',useNativeDeviceConfigFetch]])('ignores late device configuration after a company switch on %s',async(_platform,useConfig)=>{
6
+ global.IS_REACT_ACT_ENVIRONMENT=true;
7
+ let resolveOld;const old=new Promise(resolve=>{resolveOld=resolve;});
8
+ const actions={setItem:jest.fn(),getItems:jest.fn().mockReturnValueOnce(old).mockResolvedValueOnce([{id:20}])};
9
+ const done=jest.fn(), synced=jest.fn(), select=({items})=>items[0],parse=x=>x||{};
10
+ function Probe({company}){useConfig({currentCompany:{id:company},device:{id:'web-10'},isLogged:true,deviceConfigPeopleIri:`/people/${company}`,setDeviceConfigFetched:done,setDeviceRuntimeConfigSynced:synced,deviceConfigsActions:actions,runtimeDeviceType:'PDV',selectRuntimeDeviceConfig:select,parseConfigsObject:parse});return null;}
11
+ let tree;await act(async()=>{tree=renderer.create(<Probe company={3}/>);});
12
+ await act(async()=>tree.update(<Probe company={2}/>));
13
+ expect(actions.setItem).toHaveBeenLastCalledWith({id:20,configs:{}});
14
+ actions.setItem.mockClear();done.mockClear();
15
+ await act(async()=>resolveOld([{id:10}]));
16
+ expect(actions.setItem).not.toHaveBeenCalled();expect(done).not.toHaveBeenCalled();
17
+ await act(async()=>tree.unmount());
18
+ });
@@ -16,4 +16,14 @@ const filterApiHeaders = (uri, headers = {}, apiEntryPoint) => {
16
16
  !/^(api[-_]?(token|key)|authorization|proxy-authorization|cookie|app-domain|device)$/i.test(key)));
17
17
  };
18
18
 
19
- module.exports = {isTrustedApiUrl, filterApiHeaders};
19
+ // Normalize only file downloads on the configured API authority; trust checks still
20
+ // validate the final origin/path before attaching session headers.
21
+ const resolveApiFileProtocol = (uri, apiEntryPoint) => {
22
+ const image = String(uri || '');
23
+ const imageOrigin = image.match(/^https?:\/\/([^/]+)/i);
24
+ const apiOrigin = String(apiEntryPoint || '').match(/^https?:\/\/([^/]+)/i);
25
+ if (!imageOrigin || !apiOrigin || imageOrigin[1] !== apiOrigin[1]
26
+ || !/\/files\/[^/?#]+\/download(?:[?#]|$)/i.test(image)) return image;
27
+ return apiOrigin[0] + image.slice(imageOrigin[0].length);
28
+ };
29
+ module.exports = {isTrustedApiUrl, filterApiHeaders, resolveApiFileProtocol};