0
Fork 0
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:
Charles Zhao 2024-07-17 17:33:47 +08:00 committed by GitHub
parent f73b698381
commit f8f14c0ba7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 199 additions and 11 deletions

View file

@ -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 })
);
});
});

View 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;
}

View file

@ -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();
});
});

View file

@ -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);
}

View file

@ -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 }),
])
);