Skip to content

Commit dd5c156

Browse files
committed
feat(server): support multiple hosts in one deployment
1 parent 9a1ce2b commit dd5c156

File tree

19 files changed

+202
-41
lines changed

19 files changed

+202
-41
lines changed

.docker/selfhost/schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,6 +540,11 @@
540540
"description": "Where the server get deployed(FQDN).\n@default \"localhost\"\n@environment `AFFINE_SERVER_HOST`",
541541
"default": "localhost"
542542
},
543+
"hosts": {
544+
"type": "array",
545+
"description": "Multiple hosts the server will accept requests from.\n@default []",
546+
"default": []
547+
},
543548
"port": {
544549
"type": "number",
545550
"description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`",

.github/actions/deploy/deploy.mjs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,10 @@ const createHelmCommand = ({ isDryRun }) => {
126126
? 'internal'
127127
: 'dev';
128128

129-
const host = DEPLOY_HOST || CANARY_DEPLOY_HOST;
129+
const hosts = (DEPLOY_HOST || CANARY_DEPLOY_HOST)
130+
.split(',')
131+
.map(host => host.trim())
132+
.filter(host => host);
130133
const deployCommand = [
131134
`helm upgrade --install affine .github/helm/affine`,
132135
`--namespace ${namespace}`,
@@ -135,22 +138,24 @@ const createHelmCommand = ({ isDryRun }) => {
135138
`--set-string global.app.buildType="${buildType}"`,
136139
`--set global.ingress.enabled=true`,
137140
`--set-json global.ingress.annotations="{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${STATIC_IP_NAME}\\" }"`,
138-
`--set-string global.ingress.host="${host}"`,
141+
...hosts.map(
142+
(host, index) => `--set global.ingress.hosts[${index}]=${host}`
143+
),
139144
`--set-string global.version="${APP_VERSION}"`,
140145
...redisAndPostgres,
141146
...indexerOptions,
142147
`--set web.replicaCount=${replica.web}`,
143148
`--set-string web.image.tag="${imageTag}"`,
144149
`--set graphql.replicaCount=${replica.graphql}`,
145150
`--set-string graphql.image.tag="${imageTag}"`,
146-
`--set graphql.app.host=${host}`,
151+
`--set graphql.app.host=${hosts[0]}`,
147152
`--set sync.replicaCount=${replica.sync}`,
148153
`--set-string sync.image.tag="${imageTag}"`,
149154
`--set-string renderer.image.tag="${imageTag}"`,
150-
`--set renderer.app.host=${host}`,
155+
`--set renderer.app.host=${hosts[0]}`,
151156
`--set renderer.replicaCount=${replica.renderer}`,
152157
`--set-string doc.image.tag="${imageTag}"`,
153-
`--set doc.app.host=${host}`,
158+
`--set doc.app.host=${hosts[0]}`,
154159
`--set doc.replicaCount=${replica.doc}`,
155160
...serviceAnnotations,
156161
...resources,

.github/helm/affine/templates/ingress.yaml

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ spec:
3636
{{- end }}
3737
{{- end }}
3838
rules:
39-
- host: "{{ .Values.global.ingress.host }}"
39+
{{- range .Values.global.ingress.hosts }}
40+
- host: {{ . | quote }}
4041
http:
4142
paths:
4243
- path: /socket.io
@@ -45,33 +46,34 @@ spec:
4546
service:
4647
name: affine-sync
4748
port:
48-
number: {{ .Values.sync.service.port }}
49+
number: {{ $.Values.sync.service.port }}
4950
- path: /graphql
5051
pathType: Prefix
5152
backend:
5253
service:
5354
name: affine-graphql
5455
port:
55-
number: {{ .Values.graphql.service.port }}
56+
number: {{ $.Values.graphql.service.port }}
5657
- path: /api
5758
pathType: Prefix
5859
backend:
5960
service:
6061
name: affine-graphql
6162
port:
62-
number: {{ .Values.graphql.service.port }}
63+
number: {{ $.Values.graphql.service.port }}
6364
- path: /workspace
6465
pathType: Prefix
6566
backend:
6667
service:
6768
name: affine-renderer
6869
port:
69-
number: {{ .Values.renderer.service.port }}
70+
number: {{ $.Values.renderer.service.port }}
7071
- path: /
7172
pathType: Prefix
7273
backend:
7374
service:
7475
name: affine-web
7576
port:
76-
number: {{ .Values.web.service.port }}
77+
number: {{ $.Values.web.service.port }}
78+
{{- end }}
7779
{{- end }}

.github/helm/affine/values.yaml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ global:
44
ingress:
55
enabled: false
66
className: ''
7-
host: affine.pro
7+
# hosts for ingress rules
8+
# e.g.
9+
# hosts:
10+
# - affine.pro
11+
# - www.affine.pro
12+
hosts:
13+
- affine.pro
814
tls: []
915
secret:
1016
secretName: 'server-private-key'

packages/backend/server/src/__tests__/oauth/controller.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ test.before(async t => {
3737
},
3838
},
3939
},
40+
server: {
41+
hosts: ['localhost', 'test.affine.dev'],
42+
https: true,
43+
},
4044
}),
4145
AppModule,
4246
],
@@ -90,6 +94,38 @@ test("should be able to redirect to oauth provider's login page", async t => {
9094
);
9195
});
9296

97+
test('should be able to redirect to oauth provider with multiple hosts', async t => {
98+
const { app } = t.context;
99+
100+
const res = await app
101+
.POST('/api/oauth/preflight')
102+
.set('host', 'test.affine.dev')
103+
.send({ provider: 'Google' })
104+
.expect(HttpStatus.OK);
105+
106+
const { url } = res.body;
107+
108+
const redirect = new URL(url);
109+
t.is(redirect.origin, 'https://accounts.google.com');
110+
111+
t.is(redirect.pathname, '/o/oauth2/v2/auth');
112+
t.is(redirect.searchParams.get('client_id'), 'google-client-id');
113+
t.is(
114+
redirect.searchParams.get('redirect_uri'),
115+
'https://test.affine.dev/oauth/callback'
116+
);
117+
t.is(redirect.searchParams.get('response_type'), 'code');
118+
t.is(redirect.searchParams.get('prompt'), 'select_account');
119+
t.truthy(redirect.searchParams.get('state'));
120+
// state should be a json string
121+
const state = JSON.parse(redirect.searchParams.get('state')!);
122+
t.is(state.provider, 'Google');
123+
t.regex(
124+
state.state,
125+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
126+
);
127+
});
128+
93129
test('should be able to redirect to oauth provider with client_nonce', async t => {
94130
const { app } = t.context;
95131

packages/backend/server/src/app.module.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { ClsModule } from 'nestjs-cls';
88

99
import { AppController } from './app.controller';
1010
import {
11+
getRequestFromHost,
1112
getRequestIdFromHost,
1213
getRequestIdFromRequest,
1314
ScannerModule,
@@ -66,8 +67,9 @@ export const FunctionalityModules = [
6667
// make every request has a unique id to tracing
6768
return getRequestIdFromRequest(req, 'http');
6869
},
69-
setup(cls, _req, res: Response) {
70+
setup(cls, req: Request, res: Response) {
7071
res.setHeader('X-Request-Id', cls.getId());
72+
cls.set(CLS_REQUEST_HOST, req.hostname);
7173
},
7274
},
7375
// for websocket connection
@@ -79,6 +81,10 @@ export const FunctionalityModules = [
7981
// make every request has a unique id to tracing
8082
return getRequestIdFromHost(context);
8183
},
84+
setup(cls, context: ExecutionContext) {
85+
const req = getRequestFromHost(context);
86+
cls.set(CLS_REQUEST_HOST, req.hostname);
87+
},
8288
},
8389
plugins: [
8490
// https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter

packages/backend/server/src/base/helpers/__tests__/url.spec.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ test.beforeEach(async t => {
1212
server: {
1313
externalUrl: '',
1414
host: 'app.affine.local',
15+
hosts: [],
1516
port: 3010,
1617
https: true,
1718
path: '',
@@ -28,6 +29,7 @@ test('can factor base url correctly with specified external url', t => {
2829
server: {
2930
externalUrl: 'https://external.domain.com',
3031
host: 'app.affine.local',
32+
hosts: [],
3133
port: 3010,
3234
https: true,
3335
path: '/ignored',
@@ -42,6 +44,7 @@ test('can factor base url correctly with specified external url and path', t =>
4244
server: {
4345
externalUrl: 'https://external.domain.com/anything',
4446
host: 'app.affine.local',
47+
hosts: [],
4548
port: 3010,
4649
https: true,
4750
path: '/ignored',
@@ -56,6 +59,7 @@ test('can factor base url correctly with specified external url with port', t =>
5659
server: {
5760
externalUrl: 'https://external.domain.com:123',
5861
host: 'app.affine.local',
62+
hosts: [],
5963
port: 3010,
6064
https: true,
6165
},
@@ -95,7 +99,7 @@ test('can safe redirect', t => {
9599

96100
function deny(to: string) {
97101
t.context.url.safeRedirect(res, to);
98-
t.true(spy.calledOnceWith(t.context.url.home));
102+
t.true(spy.calledOnceWith(t.context.url.baseUrl));
99103
spy.resetHistory();
100104
}
101105

@@ -106,3 +110,38 @@ test('can safe redirect', t => {
106110
].forEach(allow);
107111
['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
108112
});
113+
114+
test('can get request origin', t => {
115+
t.is(t.context.url.requestOrigin, 'https://app.affine.local');
116+
});
117+
118+
test('can get request base url', t => {
119+
t.is(t.context.url.requestBaseUrl, 'https://app.affine.local');
120+
});
121+
122+
test('can get request base url with multiple hosts', t => {
123+
// mock cls
124+
const cls = new Map<string, string>();
125+
const url = new URLHelper(
126+
{
127+
server: {
128+
externalUrl: '',
129+
host: 'app.affine.local1',
130+
hosts: ['app.affine.local1', 'app.affine.local2'],
131+
port: 3010,
132+
https: true,
133+
path: '',
134+
},
135+
} as any,
136+
cls as any
137+
);
138+
139+
// no cls, use default origin
140+
t.is(url.requestOrigin, 'https://app.affine.local1');
141+
t.is(url.requestBaseUrl, 'https://app.affine.local1');
142+
143+
// set cls
144+
cls.set(CLS_REQUEST_HOST, 'app.affine.local2');
145+
t.is(url.requestOrigin, 'https://app.affine.local2');
146+
t.is(url.requestBaseUrl, 'https://app.affine.local2');
147+
});

packages/backend/server/src/base/helpers/url.ts

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { isIP } from 'node:net';
22

33
import { Injectable } from '@nestjs/common';
44
import type { Response } from 'express';
5+
import { ClsService } from 'nestjs-cls';
56

67
import { Config } from '../config';
78
import { OnEvent } from '../event';
@@ -11,10 +12,13 @@ export class URLHelper {
1112
redirectAllowHosts!: string[];
1213

1314
origin!: string;
15+
allowedOrigins!: string[];
1416
baseUrl!: string;
15-
home!: string;
1617

17-
constructor(private readonly config: Config) {
18+
constructor(
19+
private readonly config: Config,
20+
private readonly cls?: ClsService
21+
) {
1822
this.init();
1923
}
2024

@@ -34,19 +38,40 @@ export class URLHelper {
3438
this.baseUrl =
3539
externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
3640
} else {
37-
this.origin = [
38-
this.config.server.https ? 'https' : 'http',
39-
'://',
40-
this.config.server.host,
41-
this.config.server.host === 'localhost' || isIP(this.config.server.host)
42-
? `:${this.config.server.port}`
43-
: '',
44-
].join('');
41+
this.origin = this.convertHostToOrigin(this.config.server.host);
4542
this.baseUrl = this.origin + this.config.server.path;
4643
}
4744

48-
this.home = this.baseUrl;
4945
this.redirectAllowHosts = [this.baseUrl];
46+
47+
this.allowedOrigins = [this.origin];
48+
if (this.config.server.hosts.length > 0) {
49+
for (const host of this.config.server.hosts) {
50+
this.allowedOrigins.push(this.convertHostToOrigin(host));
51+
}
52+
}
53+
}
54+
55+
get requestOrigin() {
56+
if (this.config.server.hosts.length === 0) {
57+
return this.origin;
58+
}
59+
60+
// support multiple hosts
61+
const requestHost = this.cls?.get<string | undefined>(CLS_REQUEST_HOST);
62+
if (!requestHost || !this.config.server.hosts.includes(requestHost)) {
63+
return this.origin;
64+
}
65+
66+
return this.convertHostToOrigin(requestHost);
67+
}
68+
69+
get requestBaseUrl() {
70+
if (this.config.server.hosts.length === 0) {
71+
return this.baseUrl;
72+
}
73+
74+
return this.requestOrigin + this.config.server.path;
5075
}
5176

5277
stringify(query: Record<string, any>) {
@@ -72,7 +97,7 @@ export class URLHelper {
7297
}
7398

7499
url(path: string, query: Record<string, any> = {}) {
75-
const url = new URL(path, this.origin);
100+
const url = new URL(path, this.requestOrigin);
76101

77102
for (const key in query) {
78103
url.searchParams.set(key, query[key]);
@@ -87,7 +112,7 @@ export class URLHelper {
87112

88113
safeRedirect(res: Response, to: string) {
89114
try {
90-
const finalTo = new URL(decodeURIComponent(to), this.baseUrl);
115+
const finalTo = new URL(decodeURIComponent(to), this.requestBaseUrl);
91116

92117
for (const host of this.redirectAllowHosts) {
93118
const hostURL = new URL(host);
@@ -103,7 +128,7 @@ export class URLHelper {
103128
}
104129

105130
// redirect to home if the url is invalid
106-
return res.redirect(this.home);
131+
return res.redirect(this.baseUrl);
107132
}
108133

109134
verify(url: string | URL) {
@@ -118,4 +143,13 @@ export class URLHelper {
118143
return false;
119144
}
120145
}
146+
147+
private convertHostToOrigin(host: string) {
148+
return [
149+
this.config.server.https ? 'https' : 'http',
150+
'://',
151+
host,
152+
host === 'localhost' || isIP(host) ? `:${this.config.server.port}` : '',
153+
].join('');
154+
}
121155
}

0 commit comments

Comments
 (0)