mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
feat(server): support multiple hosts in one deployment (#12950)
close CLOUD-233 #### PR Dependency Tree * **PR #12950** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for configuring multiple server hosts across backend and frontend settings. * Enhanced deployment and Helm chart configuration to allow specifying multiple ingress hosts. * Updated admin and configuration interfaces to display and manage multiple server hosts. * **Improvements** * Improved URL generation, OAuth, and worker service logic to dynamically handle requests from multiple hosts. * Enhanced captcha verification to support multiple allowed hostnames. * Updated frontend logic for platform-specific server base URLs and allowed origins, including Apple app domains. * Expanded test coverage for multi-host scenarios. * **Bug Fixes** * Corrected backend logic to consistently use dynamic base URLs and origins based on request host context. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -37,6 +37,10 @@ test.before(async t => {
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
hosts: ['localhost', 'test.affine.dev'],
|
||||
https: true,
|
||||
},
|
||||
}),
|
||||
AppModule,
|
||||
],
|
||||
@@ -90,6 +94,38 @@ test("should be able to redirect to oauth provider's login page", async t => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to redirect to oauth provider with multiple hosts', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
const res = await app
|
||||
.POST('/api/oauth/preflight')
|
||||
.set('host', 'test.affine.dev')
|
||||
.send({ provider: 'Google' })
|
||||
.expect(HttpStatus.OK);
|
||||
|
||||
const { url } = res.body;
|
||||
|
||||
const redirect = new URL(url);
|
||||
t.is(redirect.origin, 'https://accounts.google.com');
|
||||
|
||||
t.is(redirect.pathname, '/o/oauth2/v2/auth');
|
||||
t.is(redirect.searchParams.get('client_id'), 'google-client-id');
|
||||
t.is(
|
||||
redirect.searchParams.get('redirect_uri'),
|
||||
'https://test.affine.dev/oauth/callback'
|
||||
);
|
||||
t.is(redirect.searchParams.get('response_type'), 'code');
|
||||
t.is(redirect.searchParams.get('prompt'), 'select_account');
|
||||
t.truthy(redirect.searchParams.get('state'));
|
||||
// state should be a json string
|
||||
const state = JSON.parse(redirect.searchParams.get('state')!);
|
||||
t.is(state.provider, 'Google');
|
||||
t.regex(
|
||||
state.state,
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
||||
);
|
||||
});
|
||||
|
||||
test('should be able to redirect to oauth provider with client_nonce', async t => {
|
||||
const { app } = t.context;
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ClsModule } from 'nestjs-cls';
|
||||
|
||||
import { AppController } from './app.controller';
|
||||
import {
|
||||
getRequestFromHost,
|
||||
getRequestIdFromHost,
|
||||
getRequestIdFromRequest,
|
||||
ScannerModule,
|
||||
@@ -66,8 +67,9 @@ export const FunctionalityModules = [
|
||||
// make every request has a unique id to tracing
|
||||
return getRequestIdFromRequest(req, 'http');
|
||||
},
|
||||
setup(cls, _req, res: Response) {
|
||||
setup(cls, req: Request, res: Response) {
|
||||
res.setHeader('X-Request-Id', cls.getId());
|
||||
cls.set(CLS_REQUEST_HOST, req.hostname);
|
||||
},
|
||||
},
|
||||
// for websocket connection
|
||||
@@ -79,6 +81,10 @@ export const FunctionalityModules = [
|
||||
// make every request has a unique id to tracing
|
||||
return getRequestIdFromHost(context);
|
||||
},
|
||||
setup(cls, context: ExecutionContext) {
|
||||
const req = getRequestFromHost(context);
|
||||
cls.set(CLS_REQUEST_HOST, req.hostname);
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
// https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter
|
||||
|
||||
@@ -12,6 +12,7 @@ test.beforeEach(async t => {
|
||||
server: {
|
||||
externalUrl: '',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '',
|
||||
@@ -28,6 +29,7 @@ test('can factor base url correctly with specified external url', t => {
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '/ignored',
|
||||
@@ -42,6 +44,7 @@ test('can factor base url correctly with specified external url and path', t =>
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com/anything',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '/ignored',
|
||||
@@ -56,6 +59,7 @@ test('can factor base url correctly with specified external url with port', t =>
|
||||
server: {
|
||||
externalUrl: 'https://external.domain.com:123',
|
||||
host: 'app.affine.local',
|
||||
hosts: [],
|
||||
port: 3010,
|
||||
https: true,
|
||||
},
|
||||
@@ -95,7 +99,7 @@ test('can safe redirect', t => {
|
||||
|
||||
function deny(to: string) {
|
||||
t.context.url.safeRedirect(res, to);
|
||||
t.true(spy.calledOnceWith(t.context.url.home));
|
||||
t.true(spy.calledOnceWith(t.context.url.baseUrl));
|
||||
spy.resetHistory();
|
||||
}
|
||||
|
||||
@@ -106,3 +110,38 @@ test('can safe redirect', t => {
|
||||
].forEach(allow);
|
||||
['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
|
||||
});
|
||||
|
||||
test('can get request origin', t => {
|
||||
t.is(t.context.url.requestOrigin, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can get request base url', t => {
|
||||
t.is(t.context.url.requestBaseUrl, 'https://app.affine.local');
|
||||
});
|
||||
|
||||
test('can get request base url with multiple hosts', t => {
|
||||
// mock cls
|
||||
const cls = new Map<string, string>();
|
||||
const url = new URLHelper(
|
||||
{
|
||||
server: {
|
||||
externalUrl: '',
|
||||
host: 'app.affine.local1',
|
||||
hosts: ['app.affine.local1', 'app.affine.local2'],
|
||||
port: 3010,
|
||||
https: true,
|
||||
path: '',
|
||||
},
|
||||
} as any,
|
||||
cls as any
|
||||
);
|
||||
|
||||
// no cls, use default origin
|
||||
t.is(url.requestOrigin, 'https://app.affine.local1');
|
||||
t.is(url.requestBaseUrl, 'https://app.affine.local1');
|
||||
|
||||
// set cls
|
||||
cls.set(CLS_REQUEST_HOST, 'app.affine.local2');
|
||||
t.is(url.requestOrigin, 'https://app.affine.local2');
|
||||
t.is(url.requestBaseUrl, 'https://app.affine.local2');
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { isIP } from 'node:net';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { Response } from 'express';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
|
||||
import { Config } from '../config';
|
||||
import { OnEvent } from '../event';
|
||||
@@ -11,10 +12,13 @@ export class URLHelper {
|
||||
redirectAllowHosts!: string[];
|
||||
|
||||
origin!: string;
|
||||
allowedOrigins!: string[];
|
||||
baseUrl!: string;
|
||||
home!: string;
|
||||
|
||||
constructor(private readonly config: Config) {
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly cls?: ClsService
|
||||
) {
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -34,19 +38,40 @@ export class URLHelper {
|
||||
this.baseUrl =
|
||||
externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
|
||||
} else {
|
||||
this.origin = [
|
||||
this.config.server.https ? 'https' : 'http',
|
||||
'://',
|
||||
this.config.server.host,
|
||||
this.config.server.host === 'localhost' || isIP(this.config.server.host)
|
||||
? `:${this.config.server.port}`
|
||||
: '',
|
||||
].join('');
|
||||
this.origin = this.convertHostToOrigin(this.config.server.host);
|
||||
this.baseUrl = this.origin + this.config.server.path;
|
||||
}
|
||||
|
||||
this.home = this.baseUrl;
|
||||
this.redirectAllowHosts = [this.baseUrl];
|
||||
|
||||
this.allowedOrigins = [this.origin];
|
||||
if (this.config.server.hosts.length > 0) {
|
||||
for (const host of this.config.server.hosts) {
|
||||
this.allowedOrigins.push(this.convertHostToOrigin(host));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get requestOrigin() {
|
||||
if (this.config.server.hosts.length === 0) {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
// support multiple hosts
|
||||
const requestHost = this.cls?.get<string | undefined>(CLS_REQUEST_HOST);
|
||||
if (!requestHost || !this.config.server.hosts.includes(requestHost)) {
|
||||
return this.origin;
|
||||
}
|
||||
|
||||
return this.convertHostToOrigin(requestHost);
|
||||
}
|
||||
|
||||
get requestBaseUrl() {
|
||||
if (this.config.server.hosts.length === 0) {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
return this.requestOrigin + this.config.server.path;
|
||||
}
|
||||
|
||||
stringify(query: Record<string, any>) {
|
||||
@@ -72,7 +97,7 @@ export class URLHelper {
|
||||
}
|
||||
|
||||
url(path: string, query: Record<string, any> = {}) {
|
||||
const url = new URL(path, this.origin);
|
||||
const url = new URL(path, this.requestOrigin);
|
||||
|
||||
for (const key in query) {
|
||||
url.searchParams.set(key, query[key]);
|
||||
@@ -87,7 +112,7 @@ export class URLHelper {
|
||||
|
||||
safeRedirect(res: Response, to: string) {
|
||||
try {
|
||||
const finalTo = new URL(decodeURIComponent(to), this.baseUrl);
|
||||
const finalTo = new URL(decodeURIComponent(to), this.requestBaseUrl);
|
||||
|
||||
for (const host of this.redirectAllowHosts) {
|
||||
const hostURL = new URL(host);
|
||||
@@ -103,7 +128,7 @@ export class URLHelper {
|
||||
}
|
||||
|
||||
// redirect to home if the url is invalid
|
||||
return res.redirect(this.home);
|
||||
return res.redirect(this.baseUrl);
|
||||
}
|
||||
|
||||
verify(url: string | URL) {
|
||||
@@ -118,4 +143,13 @@ export class URLHelper {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private convertHostToOrigin(host: string) {
|
||||
return [
|
||||
this.config.server.https ? 'https' : 'http',
|
||||
'://',
|
||||
host,
|
||||
host === 'localhost' || isIP(host) ? `:${this.config.server.port}` : '',
|
||||
].join('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ declare global {
|
||||
externalUrl?: string;
|
||||
https: boolean;
|
||||
host: string;
|
||||
hosts: ConfigItem<string[]>;
|
||||
port: number;
|
||||
path: string;
|
||||
name?: string;
|
||||
@@ -52,6 +53,11 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi
|
||||
default: 'localhost',
|
||||
env: 'AFFINE_SERVER_HOST',
|
||||
},
|
||||
hosts: {
|
||||
desc: 'Multiple hosts the server will accept requests from.',
|
||||
default: [],
|
||||
shape: z.array(z.string()),
|
||||
},
|
||||
port: {
|
||||
desc: 'Which port the server will listen on.',
|
||||
default: 3010,
|
||||
|
||||
@@ -82,7 +82,7 @@ export class ServerConfigResolver {
|
||||
? 'AFFiNE Beta Cloud'
|
||||
: 'AFFiNE Cloud'),
|
||||
version: env.version,
|
||||
baseUrl: this.url.home,
|
||||
baseUrl: this.url.requestBaseUrl,
|
||||
type: env.DEPLOYMENT_TYPE,
|
||||
features: this.server.features,
|
||||
};
|
||||
|
||||
@@ -12,6 +12,8 @@ declare global {
|
||||
var readEnv: <T>(key: string, defaultValue: T, availableValues?: T[]) => T;
|
||||
// oxlint-disable-next-line no-var
|
||||
var CUSTOM_CONFIG_PATH: string;
|
||||
// oxlint-disable-next-line no-var
|
||||
var CLS_REQUEST_HOST: 'CLS_REQUEST_HOST';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +55,7 @@ export type AppEnv = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
globalThis.CLS_REQUEST_HOST = 'CLS_REQUEST_HOST';
|
||||
globalThis.CUSTOM_CONFIG_PATH = join(homedir(), '.affine/config');
|
||||
globalThis.readEnv = function readEnv<T>(
|
||||
env: string,
|
||||
|
||||
@@ -56,13 +56,26 @@ export class CaptchaService {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
});
|
||||
const outcome: any = await result.json();
|
||||
const outcome = (await result.json()) as {
|
||||
success: boolean;
|
||||
hostname: string;
|
||||
};
|
||||
|
||||
return (
|
||||
!!outcome.success &&
|
||||
// skip hostname check in dev mode
|
||||
(env.dev || outcome.hostname === this.config.server.host)
|
||||
if (!outcome.success) return false;
|
||||
|
||||
// skip hostname check in dev mode
|
||||
if (env.dev) return true;
|
||||
|
||||
// check if the hostname is in the hosts
|
||||
if (this.config.server.hosts.includes(outcome.hostname)) return true;
|
||||
|
||||
// check if the hostname is in the host
|
||||
if (this.config.server.host === outcome.hostname) return true;
|
||||
|
||||
this.logger.warn(
|
||||
`Captcha verification failed for hostname: ${outcome.hostname}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
private async verifyChallengeResponse(response: any, resource: string) {
|
||||
|
||||
@@ -142,7 +142,7 @@ export class OAuthController {
|
||||
provider: rawState.provider,
|
||||
})
|
||||
);
|
||||
clientUrl.searchParams.set('server', this.url.origin);
|
||||
clientUrl.searchParams.set('server', this.url.requestOrigin);
|
||||
|
||||
return res.redirect(
|
||||
this.url.link('/open-app/url?', {
|
||||
|
||||
@@ -56,7 +56,7 @@ export class WorkerController {
|
||||
this.logger.error('Invalid Origin', 'ERROR', { origin, referer });
|
||||
throw new BadRequest('Invalid header');
|
||||
}
|
||||
const url = new URL(req.url, this.url.baseUrl);
|
||||
const url = new URL(req.url, this.url.requestBaseUrl);
|
||||
const imageURL = url.searchParams.get('url');
|
||||
if (!imageURL) {
|
||||
throw new BadRequest('Missing "url" parameter');
|
||||
|
||||
@@ -5,7 +5,7 @@ import { fixUrl, OriginRules } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class WorkerService {
|
||||
allowedOrigins: OriginRules = [this.url.origin];
|
||||
allowedOrigins: OriginRules = [...this.url.allowedOrigins];
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
@@ -18,7 +18,7 @@ export class WorkerService {
|
||||
...this.config.worker.allowedOrigin
|
||||
.map(u => fixUrl(u)?.origin as string)
|
||||
.filter(v => !!v),
|
||||
this.url.origin,
|
||||
...this.url.allowedOrigins,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -66,5 +66,5 @@ export async function run() {
|
||||
|
||||
logger.log(`AFFiNE Server is running in [${env.DEPLOYMENT_TYPE}] mode`);
|
||||
logger.log(`Listening on http://${listeningHost}:${config.server.port}`);
|
||||
logger.log(`And the public server should be recognized as ${url.home}`);
|
||||
logger.log(`And the public server should be recognized as ${url.baseUrl}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user