forked from logto-io/logto
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): set up proxy to host custom ui assets if available (logto…
…-io#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
- Loading branch information
1 parent
f73b698
commit f8f14c0
Showing
5 changed files
with
199 additions
and
11 deletions.
There are no files selected for viewing
91 changes: 91 additions & 0 deletions
91
packages/core/src/middleware/koa-serve-custom-ui-assets.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 changes: 40 additions & 0 deletions
40
packages/core/src/middleware/koa-serve-custom-ui-assets.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters