0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

refactor(core)!: separate admin console server

This commit is contained in:
Gao Sun 2023-01-30 22:22:19 +08:00
parent 4a84162722
commit 639b5c836a
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
9 changed files with 122 additions and 209 deletions

View file

@ -1,18 +1,18 @@
import fs from 'fs/promises';
import http2 from 'http2';
import { deduplicate } from '@silverhand/essentials';
import { toTitle } from '@silverhand/essentials';
import chalk from 'chalk';
import type Koa from 'koa';
import { EnvSet } from '#src/env-set/index.js';
import { defaultTenant, tenantPool } from '#src/tenants/index.js';
const logListening = () => {
const { localhostUrl, endpoint } = EnvSet.values;
const logListening = (type: 'core' | 'admin' = 'core') => {
const urlSet = type === 'core' ? EnvSet.values.urlSet : EnvSet.values.adminUrlSet;
for (const url of deduplicate([localhostUrl, endpoint.toString()])) {
console.log(chalk.bold(chalk.green(`App is running at ${url}`)));
for (const url of urlSet.deduplicated()) {
console.log(chalk.bold(chalk.green(`${toTitle(type)} app is running at ${url}`)));
}
};
@ -43,23 +43,35 @@ export default async function initApp(app: Koa): Promise<void> {
return tenant.run(ctx, next);
});
const { isHttpsEnabled, httpsCert, httpsKey, port } = EnvSet.values;
const { isHttpsEnabled, httpsCert, httpsKey, urlSet, adminUrlSet } = EnvSet.values;
if (isHttpsEnabled && httpsCert && httpsKey) {
http2
.createSecureServer(
const createHttp2Server = async () =>
http2.createSecureServer(
{ cert: await fs.readFile(httpsCert), key: await fs.readFile(httpsKey) },
app.callback()
)
.listen(port, () => {
logListening();
});
);
const coreServer = await createHttp2Server();
const adminServer = await createHttp2Server();
coreServer.listen(urlSet.port, () => {
logListening();
});
adminServer.listen(adminUrlSet.port, () => {
logListening('admin');
});
return;
}
// Chrome doesn't allow insecure http/2 servers
app.listen(port, () => {
app.listen(urlSet.port, () => {
logListening();
});
app.listen(adminUrlSet.port, () => {
logListening('admin');
});
}

View file

@ -0,0 +1,70 @@
import net from 'net';
import { tryThat } from '@logto/shared';
import { assertEnv, deduplicate, getEnv, getEnvAsStringArray } from '@silverhand/essentials';
import { isTrue } from './parameters.js';
import { throwErrorWithDsnMessage } from './throw-errors.js';
const enableMultiTenancyKey = 'ENABLE_MULTI_TENANCY';
const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID';
type MultiTenancyMode = 'domain' | 'env';
export class UrlSet {
public readonly port = Number(getEnv(this.envPrefix + 'PORT') || this.defaultPort);
public readonly localhostUrl = `${this.isHttpsEnabled ? 'https' : 'http'}://localhost:${
this.port
}`;
public readonly endpoint = getEnv(this.envPrefix + 'ENDPOINT', this.localhostUrl);
constructor(
public readonly isHttpsEnabled: boolean,
protected readonly defaultPort: number,
protected readonly envPrefix: string = ''
) {}
public deduplicated(): string[] {
return deduplicate([this.localhostUrl, this.endpoint]);
}
}
export default class GlobalValues {
public readonly isProduction = getEnv('NODE_ENV') === 'production';
public readonly isTest = getEnv('NODE_ENV') === 'test';
public readonly isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST'));
public readonly httpsCert = process.env.HTTPS_CERT_PATH;
public readonly httpsKey = process.env.HTTPS_KEY_PATH;
public readonly isHttpsEnabled = Boolean(this.httpsCert && this.httpsKey);
public readonly isMultiTenancy = isTrue(getEnv(enableMultiTenancyKey));
public readonly urlSet = new UrlSet(this.isHttpsEnabled, 3001);
public readonly adminUrlSet = new UrlSet(this.isHttpsEnabled, 3002, 'ADMIN_');
// eslint-disable-next-line unicorn/consistent-function-scoping
public readonly databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
public readonly developmentTenantId = getEnv(developmentTenantIdKey);
public readonly userDefaultRoleNames = getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES');
public readonly developmentUserId = getEnv('DEVELOPMENT_USER_ID');
public readonly trustProxyHeader = isTrue(getEnv('TRUST_PROXY_HEADER'));
public readonly ignoreConnectorVersionCheck = isTrue(getEnv('IGNORE_CONNECTOR_VERSION_CHECK'));
public get dbUrl(): string {
return this.databaseUrl;
}
public get endpoint(): string {
return this.urlSet.endpoint;
}
public get multiTenancyMode(): MultiTenancyMode {
const { hostname } = new URL(this.endpoint);
return this.isMultiTenancy && !net.isIP(hostname) && hostname !== 'localhost'
? 'domain'
: 'env';
}
}

View file

@ -1,8 +1,4 @@
import net from 'net';
import { tryThat } from '@logto/shared';
import type { Optional } from '@silverhand/essentials';
import { assertEnv, getEnv, getEnvAsStringArray } from '@silverhand/essentials';
import type { PostgreSql } from '@withtyped/postgres';
import type { QueryClient } from '@withtyped/server';
import type { DatabasePool } from 'slonik';
@ -10,12 +6,12 @@ import type { DatabasePool } from 'slonik';
import { createLogtoConfigLibrary } from '#src/libraries/logto-config.js';
import { appendPath } from '#src/utils/url.js';
import GlobalValues from './GlobalValues.js';
import { checkAlterationState } from './check-alteration-state.js';
import createPool from './create-pool.js';
import createQueryClient from './create-query-client.js';
import loadOidcValues from './oidc.js';
import { isTrue } from './parameters.js';
import { throwErrorWithDsnMessage, throwNotLoadedError } from './throw-errors.js';
import { throwNotLoadedError } from './throw-errors.js';
export enum MountedApps {
Api = 'api',
@ -25,72 +21,8 @@ export enum MountedApps {
Welcome = 'welcome',
}
type MultiTenancyMode = 'domain' | 'env';
const enableMultiTenancyKey = 'ENABLE_MULTI_TENANCY';
const developmentTenantIdKey = 'DEVELOPMENT_TENANT_ID';
const loadEnvValues = () => {
const isProduction = getEnv('NODE_ENV') === 'production';
const isTest = getEnv('NODE_ENV') === 'test';
const isIntegrationTest = isTrue(getEnv('INTEGRATION_TEST'));
const isHttpsEnabled = Boolean(process.env.HTTPS_CERT_PATH && process.env.HTTPS_KEY_PATH);
const isMultiTenancy = isTrue(getEnv(enableMultiTenancyKey));
const port = Number(getEnv('PORT', '3001'));
const localhostUrl = `${isHttpsEnabled ? 'https' : 'http'}://localhost:${port}`;
const endpoint = getEnv('ENDPOINT', localhostUrl);
const databaseUrl = tryThat(() => assertEnv('DB_URL'), throwErrorWithDsnMessage);
const { hostname } = new URL(endpoint);
const multiTenancyMode: MultiTenancyMode =
isMultiTenancy && !net.isIP(hostname) && hostname !== 'localhost' ? 'domain' : 'env';
const developmentTenantId = getEnv(developmentTenantIdKey);
if (!isMultiTenancy && developmentTenantId) {
throw new Error(
`Multi-tenancy is disabled but development tenant env \`${developmentTenantIdKey}\` found. Please enable multi-tenancy by setting \`${enableMultiTenancyKey}\` to true.`
);
}
if (isMultiTenancy && multiTenancyMode === 'env') {
if (isProduction) {
throw new Error(
`Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` +
'An endpoint with a valid domain is required for multi-tenancy mode.'
);
}
console.warn(
'[warn]',
`Multi-tenancy is enabled but the endpoint is an IP address: ${endpoint.toString()}.\n\n` +
`Logto is using \`${developmentTenantIdKey}\` env (current value: ${developmentTenantId}) for tenant recognition which is not supported in production.`
);
}
return Object.freeze({
isTest,
isIntegrationTest,
isProduction,
isHttpsEnabled,
isMultiTenancy,
httpsCert: process.env.HTTPS_CERT_PATH,
httpsKey: process.env.HTTPS_KEY_PATH,
port,
localhostUrl,
endpoint,
multiTenancyMode,
dbUrl: databaseUrl,
userDefaultRoleNames: getEnvAsStringArray('USER_DEFAULT_ROLE_NAMES'),
developmentUserId: getEnv('DEVELOPMENT_USER_ID'),
developmentTenantId,
trustProxyHeader: isTrue(getEnv('TRUST_PROXY_HEADER')),
adminConsoleUrl: appendPath(endpoint, '/console'),
ignoreConnectorVersionCheck: isTrue(getEnv('IGNORE_CONNECTOR_VERSION_CHECK')),
});
};
export class EnvSet {
static values: ReturnType<typeof loadEnvValues> = loadEnvValues();
static values = new GlobalValues();
static default = new EnvSet(EnvSet.values.dbUrl);
static get isTest() {

View file

@ -1,25 +1,30 @@
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import type Queries from '#src/tenants/Queries.js';
import { appendPath } from '#src/utils/url.js';
export default function koaWelcomeProxy<
export default function koaConsoleRedirectProxy<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT
>(queries: Queries): MiddlewareType<StateT, ContextT, ResponseBodyT> {
const { hasActiveUsers } = queries.users;
const { adminConsoleUrl } = EnvSet.values;
return async (ctx) => {
if (await hasActiveUsers()) {
ctx.redirect(adminConsoleUrl.toString());
return async (ctx, next) => {
const hasUser = await hasActiveUsers();
if ((ctx.path === '/' || ctx.path === '/console') && !hasUser) {
ctx.redirect('/console/welcome');
return;
}
ctx.redirect(appendPath(adminConsoleUrl, '/welcome').toString());
if (ctx.path === '/console/welcome' && hasUser) {
ctx.redirect('/console');
return;
}
return next();
};
}

View file

@ -1,31 +0,0 @@
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
import koaRootProxy from './koa-root-proxy.js';
const { jest } = import.meta;
describe('koaRootProxy', () => {
const next = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it('empty path should directly return', async () => {
const ctx = createContextWithRouteParameters({
url: '/',
});
await koaRootProxy()(ctx, next);
expect(next).not.toBeCalled();
});
it('non-empty path should return next', async () => {
const ctx = createContextWithRouteParameters({
url: '/console',
});
await koaRootProxy()(ctx, next);
expect(next).toBeCalled();
});
});

View file

@ -1,26 +0,0 @@
import type { MiddlewareType } from 'koa';
import type { IRouterParamContext } from 'koa-router';
import { EnvSet } from '#src/env-set/index.js';
import { appendPath } from '#src/utils/url.js';
export default function koaRootProxy<
StateT,
ContextT extends IRouterParamContext,
ResponseBodyT
>(): MiddlewareType<StateT, ContextT, ResponseBodyT> {
const { endpoint } = EnvSet.values;
return async (ctx, next) => {
const requestPath = ctx.request.path;
// Redirect root path to the Admin Console welcome page
if (requestPath === '/') {
ctx.redirect(appendPath(endpoint, '/welcome').toString());
return;
}
return next();
};
}

View file

@ -1,46 +0,0 @@
import { pickDefault } from '@logto/shared/esm';
import { EnvSet, MountedApps } from '#src/env-set/index.js';
import { MockQueries } from '#src/test-utils/tenant.js';
import { createContextWithRouteParameters } from '#src/utils/test-utils.js';
const { jest } = import.meta;
const hasActiveUsers = jest.fn();
const queries = new MockQueries({ users: { hasActiveUsers } });
const koaWelcomeProxy = await pickDefault(import('./koa-welcome-proxy.js'));
describe('koaWelcomeProxy', () => {
const next = jest.fn();
afterEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
});
it('should redirect to admin console if has AdminUsers', async () => {
const { endpoint } = EnvSet.values;
hasActiveUsers.mockResolvedValue(true);
const ctx = createContextWithRouteParameters({
url: `/${MountedApps.Welcome}`,
});
await koaWelcomeProxy(queries)(ctx, next);
expect(ctx.redirect).toBeCalledWith(`${endpoint}/${MountedApps.Console}`);
expect(next).not.toBeCalled();
});
it('should redirect to welcome page if has no Users', async () => {
const { endpoint } = EnvSet.values;
hasActiveUsers.mockResolvedValue(false);
const ctx = createContextWithRouteParameters({
url: `/${MountedApps.Welcome}`,
});
await koaWelcomeProxy(queries)(ctx, next);
expect(ctx.redirect).toBeCalledWith(`${endpoint}/${MountedApps.Console}/welcome`);
expect(next).not.toBeCalled();
});
});

View file

@ -14,11 +14,8 @@ import { appendPath } from '#src/utils/url.js';
import { getConstantClientMetadata } from './utils.js';
const buildAdminConsoleClientMetadata = (envSet: EnvSet): AllClientMetadata => {
const { localhostUrl, adminConsoleUrl } = EnvSet.values;
const urls = deduplicate([
appendPath(localhostUrl, '/console').toString(),
adminConsoleUrl.toString(),
]);
const { adminUrlSet } = EnvSet.values;
const urls = adminUrlSet.deduplicated().map((url) => appendPath(url, '/console').toString());
return {
...getConstantClientMetadata(envSet, ApplicationType.SPA),
@ -32,10 +29,10 @@ const buildAdminConsoleClientMetadata = (envSet: EnvSet): AllClientMetadata => {
const buildDemoAppUris = (
oidcClientMetadata: OidcClientMetadata
): Pick<OidcClientMetadata, 'redirectUris' | 'postLogoutRedirectUris'> => {
const { localhostUrl, endpoint } = EnvSet.values;
const { urlSet } = EnvSet.values;
const urls = [
appendPath(localhostUrl, MountedApps.DemoApp).toString(),
appendPath(endpoint, MountedApps.DemoApp).toString(),
appendPath(urlSet.localhostUrl, MountedApps.DemoApp).toString(),
appendPath(urlSet.endpoint, MountedApps.DemoApp).toString(),
];
const data = {

View file

@ -8,14 +8,13 @@ import type Provider from 'oidc-provider';
import { EnvSet, MountedApps } from '#src/env-set/index.js';
import koaCheckDemoApp from '#src/middleware/koa-check-demo-app.js';
import koaConnectorErrorHandler from '#src/middleware/koa-connector-error-handler.js';
import koaConsoleRedirectProxy from '#src/middleware/koa-console-redirect-proxy.js';
import koaErrorHandler from '#src/middleware/koa-error-handler.js';
import koaI18next from '#src/middleware/koa-i18next.js';
import koaOIDCErrorHandler from '#src/middleware/koa-oidc-error-handler.js';
import koaRootProxy from '#src/middleware/koa-root-proxy.js';
import koaSlonikErrorHandler from '#src/middleware/koa-slonik-error-handler.js';
import koaSpaProxy from '#src/middleware/koa-spa-proxy.js';
import koaSpaSessionGuard from '#src/middleware/koa-spa-session-guard.js';
import koaWelcomeProxy from '#src/middleware/koa-welcome-proxy.js';
import type { ModelRouters } from '#src/model-routers/index.js';
import { createModelRouters } from '#src/model-routers/index.js';
import initOidc from '#src/oidc/init.js';
@ -80,17 +79,17 @@ export default class Tenant implements TenantContext {
app.use(koaConnectorErrorHandler());
app.use(koaI18next());
// Mount APIs
const apisApp = initRouter({ provider, queries, libraries, modelRouters, envSet });
app.use(mount('/api', apisApp));
app.use(mount('/', koaRootProxy()));
app.use(mount('/' + MountedApps.Welcome, koaWelcomeProxy(queries)));
// Mount Admin Console
app.use(koaConsoleRedirectProxy(queries));
app.use(
mount('/' + MountedApps.Console, koaSpaProxy(MountedApps.Console, 5002, MountedApps.Console))
);
// Mount demo app
app.use(
mount(
'/' + MountedApps.DemoApp,
@ -101,6 +100,7 @@ export default class Tenant implements TenantContext {
)
);
// Mount UI
app.use(compose([koaSpaSessionGuard(provider), koaSpaProxy()]));
this.app = app;