mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	Fix/remove settings cache (#2694)
In this PR we remove the general SettingService cache, as it will not work across multiple horizontal unleash instances, events are not published across. We also fix the CORS origin to: - Access-Control-Allow-Origin set to "*" if no Origin is configured - Access-Control-Allow-Origin set to "*" if any Origin is configured to "*" - - Access-Control-Allow-Origin set to array and have the "cors" middleware to return an exact match on the user provided Origin. Co-authored-by: Fredrik Oseberg <fredrik.no@gmail.com>
This commit is contained in:
		
							parent
							
								
									c3d37a2982
								
							
						
					
					
						commit
						883679d60f
					
				@ -6,6 +6,7 @@ import { useUiConfigApi } from 'hooks/api/actions/useUiConfigApi/useUiConfigApi'
 | 
				
			|||||||
import useToast from 'hooks/useToast';
 | 
					import useToast from 'hooks/useToast';
 | 
				
			||||||
import { formatUnknownError } from 'utils/formatUnknownError';
 | 
					import { formatUnknownError } from 'utils/formatUnknownError';
 | 
				
			||||||
import { useId } from 'hooks/useId';
 | 
					import { useId } from 'hooks/useId';
 | 
				
			||||||
 | 
					import { fontSize } from '@mui/system';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
interface ICorsFormProps {
 | 
					interface ICorsFormProps {
 | 
				
			||||||
    frontendApiOrigins: string[] | undefined;
 | 
					    frontendApiOrigins: string[] | undefined;
 | 
				
			||||||
@ -35,8 +36,23 @@ export const CorsForm = ({ frontendApiOrigins }: ICorsFormProps) => {
 | 
				
			|||||||
            <Box sx={{ display: 'grid', gap: 1 }}>
 | 
					            <Box sx={{ display: 'grid', gap: 1 }}>
 | 
				
			||||||
                <label htmlFor={inputFieldId}>
 | 
					                <label htmlFor={inputFieldId}>
 | 
				
			||||||
                    Which origins should be allowed to call the Frontend API?
 | 
					                    Which origins should be allowed to call the Frontend API?
 | 
				
			||||||
                    Add only one origin per line.
 | 
					                    Add only one origin per line. The CORS specification does
 | 
				
			||||||
 | 
					                    not support wildcard for subdomains, it needs to be a fully
 | 
				
			||||||
 | 
					                    qualified domain, including the protocol.
 | 
				
			||||||
 | 
					                    <br />
 | 
				
			||||||
 | 
					                    <br />
 | 
				
			||||||
 | 
					                    If you specify "*" it will be the chosen origin.
 | 
				
			||||||
 | 
					                    <br />
 | 
				
			||||||
 | 
					                    <br />
 | 
				
			||||||
 | 
					                    Example:
 | 
				
			||||||
                </label>
 | 
					                </label>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                <code style={{ fontSize: '0.7em' }}>
 | 
				
			||||||
 | 
					                    https://www.example.com
 | 
				
			||||||
 | 
					                    <br />
 | 
				
			||||||
 | 
					                    https://www.example2.com
 | 
				
			||||||
 | 
					                </code>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <TextField
 | 
					                <TextField
 | 
				
			||||||
                    id={inputFieldId}
 | 
					                    id={inputFieldId}
 | 
				
			||||||
                    aria-describedby={helpTextId}
 | 
					                    aria-describedby={helpTextId}
 | 
				
			||||||
 | 
				
			|||||||
@ -17,6 +17,15 @@ export const CorsHelpAlert = () => {
 | 
				
			|||||||
                An asterisk (<code>*</code>) may be used to allow API calls from
 | 
					                An asterisk (<code>*</code>) may be used to allow API calls from
 | 
				
			||||||
                any origin.
 | 
					                any origin.
 | 
				
			||||||
            </p>
 | 
					            </p>
 | 
				
			||||||
 | 
					            <br />
 | 
				
			||||||
 | 
					            <p>
 | 
				
			||||||
 | 
					                Be aware that changes here will take up to two minutes to be
 | 
				
			||||||
 | 
					                updated. In addition, there is a maxAge on the
 | 
				
			||||||
 | 
					                Access-Control-Allow-Origin header that will instruct browsers
 | 
				
			||||||
 | 
					                to cache this header for some time. The cache period is set to
 | 
				
			||||||
 | 
					                the maxium that the browser allows (2h for Chrome, 24h for
 | 
				
			||||||
 | 
					                Firefox).
 | 
				
			||||||
 | 
					            </p>
 | 
				
			||||||
        </Alert>
 | 
					        </Alert>
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
				
			|||||||
@ -1,86 +1,152 @@
 | 
				
			|||||||
import { allowRequestOrigin } from './cors-origin-middleware';
 | 
					import { resolveOrigin } from './cors-origin-middleware';
 | 
				
			||||||
import FakeSettingStore from '../../test/fixtures/fake-setting-store';
 | 
					import FakeSettingStore from '../../test/fixtures/fake-setting-store';
 | 
				
			||||||
import SettingService from '../services/setting-service';
 | 
					 | 
				
			||||||
import { createTestConfig } from '../../test/config/test-config';
 | 
					import { createTestConfig } from '../../test/config/test-config';
 | 
				
			||||||
import FakeEventStore from '../../test/fixtures/fake-event-store';
 | 
					import FakeEventStore from '../../test/fixtures/fake-event-store';
 | 
				
			||||||
import { randomId } from '../util/random-id';
 | 
					import { randomId } from '../util/random-id';
 | 
				
			||||||
import { frontendSettingsKey } from '../types/settings/frontend-settings';
 | 
					import FakeProjectStore from '../../test/fixtures/fake-project-store';
 | 
				
			||||||
 | 
					import { ProxyService, SettingService } from '../../lib/services';
 | 
				
			||||||
 | 
					import { ISettingStore } from '../../lib/types';
 | 
				
			||||||
 | 
					import { frontendSettingsKey } from '../../lib/types/settings/frontend-settings';
 | 
				
			||||||
 | 
					import { minutesToMilliseconds } from 'date-fns';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const createSettingService = (frontendApiOrigins: string[]): SettingService => {
 | 
					const createSettingService = (
 | 
				
			||||||
 | 
					    frontendApiOrigins: string[],
 | 
				
			||||||
 | 
					): { proxyService: ProxyService; settingStore: ISettingStore } => {
 | 
				
			||||||
    const config = createTestConfig({ frontendApiOrigins });
 | 
					    const config = createTestConfig({ frontendApiOrigins });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const stores = {
 | 
					    const stores = {
 | 
				
			||||||
        settingStore: new FakeSettingStore(),
 | 
					        settingStore: new FakeSettingStore(),
 | 
				
			||||||
        eventStore: new FakeEventStore(),
 | 
					        eventStore: new FakeEventStore(),
 | 
				
			||||||
 | 
					        projectStore: new FakeProjectStore(),
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return new SettingService(stores, config);
 | 
					    const services = {
 | 
				
			||||||
 | 
					        settingService: new SettingService(stores, config),
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return {
 | 
				
			||||||
 | 
					        //@ts-ignore
 | 
				
			||||||
 | 
					        proxyService: new ProxyService(config, stores, services),
 | 
				
			||||||
 | 
					        settingStore: stores.settingStore,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('allowRequestOrigin', () => {
 | 
					test('resolveOrigin', () => {
 | 
				
			||||||
    const dotCom = 'https://example.com';
 | 
					    const dotCom = 'https://example.com';
 | 
				
			||||||
    const dotOrg = 'https://example.org';
 | 
					    const dotOrg = 'https://example.org';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    expect(allowRequestOrigin('', [])).toEqual(false);
 | 
					    expect(resolveOrigin([])).toEqual('*');
 | 
				
			||||||
    expect(allowRequestOrigin(dotCom, [])).toEqual(false);
 | 
					    expect(resolveOrigin(['*'])).toEqual('*');
 | 
				
			||||||
    expect(allowRequestOrigin(dotCom, [dotOrg])).toEqual(false);
 | 
					    expect(resolveOrigin([dotOrg])).toEqual([dotOrg]);
 | 
				
			||||||
 | 
					    expect(resolveOrigin([dotCom, dotOrg])).toEqual([dotCom, dotOrg]);
 | 
				
			||||||
    expect(allowRequestOrigin(dotCom, [dotCom, dotOrg])).toEqual(true);
 | 
					    expect(resolveOrigin([dotOrg, '*'])).toEqual('*');
 | 
				
			||||||
    expect(allowRequestOrigin(dotCom, [dotOrg, dotCom])).toEqual(true);
 | 
					 | 
				
			||||||
    expect(allowRequestOrigin(dotCom, [dotCom, dotCom])).toEqual(true);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    expect(allowRequestOrigin(dotCom, ['*'])).toEqual(true);
 | 
					 | 
				
			||||||
    expect(allowRequestOrigin(dotCom, [dotOrg, '*'])).toEqual(true);
 | 
					 | 
				
			||||||
    expect(allowRequestOrigin(dotCom, [dotCom, dotOrg, '*'])).toEqual(true);
 | 
					 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('corsOriginMiddleware origin validation', async () => {
 | 
					test('corsOriginMiddleware origin validation', async () => {
 | 
				
			||||||
    const service = createSettingService([]);
 | 
					    const { proxyService } = createSettingService([]);
 | 
				
			||||||
    const userName = randomId();
 | 
					    const userName = randomId();
 | 
				
			||||||
    await expect(() =>
 | 
					    await expect(() =>
 | 
				
			||||||
        service.setFrontendSettings({ frontendApiOrigins: ['a'] }, userName),
 | 
					        proxyService.setFrontendSettings(
 | 
				
			||||||
 | 
					            { frontendApiOrigins: ['a'] },
 | 
				
			||||||
 | 
					            userName,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
    ).rejects.toThrow('Invalid origin: a');
 | 
					    ).rejects.toThrow('Invalid origin: a');
 | 
				
			||||||
 | 
					    proxyService.destroy();
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('corsOriginMiddleware without config', async () => {
 | 
					test('corsOriginMiddleware without config', async () => {
 | 
				
			||||||
    const service = createSettingService([]);
 | 
					    const { proxyService, settingStore } = createSettingService([]);
 | 
				
			||||||
    const userName = randomId();
 | 
					    const userName = randomId();
 | 
				
			||||||
    expect(await service.getFrontendSettings()).toEqual({
 | 
					    expect(await proxyService.getFrontendSettings(false)).toEqual({
 | 
				
			||||||
        frontendApiOrigins: [],
 | 
					        frontendApiOrigins: [],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    await service.setFrontendSettings({ frontendApiOrigins: [] }, userName);
 | 
					    await proxyService.setFrontendSettings(
 | 
				
			||||||
    expect(await service.getFrontendSettings()).toEqual({
 | 
					        { frontendApiOrigins: [] },
 | 
				
			||||||
 | 
					        userName,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    expect(await proxyService.getFrontendSettings(false)).toEqual({
 | 
				
			||||||
        frontendApiOrigins: [],
 | 
					        frontendApiOrigins: [],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    await service.setFrontendSettings({ frontendApiOrigins: ['*'] }, userName);
 | 
					    await proxyService.setFrontendSettings(
 | 
				
			||||||
    expect(await service.getFrontendSettings()).toEqual({
 | 
					        { frontendApiOrigins: ['*'] },
 | 
				
			||||||
 | 
					        userName,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    expect(await proxyService.getFrontendSettings(false)).toEqual({
 | 
				
			||||||
        frontendApiOrigins: ['*'],
 | 
					        frontendApiOrigins: ['*'],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    await service.delete(frontendSettingsKey, userName);
 | 
					    await settingStore.delete(frontendSettingsKey);
 | 
				
			||||||
    expect(await service.getFrontendSettings()).toEqual({
 | 
					    expect(await proxyService.getFrontendSettings(false)).toEqual({
 | 
				
			||||||
        frontendApiOrigins: [],
 | 
					        frontendApiOrigins: [],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					    proxyService.destroy();
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
test('corsOriginMiddleware with config', async () => {
 | 
					test('corsOriginMiddleware with config', async () => {
 | 
				
			||||||
    const service = createSettingService(['*']);
 | 
					    const { proxyService, settingStore } = createSettingService(['*']);
 | 
				
			||||||
    const userName = randomId();
 | 
					    const userName = randomId();
 | 
				
			||||||
    expect(await service.getFrontendSettings()).toEqual({
 | 
					    expect(await proxyService.getFrontendSettings(false)).toEqual({
 | 
				
			||||||
        frontendApiOrigins: ['*'],
 | 
					        frontendApiOrigins: ['*'],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    await service.setFrontendSettings({ frontendApiOrigins: [] }, userName);
 | 
					    await proxyService.setFrontendSettings(
 | 
				
			||||||
    expect(await service.getFrontendSettings()).toEqual({
 | 
					        { frontendApiOrigins: [] },
 | 
				
			||||||
 | 
					        userName,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    expect(await proxyService.getFrontendSettings(false)).toEqual({
 | 
				
			||||||
        frontendApiOrigins: [],
 | 
					        frontendApiOrigins: [],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    await service.setFrontendSettings(
 | 
					    await proxyService.setFrontendSettings(
 | 
				
			||||||
        { frontendApiOrigins: ['https://example.com', 'https://example.org'] },
 | 
					        { frontendApiOrigins: ['https://example.com', 'https://example.org'] },
 | 
				
			||||||
        userName,
 | 
					        userName,
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
    expect(await service.getFrontendSettings()).toEqual({
 | 
					    expect(await proxyService.getFrontendSettings(false)).toEqual({
 | 
				
			||||||
        frontendApiOrigins: ['https://example.com', 'https://example.org'],
 | 
					        frontendApiOrigins: ['https://example.com', 'https://example.org'],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
    await service.delete(frontendSettingsKey, userName);
 | 
					    await settingStore.delete(frontendSettingsKey);
 | 
				
			||||||
    expect(await service.getFrontendSettings()).toEqual({
 | 
					    expect(await proxyService.getFrontendSettings(false)).toEqual({
 | 
				
			||||||
        frontendApiOrigins: ['*'],
 | 
					        frontendApiOrigins: ['*'],
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					    proxyService.destroy();
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test('corsOriginMiddleware with caching enabled', async () => {
 | 
				
			||||||
 | 
					    jest.useFakeTimers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const { proxyService } = createSettingService([]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const userName = randomId();
 | 
				
			||||||
 | 
					    expect(await proxyService.getFrontendSettings()).toEqual({
 | 
				
			||||||
 | 
					        frontendApiOrigins: [],
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    //setting
 | 
				
			||||||
 | 
					    await proxyService.setFrontendSettings(
 | 
				
			||||||
 | 
					        { frontendApiOrigins: ['*'] },
 | 
				
			||||||
 | 
					        userName,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    //still get cached value
 | 
				
			||||||
 | 
					    expect(await proxyService.getFrontendSettings()).toEqual({
 | 
				
			||||||
 | 
					        frontendApiOrigins: [],
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    jest.advanceTimersByTime(minutesToMilliseconds(2));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    jest.useRealTimers();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    /*
 | 
				
			||||||
 | 
					    This is needed because it is not enough to fake time to test the
 | 
				
			||||||
 | 
					    updated cache, we also need to make sure that all promises are 
 | 
				
			||||||
 | 
					    executed and completed, in the right order. 
 | 
				
			||||||
 | 
					    */
 | 
				
			||||||
 | 
					    await new Promise<void>((resolve) =>
 | 
				
			||||||
 | 
					        process.nextTick(async () => {
 | 
				
			||||||
 | 
					            const settings = await proxyService.getFrontendSettings();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            expect(settings).toEqual({
 | 
				
			||||||
 | 
					                frontendApiOrigins: ['*'],
 | 
				
			||||||
 | 
					            });
 | 
				
			||||||
 | 
					            resolve();
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    proxyService.destroy();
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
				
			|||||||
@ -2,32 +2,32 @@ import { RequestHandler } from 'express';
 | 
				
			|||||||
import cors from 'cors';
 | 
					import cors from 'cors';
 | 
				
			||||||
import { IUnleashConfig, IUnleashServices } from '../types';
 | 
					import { IUnleashConfig, IUnleashServices } from '../types';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const allowRequestOrigin = (
 | 
					export const resolveOrigin = (allowedOrigins: string[]): string | string[] => {
 | 
				
			||||||
    requestOrigin: string,
 | 
					    if (allowedOrigins.length === 0) {
 | 
				
			||||||
    allowedOrigins: string[],
 | 
					        return '*';
 | 
				
			||||||
): boolean => {
 | 
					    }
 | 
				
			||||||
    return allowedOrigins.some((allowedOrigin) => {
 | 
					    if (allowedOrigins.some((origin: string) => origin === '*')) {
 | 
				
			||||||
        return allowedOrigin === requestOrigin || allowedOrigin === '*';
 | 
					        return '*';
 | 
				
			||||||
    });
 | 
					    } else {
 | 
				
			||||||
 | 
					        return allowedOrigins;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
// Check the request's Origin header against a list of allowed origins.
 | 
					// Check the request's Origin header against a list of allowed origins.
 | 
				
			||||||
// The list may include '*', which `cors` does not support natively.
 | 
					// The list may include '*', which `cors` does not support natively.
 | 
				
			||||||
export const corsOriginMiddleware = (
 | 
					export const corsOriginMiddleware = (
 | 
				
			||||||
    { settingService }: Pick<IUnleashServices, 'settingService'>,
 | 
					    { proxyService }: Pick<IUnleashServices, 'proxyService'>,
 | 
				
			||||||
    config: IUnleashConfig,
 | 
					    config: IUnleashConfig,
 | 
				
			||||||
): RequestHandler => {
 | 
					): RequestHandler => {
 | 
				
			||||||
    return cors(async (req, callback) => {
 | 
					    return cors(async (req, callback) => {
 | 
				
			||||||
        try {
 | 
					        try {
 | 
				
			||||||
            const { frontendApiOrigins = [] } =
 | 
					            const { frontendApiOrigins = [] } =
 | 
				
			||||||
                await settingService.getFrontendSettings();
 | 
					                await proxyService.getFrontendSettings();
 | 
				
			||||||
            callback(null, {
 | 
					            callback(null, {
 | 
				
			||||||
                origin: allowRequestOrigin(
 | 
					                origin: resolveOrigin(frontendApiOrigins),
 | 
				
			||||||
                    req.header('Origin'),
 | 
					 | 
				
			||||||
                    frontendApiOrigins,
 | 
					 | 
				
			||||||
                ),
 | 
					 | 
				
			||||||
                maxAge: config.accessControlMaxAge,
 | 
					                maxAge: config.accessControlMaxAge,
 | 
				
			||||||
                exposedHeaders: 'ETag',
 | 
					                exposedHeaders: 'ETag',
 | 
				
			||||||
 | 
					                credentials: true,
 | 
				
			||||||
            });
 | 
					            });
 | 
				
			||||||
        } catch (error) {
 | 
					        } catch (error) {
 | 
				
			||||||
            callback(error);
 | 
					            callback(error);
 | 
				
			||||||
 | 
				
			|||||||
@ -24,12 +24,15 @@ import { extractUsername } from '../../util/extract-user';
 | 
				
			|||||||
import NotFoundError from '../../error/notfound-error';
 | 
					import NotFoundError from '../../error/notfound-error';
 | 
				
			||||||
import { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
 | 
					import { SetUiConfigSchema } from '../../openapi/spec/set-ui-config-schema';
 | 
				
			||||||
import { createRequestSchema } from '../../openapi/util/create-request-schema';
 | 
					import { createRequestSchema } from '../../openapi/util/create-request-schema';
 | 
				
			||||||
 | 
					import { ProxyService } from 'lib/services';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ConfigController extends Controller {
 | 
					class ConfigController extends Controller {
 | 
				
			||||||
    private versionService: VersionService;
 | 
					    private versionService: VersionService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private settingService: SettingService;
 | 
					    private settingService: SettingService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private proxyService: ProxyService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private emailService: EmailService;
 | 
					    private emailService: EmailService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    private readonly openApiService: OpenApiService;
 | 
					    private readonly openApiService: OpenApiService;
 | 
				
			||||||
@ -41,12 +44,14 @@ class ConfigController extends Controller {
 | 
				
			|||||||
            settingService,
 | 
					            settingService,
 | 
				
			||||||
            emailService,
 | 
					            emailService,
 | 
				
			||||||
            openApiService,
 | 
					            openApiService,
 | 
				
			||||||
 | 
					            proxyService,
 | 
				
			||||||
        }: Pick<
 | 
					        }: Pick<
 | 
				
			||||||
            IUnleashServices,
 | 
					            IUnleashServices,
 | 
				
			||||||
            | 'versionService'
 | 
					            | 'versionService'
 | 
				
			||||||
            | 'settingService'
 | 
					            | 'settingService'
 | 
				
			||||||
            | 'emailService'
 | 
					            | 'emailService'
 | 
				
			||||||
            | 'openApiService'
 | 
					            | 'openApiService'
 | 
				
			||||||
 | 
					            | 'proxyService'
 | 
				
			||||||
        >,
 | 
					        >,
 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
        super(config);
 | 
					        super(config);
 | 
				
			||||||
@ -54,6 +59,7 @@ class ConfigController extends Controller {
 | 
				
			|||||||
        this.settingService = settingService;
 | 
					        this.settingService = settingService;
 | 
				
			||||||
        this.emailService = emailService;
 | 
					        this.emailService = emailService;
 | 
				
			||||||
        this.openApiService = openApiService;
 | 
					        this.openApiService = openApiService;
 | 
				
			||||||
 | 
					        this.proxyService = proxyService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        this.route({
 | 
					        this.route({
 | 
				
			||||||
            method: 'get',
 | 
					            method: 'get',
 | 
				
			||||||
@ -92,7 +98,7 @@ class ConfigController extends Controller {
 | 
				
			|||||||
        res: Response<UiConfigSchema>,
 | 
					        res: Response<UiConfigSchema>,
 | 
				
			||||||
    ): Promise<void> {
 | 
					    ): Promise<void> {
 | 
				
			||||||
        const [frontendSettings, simpleAuthSettings] = await Promise.all([
 | 
					        const [frontendSettings, simpleAuthSettings] = await Promise.all([
 | 
				
			||||||
            this.settingService.getFrontendSettings(),
 | 
					            this.proxyService.getFrontendSettings(false),
 | 
				
			||||||
            this.settingService.get<SimpleAuthSettings>(simpleAuthSettingsKey),
 | 
					            this.settingService.get<SimpleAuthSettings>(simpleAuthSettingsKey),
 | 
				
			||||||
        ]);
 | 
					        ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@ -133,7 +139,7 @@ class ConfigController extends Controller {
 | 
				
			|||||||
        res: Response<string>,
 | 
					        res: Response<string>,
 | 
				
			||||||
    ): Promise<void> {
 | 
					    ): Promise<void> {
 | 
				
			||||||
        if (req.body.frontendSettings) {
 | 
					        if (req.body.frontendSettings) {
 | 
				
			||||||
            await this.settingService.setFrontendSettings(
 | 
					            await this.proxyService.setFrontendSettings(
 | 
				
			||||||
                req.body.frontendSettings,
 | 
					                req.body.frontendSettings,
 | 
				
			||||||
                extractUsername(req),
 | 
					                extractUsername(req),
 | 
				
			||||||
            );
 | 
					            );
 | 
				
			||||||
 | 
				
			|||||||
@ -55,6 +55,7 @@ async function createApp(
 | 
				
			|||||||
        metricsMonitor.stopMonitoring();
 | 
					        metricsMonitor.stopMonitoring();
 | 
				
			||||||
        stores.clientInstanceStore.destroy();
 | 
					        stores.clientInstanceStore.destroy();
 | 
				
			||||||
        services.clientMetricsServiceV2.destroy();
 | 
					        services.clientMetricsServiceV2.destroy();
 | 
				
			||||||
 | 
					        services.proxyService.destroy();
 | 
				
			||||||
        await db.destroy();
 | 
					        await db.destroy();
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
				
			|||||||
@ -109,6 +109,7 @@ export const createServices = (
 | 
				
			|||||||
        featureToggleServiceV2,
 | 
					        featureToggleServiceV2,
 | 
				
			||||||
        clientMetricsServiceV2,
 | 
					        clientMetricsServiceV2,
 | 
				
			||||||
        segmentService,
 | 
					        segmentService,
 | 
				
			||||||
 | 
					        settingService,
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const edgeService = new EdgeService(stores, config);
 | 
					    const edgeService = new EdgeService(stores, config);
 | 
				
			||||||
 | 
				
			|||||||
@ -10,16 +10,29 @@ import {
 | 
				
			|||||||
    UnleashEvents,
 | 
					    UnleashEvents,
 | 
				
			||||||
} from 'unleash-client';
 | 
					} from 'unleash-client';
 | 
				
			||||||
import { ProxyRepository } from '../proxy';
 | 
					import { ProxyRepository } from '../proxy';
 | 
				
			||||||
import assert from 'assert';
 | 
					 | 
				
			||||||
import { ApiTokenType } from '../types/models/api-token';
 | 
					import { ApiTokenType } from '../types/models/api-token';
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					    FrontendSettings,
 | 
				
			||||||
 | 
					    frontendSettingsKey,
 | 
				
			||||||
 | 
					} from '../types/settings/frontend-settings';
 | 
				
			||||||
 | 
					import { validateOrigins } from '../util';
 | 
				
			||||||
 | 
					import { BadDataError } from '../error';
 | 
				
			||||||
 | 
					import assert from 'assert';
 | 
				
			||||||
 | 
					import { minutesToMilliseconds } from 'date-fns';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Config = Pick<IUnleashConfig, 'getLogger' | 'frontendApi'>;
 | 
					type Config = Pick<
 | 
				
			||||||
 | 
					    IUnleashConfig,
 | 
				
			||||||
 | 
					    'getLogger' | 'frontendApi' | 'frontendApiOrigins'
 | 
				
			||||||
 | 
					>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Stores = Pick<IUnleashStores, 'projectStore' | 'eventStore'>;
 | 
					type Stores = Pick<IUnleashStores, 'projectStore' | 'eventStore'>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
type Services = Pick<
 | 
					type Services = Pick<
 | 
				
			||||||
    IUnleashServices,
 | 
					    IUnleashServices,
 | 
				
			||||||
    'featureToggleServiceV2' | 'segmentService' | 'clientMetricsServiceV2'
 | 
					    | 'featureToggleServiceV2'
 | 
				
			||||||
 | 
					    | 'segmentService'
 | 
				
			||||||
 | 
					    | 'clientMetricsServiceV2'
 | 
				
			||||||
 | 
					    | 'settingService'
 | 
				
			||||||
>;
 | 
					>;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class ProxyService {
 | 
					export class ProxyService {
 | 
				
			||||||
@ -33,11 +46,20 @@ export class ProxyService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private readonly clients: Map<ApiUser['secret'], Unleash> = new Map();
 | 
					    private readonly clients: Map<ApiUser['secret'], Unleash> = new Map();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private cachedFrontendSettings?: FrontendSettings;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private timer: NodeJS.Timeout;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    constructor(config: Config, stores: Stores, services: Services) {
 | 
					    constructor(config: Config, stores: Stores, services: Services) {
 | 
				
			||||||
        this.config = config;
 | 
					        this.config = config;
 | 
				
			||||||
        this.logger = config.getLogger('services/proxy-service.ts');
 | 
					        this.logger = config.getLogger('services/proxy-service.ts');
 | 
				
			||||||
        this.stores = stores;
 | 
					        this.stores = stores;
 | 
				
			||||||
        this.services = services;
 | 
					        this.services = services;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        this.timer = setInterval(
 | 
				
			||||||
 | 
					            () => this.fetchFrontendSettings(),
 | 
				
			||||||
 | 
					            minutesToMilliseconds(2),
 | 
				
			||||||
 | 
					        ).unref();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async getProxyFeatures(
 | 
					    async getProxyFeatures(
 | 
				
			||||||
@ -138,4 +160,43 @@ export class ProxyService {
 | 
				
			|||||||
    private static assertExpectedTokenType({ type }: ApiUser) {
 | 
					    private static assertExpectedTokenType({ type }: ApiUser) {
 | 
				
			||||||
        assert(type === ApiTokenType.FRONTEND || type === ApiTokenType.ADMIN);
 | 
					        assert(type === ApiTokenType.FRONTEND || type === ApiTokenType.ADMIN);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async setFrontendSettings(
 | 
				
			||||||
 | 
					        value: FrontendSettings,
 | 
				
			||||||
 | 
					        createdBy: string,
 | 
				
			||||||
 | 
					    ): Promise<void> {
 | 
				
			||||||
 | 
					        const error = validateOrigins(value.frontendApiOrigins);
 | 
				
			||||||
 | 
					        if (error) {
 | 
				
			||||||
 | 
					            throw new BadDataError(error);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        await this.services.settingService.insert(
 | 
				
			||||||
 | 
					            frontendSettingsKey,
 | 
				
			||||||
 | 
					            value,
 | 
				
			||||||
 | 
					            createdBy,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    private async fetchFrontendSettings(): Promise<FrontendSettings> {
 | 
				
			||||||
 | 
					        this.cachedFrontendSettings = await this.services.settingService.get(
 | 
				
			||||||
 | 
					            frontendSettingsKey,
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					                frontendApiOrigins: this.config.frontendApiOrigins,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return this.cachedFrontendSettings;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    async getFrontendSettings(
 | 
				
			||||||
 | 
					        useCache: boolean = true,
 | 
				
			||||||
 | 
					    ): Promise<FrontendSettings> {
 | 
				
			||||||
 | 
					        if (useCache && this.cachedFrontendSettings) {
 | 
				
			||||||
 | 
					            return this.cachedFrontendSettings;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return this.fetchFrontendSettings();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    destroy(): void {
 | 
				
			||||||
 | 
					        clearInterval(this.timer);
 | 
				
			||||||
 | 
					        this.timer = null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
				
			|||||||
@ -8,12 +8,6 @@ import {
 | 
				
			|||||||
    SettingDeletedEvent,
 | 
					    SettingDeletedEvent,
 | 
				
			||||||
    SettingUpdatedEvent,
 | 
					    SettingUpdatedEvent,
 | 
				
			||||||
} from '../types/events';
 | 
					} from '../types/events';
 | 
				
			||||||
import { validateOrigins } from '../util/validateOrigin';
 | 
					 | 
				
			||||||
import {
 | 
					 | 
				
			||||||
    FrontendSettings,
 | 
					 | 
				
			||||||
    frontendSettingsKey,
 | 
					 | 
				
			||||||
} from '../types/settings/frontend-settings';
 | 
					 | 
				
			||||||
import BadDataError from '../error/bad-data-error';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default class SettingService {
 | 
					export default class SettingService {
 | 
				
			||||||
    private config: IUnleashConfig;
 | 
					    private config: IUnleashConfig;
 | 
				
			||||||
@ -24,10 +18,6 @@ export default class SettingService {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    private eventStore: IEventStore;
 | 
					    private eventStore: IEventStore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // SettingService.getFrontendSettings is called on every request to the
 | 
					 | 
				
			||||||
    // frontend API. Keep fetched settings in a cache for fewer DB queries.
 | 
					 | 
				
			||||||
    private cache = new Map<string, unknown>();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    constructor(
 | 
					    constructor(
 | 
				
			||||||
        {
 | 
					        {
 | 
				
			||||||
            settingStore,
 | 
					            settingStore,
 | 
				
			||||||
@ -42,14 +32,11 @@ export default class SettingService {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async get<T>(id: string, defaultValue?: T): Promise<T> {
 | 
					    async get<T>(id: string, defaultValue?: T): Promise<T> {
 | 
				
			||||||
        if (!this.cache.has(id)) {
 | 
					        const value = await this.settingStore.get(id);
 | 
				
			||||||
            this.cache.set(id, await this.settingStore.get(id));
 | 
					        return value || defaultValue;
 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        return (this.cache.get(id) as T) || defaultValue;
 | 
					 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async insert(id: string, value: object, createdBy: string): Promise<void> {
 | 
					    async insert(id: string, value: object, createdBy: string): Promise<void> {
 | 
				
			||||||
        this.cache.delete(id);
 | 
					 | 
				
			||||||
        const exists = await this.settingStore.exists(id);
 | 
					        const exists = await this.settingStore.exists(id);
 | 
				
			||||||
        if (exists) {
 | 
					        if (exists) {
 | 
				
			||||||
            await this.settingStore.updateRow(id, value);
 | 
					            await this.settingStore.updateRow(id, value);
 | 
				
			||||||
@ -71,7 +58,6 @@ export default class SettingService {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async delete(id: string, createdBy: string): Promise<void> {
 | 
					    async delete(id: string, createdBy: string): Promise<void> {
 | 
				
			||||||
        this.cache.delete(id);
 | 
					 | 
				
			||||||
        await this.settingStore.delete(id);
 | 
					        await this.settingStore.delete(id);
 | 
				
			||||||
        await this.eventStore.store(
 | 
					        await this.eventStore.store(
 | 
				
			||||||
            new SettingDeletedEvent({
 | 
					            new SettingDeletedEvent({
 | 
				
			||||||
@ -84,26 +70,6 @@ export default class SettingService {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    async deleteAll(): Promise<void> {
 | 
					    async deleteAll(): Promise<void> {
 | 
				
			||||||
        this.cache.clear();
 | 
					 | 
				
			||||||
        await this.settingStore.deleteAll();
 | 
					        await this.settingStore.deleteAll();
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					 | 
				
			||||||
    async setFrontendSettings(
 | 
					 | 
				
			||||||
        value: FrontendSettings,
 | 
					 | 
				
			||||||
        createdBy: string,
 | 
					 | 
				
			||||||
    ): Promise<void> {
 | 
					 | 
				
			||||||
        const error = validateOrigins(value.frontendApiOrigins);
 | 
					 | 
				
			||||||
        if (error) {
 | 
					 | 
				
			||||||
            throw new BadDataError(error);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        await this.insert(frontendSettingsKey, value, createdBy);
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    async getFrontendSettings(): Promise<FrontendSettings> {
 | 
					 | 
				
			||||||
        return this.get(frontendSettingsKey, {
 | 
					 | 
				
			||||||
            frontendApiOrigins: this.config.frontendApiOrigins,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					 | 
				
			||||||
module.exports = SettingService;
 | 
					 | 
				
			||||||
 | 
				
			|||||||
@ -44,7 +44,7 @@ test('gets ui config with disablePasswordAuth', async () => {
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
test('gets ui config with frontendSettings', async () => {
 | 
					test('gets ui config with frontendSettings', async () => {
 | 
				
			||||||
    const frontendApiOrigins = ['https://example.net'];
 | 
					    const frontendApiOrigins = ['https://example.net'];
 | 
				
			||||||
    await app.services.settingService.setFrontendSettings(
 | 
					    await app.services.proxyService.setFrontendSettings(
 | 
				
			||||||
        { frontendApiOrigins },
 | 
					        { frontendApiOrigins },
 | 
				
			||||||
        randomId(),
 | 
					        randomId(),
 | 
				
			||||||
    );
 | 
					    );
 | 
				
			||||||
 | 
				
			|||||||
@ -47,6 +47,7 @@ async function createApp(
 | 
				
			|||||||
        services.clientInstanceService.destroy();
 | 
					        services.clientInstanceService.destroy();
 | 
				
			||||||
        services.clientMetricsServiceV2.destroy();
 | 
					        services.clientMetricsServiceV2.destroy();
 | 
				
			||||||
        services.apiTokenService.destroy();
 | 
					        services.apiTokenService.destroy();
 | 
				
			||||||
 | 
					        services.proxyService.destroy();
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // TODO: use create from server-impl instead?
 | 
					    // TODO: use create from server-impl instead?
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
		Reference in New Issue
	
	Block a user