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