diff --git a/packages/core/src/middleware/koa-serve-custom-ui-assets.test.ts b/packages/core/src/middleware/koa-serve-custom-ui-assets.test.ts new file mode 100644 index 000000000..41621a06c --- /dev/null +++ b/packages/core/src/middleware/koa-serve-custom-ui-assets.test.ts @@ -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(''); + 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 }) + ); + }); +}); diff --git a/packages/core/src/middleware/koa-serve-custom-ui-assets.ts b/packages/core/src/middleware/koa-serve-custom-ui-assets.ts new file mode 100644 index 000000000..0232fedf9 --- /dev/null +++ b/packages/core/src/middleware/koa-serve-custom-ui-assets.ts @@ -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; +} diff --git a/packages/core/src/middleware/koa-spa-proxy.test.ts b/packages/core/src/middleware/koa-spa-proxy.test.ts index 3216ea0b8..23f003c41 100644 --- a/packages/core/src/middleware/koa-spa-proxy.test.ts +++ b/packages/core/src/middleware/koa-spa-proxy.test.ts @@ -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(); + }); }); diff --git a/packages/core/src/middleware/koa-spa-proxy.ts b/packages/core/src/middleware/koa-spa-proxy.ts index ede71a7ef..6ec020135 100644 --- a/packages/core/src/middleware/koa-spa-proxy.ts +++ b/packages/core/src/middleware/koa-spa-proxy.ts @@ -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( - 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({ + mountedApps, packagePath = 'experience', port = 5001, - prefix = '' -): MiddlewareType { + prefix = '', + queries, +}: Properties): MiddlewareType { type Middleware = MiddlewareType; const distributionPath = path.join('node_modules/@logto', packagePath, 'dist'); @@ -43,6 +55,13 @@ export default function koaSpaProxy