mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +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:
@@ -540,6 +540,11 @@
|
|||||||
"description": "Where the server get deployed(FQDN).\n@default \"localhost\"\n@environment `AFFINE_SERVER_HOST`",
|
"description": "Where the server get deployed(FQDN).\n@default \"localhost\"\n@environment `AFFINE_SERVER_HOST`",
|
||||||
"default": "localhost"
|
"default": "localhost"
|
||||||
},
|
},
|
||||||
|
"hosts": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Multiple hosts the server will accept requests from.\n@default []",
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
"port": {
|
"port": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`",
|
"description": "Which port the server will listen on.\n@default 3010\n@environment `AFFINE_SERVER_PORT`",
|
||||||
|
|||||||
15
.github/actions/deploy/deploy.mjs
vendored
15
.github/actions/deploy/deploy.mjs
vendored
@@ -126,7 +126,10 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
? 'internal'
|
? 'internal'
|
||||||
: 'dev';
|
: 'dev';
|
||||||
|
|
||||||
const host = DEPLOY_HOST || CANARY_DEPLOY_HOST;
|
const hosts = (DEPLOY_HOST || CANARY_DEPLOY_HOST)
|
||||||
|
.split(',')
|
||||||
|
.map(host => host.trim())
|
||||||
|
.filter(host => host);
|
||||||
const deployCommand = [
|
const deployCommand = [
|
||||||
`helm upgrade --install affine .github/helm/affine`,
|
`helm upgrade --install affine .github/helm/affine`,
|
||||||
`--namespace ${namespace}`,
|
`--namespace ${namespace}`,
|
||||||
@@ -135,7 +138,9 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
`--set-string global.app.buildType="${buildType}"`,
|
`--set-string global.app.buildType="${buildType}"`,
|
||||||
`--set global.ingress.enabled=true`,
|
`--set global.ingress.enabled=true`,
|
||||||
`--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}\\" }"`,
|
`--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}\\" }"`,
|
||||||
`--set-string global.ingress.host="${host}"`,
|
...hosts.map(
|
||||||
|
(host, index) => `--set global.ingress.hosts[${index}]=${host}`
|
||||||
|
),
|
||||||
`--set-string global.version="${APP_VERSION}"`,
|
`--set-string global.version="${APP_VERSION}"`,
|
||||||
...redisAndPostgres,
|
...redisAndPostgres,
|
||||||
...indexerOptions,
|
...indexerOptions,
|
||||||
@@ -143,14 +148,14 @@ const createHelmCommand = ({ isDryRun }) => {
|
|||||||
`--set-string web.image.tag="${imageTag}"`,
|
`--set-string web.image.tag="${imageTag}"`,
|
||||||
`--set graphql.replicaCount=${replica.graphql}`,
|
`--set graphql.replicaCount=${replica.graphql}`,
|
||||||
`--set-string graphql.image.tag="${imageTag}"`,
|
`--set-string graphql.image.tag="${imageTag}"`,
|
||||||
`--set graphql.app.host=${host}`,
|
`--set graphql.app.host=${hosts[0]}`,
|
||||||
`--set sync.replicaCount=${replica.sync}`,
|
`--set sync.replicaCount=${replica.sync}`,
|
||||||
`--set-string sync.image.tag="${imageTag}"`,
|
`--set-string sync.image.tag="${imageTag}"`,
|
||||||
`--set-string renderer.image.tag="${imageTag}"`,
|
`--set-string renderer.image.tag="${imageTag}"`,
|
||||||
`--set renderer.app.host=${host}`,
|
`--set renderer.app.host=${hosts[0]}`,
|
||||||
`--set renderer.replicaCount=${replica.renderer}`,
|
`--set renderer.replicaCount=${replica.renderer}`,
|
||||||
`--set-string doc.image.tag="${imageTag}"`,
|
`--set-string doc.image.tag="${imageTag}"`,
|
||||||
`--set doc.app.host=${host}`,
|
`--set doc.app.host=${hosts[0]}`,
|
||||||
`--set doc.replicaCount=${replica.doc}`,
|
`--set doc.replicaCount=${replica.doc}`,
|
||||||
...serviceAnnotations,
|
...serviceAnnotations,
|
||||||
...resources,
|
...resources,
|
||||||
|
|||||||
14
.github/helm/affine/templates/ingress.yaml
vendored
14
.github/helm/affine/templates/ingress.yaml
vendored
@@ -36,7 +36,8 @@ spec:
|
|||||||
{{- end }}
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
rules:
|
rules:
|
||||||
- host: "{{ .Values.global.ingress.host }}"
|
{{- range .Values.global.ingress.hosts }}
|
||||||
|
- host: {{ . | quote }}
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
- path: /socket.io
|
- path: /socket.io
|
||||||
@@ -45,33 +46,34 @@ spec:
|
|||||||
service:
|
service:
|
||||||
name: affine-sync
|
name: affine-sync
|
||||||
port:
|
port:
|
||||||
number: {{ .Values.sync.service.port }}
|
number: {{ $.Values.sync.service.port }}
|
||||||
- path: /graphql
|
- path: /graphql
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: affine-graphql
|
name: affine-graphql
|
||||||
port:
|
port:
|
||||||
number: {{ .Values.graphql.service.port }}
|
number: {{ $.Values.graphql.service.port }}
|
||||||
- path: /api
|
- path: /api
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: affine-graphql
|
name: affine-graphql
|
||||||
port:
|
port:
|
||||||
number: {{ .Values.graphql.service.port }}
|
number: {{ $.Values.graphql.service.port }}
|
||||||
- path: /workspace
|
- path: /workspace
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: affine-renderer
|
name: affine-renderer
|
||||||
port:
|
port:
|
||||||
number: {{ .Values.renderer.service.port }}
|
number: {{ $.Values.renderer.service.port }}
|
||||||
- path: /
|
- path: /
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
service:
|
service:
|
||||||
name: affine-web
|
name: affine-web
|
||||||
port:
|
port:
|
||||||
number: {{ .Values.web.service.port }}
|
number: {{ $.Values.web.service.port }}
|
||||||
|
{{- end }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
8
.github/helm/affine/values.yaml
vendored
8
.github/helm/affine/values.yaml
vendored
@@ -4,7 +4,13 @@ global:
|
|||||||
ingress:
|
ingress:
|
||||||
enabled: false
|
enabled: false
|
||||||
className: ''
|
className: ''
|
||||||
host: affine.pro
|
# hosts for ingress rules
|
||||||
|
# e.g.
|
||||||
|
# hosts:
|
||||||
|
# - affine.pro
|
||||||
|
# - www.affine.pro
|
||||||
|
hosts:
|
||||||
|
- affine.pro
|
||||||
tls: []
|
tls: []
|
||||||
secret:
|
secret:
|
||||||
secretName: 'server-private-key'
|
secretName: 'server-private-key'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ const AFFINE_DOMAINS = [
|
|||||||
'insider.affine.pro', // Beta/internal cloud domain
|
'insider.affine.pro', // Beta/internal cloud domain
|
||||||
'affine.fail', // Canary cloud domain
|
'affine.fail', // Canary cloud domain
|
||||||
'toeverything.app', // Safety measure for potential future use
|
'toeverything.app', // Safety measure for potential future use
|
||||||
|
'apple.getaffineapp.com', // Cloud domain for Apple app
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ test.before(async t => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
hosts: ['localhost', 'test.affine.dev'],
|
||||||
|
https: true,
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
AppModule,
|
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 => {
|
test('should be able to redirect to oauth provider with client_nonce', async t => {
|
||||||
const { app } = t.context;
|
const { app } = t.context;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { ClsModule } from 'nestjs-cls';
|
|||||||
|
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import {
|
import {
|
||||||
|
getRequestFromHost,
|
||||||
getRequestIdFromHost,
|
getRequestIdFromHost,
|
||||||
getRequestIdFromRequest,
|
getRequestIdFromRequest,
|
||||||
ScannerModule,
|
ScannerModule,
|
||||||
@@ -66,8 +67,9 @@ export const FunctionalityModules = [
|
|||||||
// make every request has a unique id to tracing
|
// make every request has a unique id to tracing
|
||||||
return getRequestIdFromRequest(req, 'http');
|
return getRequestIdFromRequest(req, 'http');
|
||||||
},
|
},
|
||||||
setup(cls, _req, res: Response) {
|
setup(cls, req: Request, res: Response) {
|
||||||
res.setHeader('X-Request-Id', cls.getId());
|
res.setHeader('X-Request-Id', cls.getId());
|
||||||
|
cls.set(CLS_REQUEST_HOST, req.hostname);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// for websocket connection
|
// for websocket connection
|
||||||
@@ -79,6 +81,10 @@ export const FunctionalityModules = [
|
|||||||
// make every request has a unique id to tracing
|
// make every request has a unique id to tracing
|
||||||
return getRequestIdFromHost(context);
|
return getRequestIdFromHost(context);
|
||||||
},
|
},
|
||||||
|
setup(cls, context: ExecutionContext) {
|
||||||
|
const req = getRequestFromHost(context);
|
||||||
|
cls.set(CLS_REQUEST_HOST, req.hostname);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
// https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter
|
// https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional/prisma-adapter
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ test.beforeEach(async t => {
|
|||||||
server: {
|
server: {
|
||||||
externalUrl: '',
|
externalUrl: '',
|
||||||
host: 'app.affine.local',
|
host: 'app.affine.local',
|
||||||
|
hosts: [],
|
||||||
port: 3010,
|
port: 3010,
|
||||||
https: true,
|
https: true,
|
||||||
path: '',
|
path: '',
|
||||||
@@ -28,6 +29,7 @@ test('can factor base url correctly with specified external url', t => {
|
|||||||
server: {
|
server: {
|
||||||
externalUrl: 'https://external.domain.com',
|
externalUrl: 'https://external.domain.com',
|
||||||
host: 'app.affine.local',
|
host: 'app.affine.local',
|
||||||
|
hosts: [],
|
||||||
port: 3010,
|
port: 3010,
|
||||||
https: true,
|
https: true,
|
||||||
path: '/ignored',
|
path: '/ignored',
|
||||||
@@ -42,6 +44,7 @@ test('can factor base url correctly with specified external url and path', t =>
|
|||||||
server: {
|
server: {
|
||||||
externalUrl: 'https://external.domain.com/anything',
|
externalUrl: 'https://external.domain.com/anything',
|
||||||
host: 'app.affine.local',
|
host: 'app.affine.local',
|
||||||
|
hosts: [],
|
||||||
port: 3010,
|
port: 3010,
|
||||||
https: true,
|
https: true,
|
||||||
path: '/ignored',
|
path: '/ignored',
|
||||||
@@ -56,6 +59,7 @@ test('can factor base url correctly with specified external url with port', t =>
|
|||||||
server: {
|
server: {
|
||||||
externalUrl: 'https://external.domain.com:123',
|
externalUrl: 'https://external.domain.com:123',
|
||||||
host: 'app.affine.local',
|
host: 'app.affine.local',
|
||||||
|
hosts: [],
|
||||||
port: 3010,
|
port: 3010,
|
||||||
https: true,
|
https: true,
|
||||||
},
|
},
|
||||||
@@ -95,7 +99,7 @@ test('can safe redirect', t => {
|
|||||||
|
|
||||||
function deny(to: string) {
|
function deny(to: string) {
|
||||||
t.context.url.safeRedirect(res, to);
|
t.context.url.safeRedirect(res, to);
|
||||||
t.true(spy.calledOnceWith(t.context.url.home));
|
t.true(spy.calledOnceWith(t.context.url.baseUrl));
|
||||||
spy.resetHistory();
|
spy.resetHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,3 +110,38 @@ test('can safe redirect', t => {
|
|||||||
].forEach(allow);
|
].forEach(allow);
|
||||||
['https://other.domain.com', 'a://invalid.uri'].forEach(deny);
|
['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 { Injectable } from '@nestjs/common';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import { ClsService } from 'nestjs-cls';
|
||||||
|
|
||||||
import { Config } from '../config';
|
import { Config } from '../config';
|
||||||
import { OnEvent } from '../event';
|
import { OnEvent } from '../event';
|
||||||
@@ -11,10 +12,13 @@ export class URLHelper {
|
|||||||
redirectAllowHosts!: string[];
|
redirectAllowHosts!: string[];
|
||||||
|
|
||||||
origin!: string;
|
origin!: string;
|
||||||
|
allowedOrigins!: string[];
|
||||||
baseUrl!: string;
|
baseUrl!: string;
|
||||||
home!: string;
|
|
||||||
|
|
||||||
constructor(private readonly config: Config) {
|
constructor(
|
||||||
|
private readonly config: Config,
|
||||||
|
private readonly cls?: ClsService
|
||||||
|
) {
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,19 +38,40 @@ export class URLHelper {
|
|||||||
this.baseUrl =
|
this.baseUrl =
|
||||||
externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
|
externalUrl.origin + externalUrl.pathname.replace(/\/$/, '');
|
||||||
} else {
|
} else {
|
||||||
this.origin = [
|
this.origin = this.convertHostToOrigin(this.config.server.host);
|
||||||
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.baseUrl = this.origin + this.config.server.path;
|
this.baseUrl = this.origin + this.config.server.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.home = this.baseUrl;
|
|
||||||
this.redirectAllowHosts = [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>) {
|
stringify(query: Record<string, any>) {
|
||||||
@@ -72,7 +97,7 @@ export class URLHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
url(path: string, query: Record<string, any> = {}) {
|
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) {
|
for (const key in query) {
|
||||||
url.searchParams.set(key, query[key]);
|
url.searchParams.set(key, query[key]);
|
||||||
@@ -87,7 +112,7 @@ export class URLHelper {
|
|||||||
|
|
||||||
safeRedirect(res: Response, to: string) {
|
safeRedirect(res: Response, to: string) {
|
||||||
try {
|
try {
|
||||||
const finalTo = new URL(decodeURIComponent(to), this.baseUrl);
|
const finalTo = new URL(decodeURIComponent(to), this.requestBaseUrl);
|
||||||
|
|
||||||
for (const host of this.redirectAllowHosts) {
|
for (const host of this.redirectAllowHosts) {
|
||||||
const hostURL = new URL(host);
|
const hostURL = new URL(host);
|
||||||
@@ -103,7 +128,7 @@ export class URLHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// redirect to home if the url is invalid
|
// redirect to home if the url is invalid
|
||||||
return res.redirect(this.home);
|
return res.redirect(this.baseUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
verify(url: string | URL) {
|
verify(url: string | URL) {
|
||||||
@@ -118,4 +143,13 @@ export class URLHelper {
|
|||||||
return false;
|
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;
|
externalUrl?: string;
|
||||||
https: boolean;
|
https: boolean;
|
||||||
host: string;
|
host: string;
|
||||||
|
hosts: ConfigItem<string[]>;
|
||||||
port: number;
|
port: number;
|
||||||
path: string;
|
path: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -52,6 +53,11 @@ Default to be \`[server.protocol]://[server.host][:server.port]\` if not specifi
|
|||||||
default: 'localhost',
|
default: 'localhost',
|
||||||
env: 'AFFINE_SERVER_HOST',
|
env: 'AFFINE_SERVER_HOST',
|
||||||
},
|
},
|
||||||
|
hosts: {
|
||||||
|
desc: 'Multiple hosts the server will accept requests from.',
|
||||||
|
default: [],
|
||||||
|
shape: z.array(z.string()),
|
||||||
|
},
|
||||||
port: {
|
port: {
|
||||||
desc: 'Which port the server will listen on.',
|
desc: 'Which port the server will listen on.',
|
||||||
default: 3010,
|
default: 3010,
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class ServerConfigResolver {
|
|||||||
? 'AFFiNE Beta Cloud'
|
? 'AFFiNE Beta Cloud'
|
||||||
: 'AFFiNE Cloud'),
|
: 'AFFiNE Cloud'),
|
||||||
version: env.version,
|
version: env.version,
|
||||||
baseUrl: this.url.home,
|
baseUrl: this.url.requestBaseUrl,
|
||||||
type: env.DEPLOYMENT_TYPE,
|
type: env.DEPLOYMENT_TYPE,
|
||||||
features: this.server.features,
|
features: this.server.features,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ declare global {
|
|||||||
var readEnv: <T>(key: string, defaultValue: T, availableValues?: T[]) => T;
|
var readEnv: <T>(key: string, defaultValue: T, availableValues?: T[]) => T;
|
||||||
// oxlint-disable-next-line no-var
|
// oxlint-disable-next-line no-var
|
||||||
var CUSTOM_CONFIG_PATH: string;
|
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;
|
version: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
globalThis.CLS_REQUEST_HOST = 'CLS_REQUEST_HOST';
|
||||||
globalThis.CUSTOM_CONFIG_PATH = join(homedir(), '.affine/config');
|
globalThis.CUSTOM_CONFIG_PATH = join(homedir(), '.affine/config');
|
||||||
globalThis.readEnv = function readEnv<T>(
|
globalThis.readEnv = function readEnv<T>(
|
||||||
env: string,
|
env: string,
|
||||||
|
|||||||
@@ -56,13 +56,26 @@ export class CaptchaService {
|
|||||||
body: formData,
|
body: formData,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
const outcome: any = await result.json();
|
const outcome = (await result.json()) as {
|
||||||
|
success: boolean;
|
||||||
|
hostname: string;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
if (!outcome.success) return false;
|
||||||
!!outcome.success &&
|
|
||||||
// skip hostname check in dev mode
|
// skip hostname check in dev mode
|
||||||
(env.dev || outcome.hostname === this.config.server.host)
|
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) {
|
private async verifyChallengeResponse(response: any, resource: string) {
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export class OAuthController {
|
|||||||
provider: rawState.provider,
|
provider: rawState.provider,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
clientUrl.searchParams.set('server', this.url.origin);
|
clientUrl.searchParams.set('server', this.url.requestOrigin);
|
||||||
|
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
this.url.link('/open-app/url?', {
|
this.url.link('/open-app/url?', {
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class WorkerController {
|
|||||||
this.logger.error('Invalid Origin', 'ERROR', { origin, referer });
|
this.logger.error('Invalid Origin', 'ERROR', { origin, referer });
|
||||||
throw new BadRequest('Invalid header');
|
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');
|
const imageURL = url.searchParams.get('url');
|
||||||
if (!imageURL) {
|
if (!imageURL) {
|
||||||
throw new BadRequest('Missing "url" parameter');
|
throw new BadRequest('Missing "url" parameter');
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { fixUrl, OriginRules } from './utils';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WorkerService {
|
export class WorkerService {
|
||||||
allowedOrigins: OriginRules = [this.url.origin];
|
allowedOrigins: OriginRules = [...this.url.allowedOrigins];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: Config,
|
private readonly config: Config,
|
||||||
@@ -18,7 +18,7 @@ export class WorkerService {
|
|||||||
...this.config.worker.allowedOrigin
|
...this.config.worker.allowedOrigin
|
||||||
.map(u => fixUrl(u)?.origin as string)
|
.map(u => fixUrl(u)?.origin as string)
|
||||||
.filter(v => !!v),
|
.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(`AFFiNE Server is running in [${env.DEPLOYMENT_TYPE}] mode`);
|
||||||
logger.log(`Listening on http://${listeningHost}:${config.server.port}`);
|
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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,10 @@
|
|||||||
"desc": "Where the server get deployed(FQDN).",
|
"desc": "Where the server get deployed(FQDN).",
|
||||||
"env": "AFFINE_SERVER_HOST"
|
"env": "AFFINE_SERVER_HOST"
|
||||||
},
|
},
|
||||||
|
"hosts": {
|
||||||
|
"type": "Array",
|
||||||
|
"desc": "Multiple hosts the server will accept requests from."
|
||||||
|
},
|
||||||
"port": {
|
"port": {
|
||||||
"type": "Number",
|
"type": "Number",
|
||||||
"desc": "Which port the server will listen on.",
|
"desc": "Which port the server will listen on.",
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export const KNOWN_CONFIG_GROUPS = [
|
|||||||
{
|
{
|
||||||
name: 'Server',
|
name: 'Server',
|
||||||
module: 'server',
|
module: 'server',
|
||||||
fields: ['externalUrl', 'name'],
|
fields: ['externalUrl', 'name', 'hosts'],
|
||||||
} as ConfigGroup<'server'>,
|
} as ConfigGroup<'server'>,
|
||||||
{
|
{
|
||||||
name: 'Auth',
|
name: 'Auth',
|
||||||
|
|||||||
@@ -63,7 +63,11 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'affine-cloud',
|
id: 'affine-cloud',
|
||||||
baseUrl: 'https://app.affine.pro',
|
baseUrl: BUILD_CONFIG.isNative
|
||||||
|
? BUILD_CONFIG.isIOS
|
||||||
|
? 'https://apple.getaffineapp.com'
|
||||||
|
: 'https://app.affine.pro'
|
||||||
|
: location.origin,
|
||||||
config: {
|
config: {
|
||||||
serverName: 'Affine Cloud',
|
serverName: 'Affine Cloud',
|
||||||
features: [
|
features: [
|
||||||
@@ -91,7 +95,11 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'affine-cloud',
|
id: 'affine-cloud',
|
||||||
baseUrl: 'https://insider.affine.pro',
|
baseUrl: BUILD_CONFIG.isNative
|
||||||
|
? BUILD_CONFIG.isIOS
|
||||||
|
? 'https://apple.getaffineapp.com'
|
||||||
|
: 'https://insider.affine.pro'
|
||||||
|
: location.origin,
|
||||||
config: {
|
config: {
|
||||||
serverName: 'Affine Cloud',
|
serverName: 'Affine Cloud',
|
||||||
features: [
|
features: [
|
||||||
@@ -147,7 +155,9 @@ export const BUILD_IN_SERVERS: (ServerMetadata & { config: ServerConfig })[] =
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
id: 'affine-cloud',
|
id: 'affine-cloud',
|
||||||
baseUrl: 'https://affine.fail',
|
baseUrl: BUILD_CONFIG.isNative
|
||||||
|
? 'https://affine.fail'
|
||||||
|
: location.origin,
|
||||||
config: {
|
config: {
|
||||||
serverName: 'Affine Cloud',
|
serverName: 'Affine Cloud',
|
||||||
features: [
|
features: [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ function maybeAffineOrigin(origin: string, baseUrl: string) {
|
|||||||
return (
|
return (
|
||||||
origin.startsWith('file://') ||
|
origin.startsWith('file://') ||
|
||||||
origin.endsWith('affine.pro') || // stable/beta
|
origin.endsWith('affine.pro') || // stable/beta
|
||||||
|
origin.endsWith('apple.getaffineapp.com') || // stable/beta
|
||||||
origin.endsWith('affine.fail') || // canary
|
origin.endsWith('affine.fail') || // canary
|
||||||
origin === baseUrl // localhost or self-hosted
|
origin === baseUrl // localhost or self-hosted
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user