Skip to content
/ logto Public
  • Rate limit · GitHub

    Access has been restricted

    You have triggered a rate limit.

    Please wait a few minutes before you try again;
    in some cases this may take up to an hour.

  • Notifications You must be signed in to change notification settings
  • Fork 475
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): set up proxy to host custom ui assets if available #6214

Merged
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
feat(core): set up proxy to host custom ui assets if available
  • Loading branch information
charIeszhao committed Jul 16, 2024
commit 80310f8904094f040bf6661f14dc048b641c5390
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;
}
26 changes: 26 additions & 0 deletions packages/core/src/middleware/koa-spa-proxy.test.ts
Original file line number Diff line number Diff line change
@@ -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();
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'));

@@ -85,4 +97,18 @@ describe('koaSpaProxy middleware', () => {
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, undefined, undefined, undefined, queries)(ctx, next);
expect(mockCustomUiAssetsMiddleware).toBeCalled();
expect(mockStaticMiddleware).not.toBeCalled();
expect(mockProxyMiddleware).not.toBeCalled();
});
});
15 changes: 14 additions & 1 deletion packages/core/src/middleware/koa-spa-proxy.ts
Original file line number Diff line number Diff line change
@@ -7,12 +7,17 @@ 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';

import serveCustomUiAssets from './koa-serve-custom-ui-assets.js';

// eslint-disable-next-line max-params
export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext, ResponseBodyT>(
mountedApps: string[],
packagePath = 'experience',
port = 5001,
prefix = ''
prefix = '',
queries?: Queries
charIeszhao marked this conversation as resolved.
Show resolved Hide resolved
): MiddlewareType<StateT, ContextT, ResponseBodyT> {
type Middleware = MiddlewareType<StateT, ContextT, ResponseBodyT>;

@@ -43,6 +48,14 @@ export default function koaSpaProxy<StateT, ContextT extends IRouterParamContext
return next();
}

const { signInExperiences } = queries ?? {};
charIeszhao marked this conversation as resolved.
Show resolved Hide resolved
const { customUiAssets } = (await 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);
}
2 changes: 1 addition & 1 deletion packages/core/src/tenants/Tenant.ts
Original file line number Diff line number Diff line change
@@ -173,7 +173,7 @@ export default class Tenant implements TenantContext {
koaExperienceSsr(libraries, queries),
koaSpaSessionGuard(provider, queries),
mount(`/${experience.routes.consent}`, koaAutoConsent(provider, queries)),
koaSpaProxy(mountedApps),
koaSpaProxy(mountedApps, undefined, undefined, undefined, queries),
])
);

Rate limit · GitHub

Access has been restricted

You have triggered a rate limit.

Please wait a few minutes before you try again;
in some cases this may take up to an hour.