Skip to content

Commit

Permalink
feat(core): set up proxy to host custom ui assets if available (logto…
Browse files Browse the repository at this point in the history
…-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
charIeszhao authored Jul 17, 2024
1 parent f73b698 commit f8f14c0
Show file tree
Hide file tree
Showing 5 changed files with 199 additions and 11 deletions.
91 changes: 91 additions & 0 deletions packages/core/src/middleware/koa-serve-custom-ui-assets.test.ts
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 packages/core/src/middleware/koa-serve-custom-ui-assets.ts
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;
}
34 changes: 30 additions & 4 deletions packages/core/src/middleware/koa-spa-proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand All @@ -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'));

Expand All @@ -42,15 +54,15 @@ describe('koaSpaProxy middleware', () => {
url: `/${app}/foo`,
});

await koaSpaProxy(mountedApps)(ctx, next);
await koaSpaProxy({ mountedApps, queries })(ctx, next);

expect(mockProxyMiddleware).not.toBeCalled();
});
}

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

Expand All @@ -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('/');
Expand All @@ -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();
});
});
27 changes: 23 additions & 4 deletions packages/core/src/middleware/koa-spa-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
}
Expand Down
18 changes: 15 additions & 3 deletions packages/core/src/tenants/Tenant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
)
);
}
Expand All @@ -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,
})
)
);
}
Expand All @@ -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 }),
])
);

Expand Down

0 comments on commit f8f14c0

Please sign in to comment.