mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): set up proxy to host custom ui assets if available (#6214)
* feat(core): set up proxy to host custom ui assets if available * refactor: use object param for koa spa proxy middleware * refactor: make queries param mandatory
This commit is contained in:
parent
f73b698381
commit
f8f14c0ba7
5 changed files with 199 additions and 11 deletions
|
@ -0,0 +1,91 @@
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
|
||||||
|
import { StorageProvider } from '@logto/schemas';
|
||||||
|
import { createMockUtils, pickDefault } from '@logto/shared/esm';
|
||||||
|
|
||||||
|
import RequestError from '#src/errors/RequestError/index.js';
|
||||||
|
import SystemContext from '#src/tenants/SystemContext.js';
|
||||||
|
import createMockContext from '#src/test-utils/jest-koa-mocks/create-mock-context.js';
|
||||||
|
|
||||||
|
const { jest } = import.meta;
|
||||||
|
const { mockEsmWithActual } = createMockUtils(jest);
|
||||||
|
|
||||||
|
const experienceBlobsProviderConfig = {
|
||||||
|
provider: StorageProvider.AzureStorage,
|
||||||
|
connectionString: 'connectionString',
|
||||||
|
container: 'container',
|
||||||
|
} satisfies {
|
||||||
|
provider: StorageProvider.AzureStorage;
|
||||||
|
connectionString: string;
|
||||||
|
container: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
SystemContext.shared.experienceBlobsProviderConfig = experienceBlobsProviderConfig;
|
||||||
|
|
||||||
|
const mockedIsFileExisted = jest.fn(async (filename: string) => true);
|
||||||
|
const mockedDownloadFile = jest.fn();
|
||||||
|
|
||||||
|
await mockEsmWithActual('#src/utils/storage/azure-storage.js', () => ({
|
||||||
|
buildAzureStorage: jest.fn(() => ({
|
||||||
|
uploadFile: jest.fn(async () => 'https://fake.url'),
|
||||||
|
downloadFile: mockedDownloadFile,
|
||||||
|
isFileExisted: mockedIsFileExisted,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await mockEsmWithActual('#src/utils/tenant.js', () => ({
|
||||||
|
getTenantId: jest.fn().mockResolvedValue(['default']),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const koaServeCustomUiAssets = await pickDefault(import('./koa-serve-custom-ui-assets.js'));
|
||||||
|
|
||||||
|
describe('koaServeCustomUiAssets middleware', () => {
|
||||||
|
const next = jest.fn();
|
||||||
|
|
||||||
|
it('should serve the file directly if the request path contains a dot', async () => {
|
||||||
|
const mockBodyStream = Readable.from('javascript content');
|
||||||
|
mockedDownloadFile.mockImplementation(async (objectKey: string) => {
|
||||||
|
if (objectKey.endsWith('/scripts.js')) {
|
||||||
|
return {
|
||||||
|
contentType: 'text/javascript',
|
||||||
|
readableStreamBody: mockBodyStream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('File not found');
|
||||||
|
});
|
||||||
|
const ctx = createMockContext({ url: '/scripts.js' });
|
||||||
|
|
||||||
|
await koaServeCustomUiAssets('custom-ui-asset-id')(ctx, next);
|
||||||
|
|
||||||
|
expect(ctx.type).toEqual('text/javascript');
|
||||||
|
expect(ctx.body).toEqual(mockBodyStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should serve the index.html', async () => {
|
||||||
|
const mockBodyStream = Readable.from('<html></html>');
|
||||||
|
mockedDownloadFile.mockImplementation(async (objectKey: string) => {
|
||||||
|
if (objectKey.endsWith('/index.html')) {
|
||||||
|
return {
|
||||||
|
contentType: 'text/html',
|
||||||
|
readableStreamBody: mockBodyStream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('File not found');
|
||||||
|
});
|
||||||
|
const ctx = createMockContext({ url: '/sign-in' });
|
||||||
|
await koaServeCustomUiAssets('custom-ui-asset-id')(ctx, next);
|
||||||
|
|
||||||
|
expect(ctx.type).toEqual('text/html');
|
||||||
|
expect(ctx.body).toEqual(mockBodyStream);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 404 if the file does not exist', async () => {
|
||||||
|
mockedIsFileExisted.mockResolvedValue(false);
|
||||||
|
const ctx = createMockContext({ url: '/fake.txt' });
|
||||||
|
|
||||||
|
await expect(koaServeCustomUiAssets('custom-ui-asset-id')(ctx, next)).rejects.toMatchError(
|
||||||
|
new RequestError({ code: 'entity.not_found', status: 404 })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
40
packages/core/src/middleware/koa-serve-custom-ui-assets.ts
Normal file
40
packages/core/src/middleware/koa-serve-custom-ui-assets.ts
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
import type { MiddlewareType } from 'koa';
|
||||||
|
|
||||||
|
import SystemContext from '#src/tenants/SystemContext.js';
|
||||||
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
import { buildAzureStorage } from '#src/utils/storage/azure-storage.js';
|
||||||
|
import { getTenantId } from '#src/utils/tenant.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware that serves custom UI assets user uploaded previously through sign-in experience settings.
|
||||||
|
* If the request path contains a dot, consider it as a file and will try to serve the file directly.
|
||||||
|
* Otherwise, redirect the request to the `index.html` page.
|
||||||
|
*/
|
||||||
|
export default function koaServeCustomUiAssets(customUiAssetId: string) {
|
||||||
|
const { experienceBlobsProviderConfig } = SystemContext.shared;
|
||||||
|
assertThat(experienceBlobsProviderConfig?.provider === 'AzureStorage', 'storage.not_configured');
|
||||||
|
|
||||||
|
const serve: MiddlewareType = async (ctx, next) => {
|
||||||
|
const [tenantId] = await getTenantId(ctx.URL);
|
||||||
|
assertThat(tenantId, 'session.not_found', 404);
|
||||||
|
|
||||||
|
const { container, connectionString } = experienceBlobsProviderConfig;
|
||||||
|
const { downloadFile, isFileExisted } = buildAzureStorage(connectionString, container);
|
||||||
|
|
||||||
|
const contextPath = `${tenantId}/${customUiAssetId}`;
|
||||||
|
const requestPath = ctx.request.path;
|
||||||
|
const isFileRequest = requestPath.includes('.');
|
||||||
|
|
||||||
|
const fileObjectKey = `${contextPath}${isFileRequest ? requestPath : '/index.html'}`;
|
||||||
|
const isExisted = await isFileExisted(fileObjectKey);
|
||||||
|
assertThat(isExisted, 'entity.not_found', 404);
|
||||||
|
|
||||||
|
const downloadResponse = await downloadFile(fileObjectKey);
|
||||||
|
ctx.type = downloadResponse.contentType ?? 'application/octet-stream';
|
||||||
|
ctx.body = downloadResponse.readableStreamBody;
|
||||||
|
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
|
||||||
|
return serve;
|
||||||
|
}
|
|
@ -10,6 +10,7 @@ const { mockEsmDefault } = createMockUtils(jest);
|
||||||
|
|
||||||
const mockProxyMiddleware = jest.fn();
|
const mockProxyMiddleware = jest.fn();
|
||||||
const mockStaticMiddleware = jest.fn();
|
const mockStaticMiddleware = jest.fn();
|
||||||
|
const mockCustomUiAssetsMiddleware = jest.fn();
|
||||||
const mountedApps = Object.values(UserApps);
|
const mountedApps = Object.values(UserApps);
|
||||||
|
|
||||||
mockEsmDefault('node:fs/promises', () => ({
|
mockEsmDefault('node:fs/promises', () => ({
|
||||||
|
@ -18,6 +19,17 @@ mockEsmDefault('node:fs/promises', () => ({
|
||||||
|
|
||||||
mockEsmDefault('koa-proxies', () => jest.fn(() => mockProxyMiddleware));
|
mockEsmDefault('koa-proxies', () => jest.fn(() => mockProxyMiddleware));
|
||||||
mockEsmDefault('#src/middleware/koa-serve-static.js', () => jest.fn(() => mockStaticMiddleware));
|
mockEsmDefault('#src/middleware/koa-serve-static.js', () => jest.fn(() => mockStaticMiddleware));
|
||||||
|
mockEsmDefault('#src/middleware/koa-serve-custom-ui-assets.js', () =>
|
||||||
|
jest.fn(() => mockCustomUiAssetsMiddleware)
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockFindDefaultSignInExperience = jest.fn().mockResolvedValue({ customUiAssets: null });
|
||||||
|
const { MockQueries } = await import('#src/test-utils/tenant.js');
|
||||||
|
const queries = new MockQueries({
|
||||||
|
signInExperiences: {
|
||||||
|
findDefaultSignInExperience: mockFindDefaultSignInExperience,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const koaSpaProxy = await pickDefault(import('./koa-spa-proxy.js'));
|
const koaSpaProxy = await pickDefault(import('./koa-spa-proxy.js'));
|
||||||
|
|
||||||
|
@ -42,7 +54,7 @@ describe('koaSpaProxy middleware', () => {
|
||||||
url: `/${app}/foo`,
|
url: `/${app}/foo`,
|
||||||
});
|
});
|
||||||
|
|
||||||
await koaSpaProxy(mountedApps)(ctx, next);
|
await koaSpaProxy({ mountedApps, queries })(ctx, next);
|
||||||
|
|
||||||
expect(mockProxyMiddleware).not.toBeCalled();
|
expect(mockProxyMiddleware).not.toBeCalled();
|
||||||
});
|
});
|
||||||
|
@ -50,7 +62,7 @@ describe('koaSpaProxy middleware', () => {
|
||||||
|
|
||||||
it('dev env should call dev proxy for SPA paths', async () => {
|
it('dev env should call dev proxy for SPA paths', async () => {
|
||||||
const ctx = createContextWithRouteParameters();
|
const ctx = createContextWithRouteParameters();
|
||||||
await koaSpaProxy(mountedApps)(ctx, next);
|
await koaSpaProxy({ mountedApps, queries })(ctx, next);
|
||||||
expect(mockProxyMiddleware).toBeCalled();
|
expect(mockProxyMiddleware).toBeCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -64,7 +76,7 @@ describe('koaSpaProxy middleware', () => {
|
||||||
url: '/foo',
|
url: '/foo',
|
||||||
});
|
});
|
||||||
|
|
||||||
await koaSpaProxy(mountedApps)(ctx, next);
|
await koaSpaProxy({ mountedApps, queries })(ctx, next);
|
||||||
|
|
||||||
expect(mockStaticMiddleware).toBeCalled();
|
expect(mockStaticMiddleware).toBeCalled();
|
||||||
expect(ctx.request.path).toEqual('/');
|
expect(ctx.request.path).toEqual('/');
|
||||||
|
@ -81,8 +93,22 @@ describe('koaSpaProxy middleware', () => {
|
||||||
url: '/sign-in',
|
url: '/sign-in',
|
||||||
});
|
});
|
||||||
|
|
||||||
await koaSpaProxy(mountedApps)(ctx, next);
|
await koaSpaProxy({ mountedApps, queries })(ctx, next);
|
||||||
expect(mockStaticMiddleware).toBeCalled();
|
expect(mockStaticMiddleware).toBeCalled();
|
||||||
stub.restore();
|
stub.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should serve custom UI assets if user uploaded them', async () => {
|
||||||
|
const customUiAssets = { id: 'custom-ui-assets', createdAt: Date.now() };
|
||||||
|
mockFindDefaultSignInExperience.mockResolvedValue({ customUiAssets });
|
||||||
|
|
||||||
|
const ctx = createContextWithRouteParameters({
|
||||||
|
url: '/sign-in',
|
||||||
|
});
|
||||||
|
|
||||||
|
await koaSpaProxy({ mountedApps, queries })(ctx, next);
|
||||||
|
expect(mockCustomUiAssetsMiddleware).toBeCalled();
|
||||||
|
expect(mockStaticMiddleware).not.toBeCalled();
|
||||||
|
expect(mockProxyMiddleware).not.toBeCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,13 +7,25 @@ import type { IRouterParamContext } from 'koa-router';
|
||||||
|
|
||||||
import { EnvSet } from '#src/env-set/index.js';
|
import { EnvSet } from '#src/env-set/index.js';
|
||||||
import serveStatic from '#src/middleware/koa-serve-static.js';
|
import serveStatic from '#src/middleware/koa-serve-static.js';
|
||||||
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
|
|
||||||
export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
|
import serveCustomUiAssets from './koa-serve-custom-ui-assets.js';
|
||||||
mountedApps: string[],
|
|
||||||
|
type Properties = {
|
||||||
|
readonly mountedApps: string[];
|
||||||
|
readonly queries: Queries;
|
||||||
|
readonly packagePath?: string;
|
||||||
|
readonly port?: number;
|
||||||
|
readonly prefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext, ResponseBodyT>({
|
||||||
|
mountedApps,
|
||||||
packagePath = 'experience',
|
packagePath = 'experience',
|
||||||
port = 5001,
|
port = 5001,
|
||||||
prefix = ''
|
prefix = '',
|
||||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
queries,
|
||||||
|
}: Properties): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||||
type Middleware = MiddlewareType<StateT, ContextT, ResponseBodyT>;
|
type Middleware = MiddlewareType<StateT, ContextT, ResponseBodyT>;
|
||||||
|
|
||||||
const distributionPath = path.join('node_modules/@logto', packagePath, 'dist');
|
const distributionPath = path.join('node_modules/@logto', packagePath, 'dist');
|
||||||
|
@ -43,6 +55,13 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { customUiAssets } = await queries.signInExperiences.findDefaultSignInExperience();
|
||||||
|
// If user has uploaded custom UI assets, serve them instead of native experience UI
|
||||||
|
if (customUiAssets && packagePath === 'experience') {
|
||||||
|
const serve = serveCustomUiAssets(customUiAssets.id);
|
||||||
|
return serve(ctx, next);
|
||||||
|
}
|
||||||
|
|
||||||
if (!EnvSet.values.isProduction) {
|
if (!EnvSet.values.isProduction) {
|
||||||
return spaProxy(ctx, next);
|
return spaProxy(ctx, next);
|
||||||
}
|
}
|
||||||
|
|
|
@ -147,7 +147,13 @@ export default class Tenant implements TenantContext {
|
||||||
app.use(
|
app.use(
|
||||||
mount(
|
mount(
|
||||||
'/' + AdminApps.Console,
|
'/' + AdminApps.Console,
|
||||||
koaSpaProxy(mountedApps, AdminApps.Console, 5002, AdminApps.Console)
|
koaSpaProxy({
|
||||||
|
mountedApps,
|
||||||
|
queries,
|
||||||
|
packagePath: AdminApps.Console,
|
||||||
|
port: 5002,
|
||||||
|
prefix: AdminApps.Console,
|
||||||
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -162,7 +168,13 @@ export default class Tenant implements TenantContext {
|
||||||
app.use(
|
app.use(
|
||||||
mount(
|
mount(
|
||||||
'/' + UserApps.DemoApp,
|
'/' + UserApps.DemoApp,
|
||||||
koaSpaProxy(mountedApps, UserApps.DemoApp, 5003, UserApps.DemoApp)
|
koaSpaProxy({
|
||||||
|
mountedApps,
|
||||||
|
queries,
|
||||||
|
packagePath: UserApps.DemoApp,
|
||||||
|
port: 5003,
|
||||||
|
prefix: UserApps.DemoApp,
|
||||||
|
})
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -173,7 +185,7 @@ export default class Tenant implements TenantContext {
|
||||||
koaExperienceSsr(libraries, queries),
|
koaExperienceSsr(libraries, queries),
|
||||||
koaSpaSessionGuard(provider, queries),
|
koaSpaSessionGuard(provider, queries),
|
||||||
mount(`/${experience.routes.consent}`, koaAutoConsent(provider, queries)),
|
mount(`/${experience.routes.consent}`, koaAutoConsent(provider, queries)),
|
||||||
koaSpaProxy(mountedApps),
|
koaSpaProxy({ mountedApps, queries }),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue