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:
fengmk2
2025-06-27 17:41:37 +08:00
committed by GitHub
parent 5c45c66ce8
commit dc55518c5b
21 changed files with 214 additions and 43 deletions

View File

@@ -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;

View File

@@ -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

View File

@@ -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');
});

View File

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

View File

@@ -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,

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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?', {

View File

@@ -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');

View File

@@ -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,
];
}

View File

@@ -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}`);
}