mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -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 mockStaticMiddleware = jest.fn();
|
||||
const mockCustomUiAssetsMiddleware = jest.fn();
|
||||
const mountedApps = Object.values(UserApps);
|
||||
|
||||
mockEsmDefault('node:fs/promises', () => ({
|
||||
|
@ -18,6 +19,17 @@ mockEsmDefault('node:fs/promises', () => ({
|
|||
|
||||
mockEsmDefault('koa-proxies', () => jest.fn(() => mockProxyMiddleware));
|
||||
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'));
|
||||
|
||||
|
@ -42,7 +54,7 @@ describe('koaSpaProxy middleware', () => {
|
|||
url: `/${app}/foo`,
|
||||
});
|
||||
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
await koaSpaProxy({ mountedApps, queries })(ctx, next);
|
||||
|
||||
expect(mockProxyMiddleware).not.toBeCalled();
|
||||
});
|
||||
|
@ -50,7 +62,7 @@ describe('koaSpaProxy middleware', () => {
|
|||
|
||||
it('dev env should call dev proxy for SPA paths', async () => {
|
||||
const ctx = createContextWithRouteParameters();
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
await koaSpaProxy({ mountedApps, queries })(ctx, next);
|
||||
expect(mockProxyMiddleware).toBeCalled();
|
||||
});
|
||||
|
||||
|
@ -64,7 +76,7 @@ describe('koaSpaProxy middleware', () => {
|
|||
url: '/foo',
|
||||
});
|
||||
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
await koaSpaProxy({ mountedApps, queries })(ctx, next);
|
||||
|
||||
expect(mockStaticMiddleware).toBeCalled();
|
||||
expect(ctx.request.path).toEqual('/');
|
||||
|
@ -81,8 +93,22 @@ describe('koaSpaProxy middleware', () => {
|
|||
url: '/sign-in',
|
||||
});
|
||||
|
||||
await koaSpaProxy(mountedApps)(ctx, next);
|
||||
await koaSpaProxy({ mountedApps, queries })(ctx, next);
|
||||
expect(mockStaticMiddleware).toBeCalled();
|
||||
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 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>(
|
||||
mountedApps: string[],
|
||||
import serveCustomUiAssets from './koa-serve-custom-ui-assets.js';
|
||||
|
||||
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',
|
||||
port = 5001,
|
||||
prefix = ''
|
||||
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||
prefix = '',
|
||||
queries,
|
||||
}: Properties): MiddlewareType<StateT, ContextT, ResponseBodyT> {
|
||||
type Middleware = MiddlewareType<StateT, ContextT, ResponseBodyT>;
|
||||
|
||||
const distributionPath = path.join('node_modules/@logto', packagePath, 'dist');
|
||||
|
@ -43,6 +55,13 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
|
|||
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) {
|
||||
return spaProxy(ctx, next);
|
||||
}
|
||||
|
|
|
@ -147,7 +147,13 @@ export default class Tenant implements TenantContext {
|
|||
app.use(
|
||||
mount(
|
||||
'/' + 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(
|
||||
mount(
|
||||
'/' + 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),
|
||||
koaSpaSessionGuard(provider, queries),
|
||||
mount(`/${experience.routes.consent}`, koaAutoConsent(provider, queries)),
|
||||
koaSpaProxy(mountedApps),
|
||||
koaSpaProxy({ mountedApps, queries }),
|
||||
])
|
||||
);
|
||||
|
||||
|
|
Loading…
Reference in a new issue