From 91c586905337f97870f09cc1729a5fcc0e610990 Mon Sep 17 00:00:00 2001 From: DarkSky <25152247+darkskygit@users.noreply.github.com> Date: Mon, 23 Feb 2026 21:23:01 +0800 Subject: [PATCH] feat: improve selfhosted login (#14502) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix #13397 fix #14011 #### PR Dependency Tree * **PR #14502** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) ## Summary by CodeRabbit * **New Features** * Centralized CORS policy with dynamic origin validation applied to server and realtime connections * Improved sign-in flows with contextual, localized error hints and toast notifications * Centralized network-error normalization and conditional OAuth provider fetching * **Bug Fixes** * Better feedback for self-hosted connection failures and clearer authentication error handling * More robust handling of network-related failures with user-friendly messages --- packages/backend/server/src/base/cors.ts | 99 +++++++++++++++++++ packages/backend/server/src/base/index.ts | 1 + .../server/src/base/websocket/adapter.ts | 33 ++++++- packages/backend/server/src/server.ts | 31 +++++- .../src/components/sign-in/add-selfhosted.tsx | 51 +++++++--- .../sign-in/sign-in-with-password.tsx | 35 ++++++- .../src/modules/cloud/stores/server-config.ts | 85 ++++++++++++---- 7 files changed, 294 insertions(+), 41 deletions(-) create mode 100644 packages/backend/server/src/base/cors.ts diff --git a/packages/backend/server/src/base/cors.ts b/packages/backend/server/src/base/cors.ts new file mode 100644 index 000000000..29e84351d --- /dev/null +++ b/packages/backend/server/src/base/cors.ts @@ -0,0 +1,99 @@ +import { URLHelper } from './helpers'; + +const DEV_LOOPBACK_PROTOCOLS = new Set(['http:', 'https:']); +const DEV_LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); +const MOBILE_CLIENT_ORIGINS = new Set([ + 'https://localhost', + 'capacitor://localhost', + 'ionic://localhost', +]); +const DESKTOP_CLIENT_ORIGINS = new Set(['assets://.', 'assets://another-host']); + +export const CORS_ALLOWED_METHODS = [ + 'GET', + 'HEAD', + 'PUT', + 'PATCH', + 'POST', + 'DELETE', + 'OPTIONS', +]; + +export const CORS_ALLOWED_HEADERS = [ + 'accept', + 'authorization', + 'content-type', + 'x-affine-version', + 'x-operation-name', + 'x-request-id', + 'x-captcha-token', + 'x-captcha-challenge', + 'x-affine-csrf-token', + 'x-requested-with', + 'range', +]; + +export const CORS_EXPOSED_HEADERS = [ + 'content-length', + 'content-range', + 'x-request-id', +]; + +function normalizeHostname(hostname: string) { + return hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, ''); +} + +function isDevLoopbackOrigin(origin: string) { + try { + const parsed = new URL(origin); + return ( + DEV_LOOPBACK_PROTOCOLS.has(parsed.protocol) && + DEV_LOOPBACK_HOSTS.has(normalizeHostname(parsed.hostname)) + ); + } catch { + return false; + } +} + +export function buildCorsAllowedOrigins(url: URLHelper) { + return new Set([ + ...url.allowedOrigins, + ...MOBILE_CLIENT_ORIGINS, + ...DESKTOP_CLIENT_ORIGINS, + ]); +} + +export function isCorsOriginAllowed( + origin: string | undefined | null, + allowedOrigins: Set +) { + if (!origin) { + return true; + } + + if (allowedOrigins.has(origin)) { + return true; + } + + if ((env.dev || env.testing) && isDevLoopbackOrigin(origin)) { + return true; + } + + return false; +} + +export function corsOriginCallback( + origin: string | undefined, + allowedOrigins: Set, + onBlocked: (origin: string) => void, + callback: (error: Error | null, allow?: boolean) => void +) { + if (isCorsOriginAllowed(origin, allowedOrigins)) { + callback(null, true); + return; + } + + const blockedOrigin = origin ?? ''; + onBlocked(blockedOrigin); + callback(null, false); +} diff --git a/packages/backend/server/src/base/index.ts b/packages/backend/server/src/base/index.ts index 9a92b4b27..2af9f383f 100644 --- a/packages/backend/server/src/base/index.ts +++ b/packages/backend/server/src/base/index.ts @@ -11,6 +11,7 @@ export { defineModuleConfig, type JSONSchema, } from './config'; +export * from './cors'; export * from './error'; export { EventBus, OnEvent } from './event'; export { diff --git a/packages/backend/server/src/base/websocket/adapter.ts b/packages/backend/server/src/base/websocket/adapter.ts index 064d45d3b..fed236967 100644 --- a/packages/backend/server/src/base/websocket/adapter.ts +++ b/packages/backend/server/src/base/websocket/adapter.ts @@ -4,7 +4,15 @@ import { createAdapter } from '@socket.io/redis-adapter'; import { Server, Socket } from 'socket.io'; import { Config } from '../config'; +import { + buildCorsAllowedOrigins, + CORS_ALLOWED_HEADERS, + CORS_ALLOWED_METHODS, + corsOriginCallback, +} from '../cors'; import { AuthenticationRequired } from '../error'; +import { URLHelper } from '../helpers'; +import { AFFiNELogger } from '../logger'; import { SocketIoRedis } from '../redis'; import { WEBSOCKET_OPTIONS } from './options'; @@ -14,17 +22,34 @@ export class SocketIoAdapter extends IoAdapter { } override createIOServer(port: number, options?: any): Server { + const logger = this.app.get(AFFiNELogger); const config = this.app.get(WEBSOCKET_OPTIONS) as Config['websocket'] & { canActivate: (socket: Socket) => Promise; }; + const url = this.app.get(URLHelper); + const allowedOrigins = buildCorsAllowedOrigins(url); + const server: Server = super.createIOServer(port, { ...config, ...options, - // Enable CORS for Socket.IO cors: { - origin: true, // Allow all origins - credentials: true, // Allow credentials (cookies, auth headers) - methods: ['GET', 'POST'], + origin: ( + origin: string | undefined, + callback: (error: Error | null, allow?: boolean) => void + ) => { + corsOriginCallback( + origin, + allowedOrigins, + blockedOrigin => + logger.warn( + `Blocked WebSocket CORS request from origin: ${blockedOrigin}` + ), + callback + ); + }, + credentials: true, + methods: CORS_ALLOWED_METHODS, + allowedHeaders: CORS_ALLOWED_HEADERS, }, }); diff --git a/packages/backend/server/src/server.ts b/packages/backend/server/src/server.ts index 558be7881..e1e29bc7a 100644 --- a/packages/backend/server/src/server.ts +++ b/packages/backend/server/src/server.ts @@ -5,9 +5,14 @@ import graphqlUploadExpress from 'graphql-upload/graphqlUploadExpress.mjs'; import { AFFiNELogger, + buildCorsAllowedOrigins, CacheInterceptor, CloudThrottlerGuard, Config, + CORS_ALLOWED_HEADERS, + CORS_ALLOWED_METHODS, + CORS_EXPOSED_HEADERS, + corsOriginCallback, GlobalExceptionFilter, URLHelper, } from './base'; @@ -16,12 +21,11 @@ import { AuthGuard } from './core/auth'; import { serverTimingAndCache } from './middleware/timing'; const OneMB = 1024 * 1024; - export async function run() { const { AppModule } = await import('./app.module'); const app = await NestFactory.create(AppModule, { - cors: true, + cors: false, rawBody: true, bodyParser: true, bufferLogs: true, @@ -32,6 +36,27 @@ export async function run() { const logger = app.get(AFFiNELogger); app.useLogger(logger); const config = app.get(Config); + const url = app.get(URLHelper); + + const allowedOrigins = buildCorsAllowedOrigins(url); + + app.enableCors({ + origin: (origin, callback) => { + corsOriginCallback( + origin, + allowedOrigins, + blockedOrigin => + logger.warn(`Blocked CORS request from origin: ${blockedOrigin}`), + callback + ); + }, + credentials: true, + methods: CORS_ALLOWED_METHODS, + allowedHeaders: CORS_ALLOWED_HEADERS, + exposedHeaders: CORS_EXPOSED_HEADERS, + maxAge: 86400, + optionsSuccessStatus: 204, + }); if (config.server.path) { app.setGlobalPrefix(config.server.path); @@ -74,8 +99,6 @@ export async function run() { }); } - const url = app.get(URLHelper); - await app.listen(config.server.port, config.server.listenAddr); const formattedAddr = config.server.listenAddr.includes(':') diff --git a/packages/frontend/core/src/components/sign-in/add-selfhosted.tsx b/packages/frontend/core/src/components/sign-in/add-selfhosted.tsx index d774b2848..c2da2ec27 100644 --- a/packages/frontend/core/src/components/sign-in/add-selfhosted.tsx +++ b/packages/frontend/core/src/components/sign-in/add-selfhosted.tsx @@ -1,4 +1,4 @@ -import { Button } from '@affine/component'; +import { Button, notify } from '@affine/component'; import { AuthContainer, AuthContent, @@ -8,6 +8,7 @@ import { } from '@affine/component/auth-components'; import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks'; import { ServersService } from '@affine/core/modules/cloud'; +import { UserFriendlyError } from '@affine/error'; import { Trans, useI18n } from '@affine/i18n'; import { useService } from '@toeverything/infra'; import { @@ -35,12 +36,14 @@ export const AddSelfhostedStep = ({ state: SignInState; changeState: Dispatch>; }) => { + const t = useI18n(); const serversService = useService(ServersService); const [baseURL, setBaseURL] = useState(state.initialServerBaseUrl ?? ''); const [isConnecting, setIsConnecting] = useState(false); const [error, setError] = useState(false); - - const t = useI18n(); + const [errorHint, setErrorHint] = useState( + t['com.affine.auth.sign.add-selfhosted.error']() + ); const urlValid = useMemo(() => { try { @@ -51,10 +54,14 @@ export const AddSelfhostedStep = ({ } }, [baseURL]); - const onBaseURLChange = useCallback((value: string) => { - setBaseURL(value); - setError(false); - }, []); + const onBaseURLChange = useCallback( + (value: string) => { + setBaseURL(value); + setError(false); + setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']()); + }, + [t] + ); const onConnect = useAsyncCallback(async () => { setIsConnecting(true); @@ -69,11 +76,33 @@ export const AddSelfhostedStep = ({ })); } catch (err) { console.error(err); + const userFriendlyError = UserFriendlyError.fromAny(err); setError(true); - } + if (userFriendlyError.is('TOO_MANY_REQUEST')) { + setErrorHint(t['error.TOO_MANY_REQUEST']()); + } else if ( + userFriendlyError.is('NETWORK_ERROR') || + userFriendlyError.is('REQUEST_ABORTED') + ) { + setErrorHint(t['error.NETWORK_ERROR']()); + } else { + setErrorHint(t['com.affine.auth.sign.add-selfhosted.error']()); + } - setIsConnecting(false); - }, [baseURL, changeState, serversService]); + notify.error({ + title: t['com.affine.auth.toast.title.failed'](), + message: + userFriendlyError.is('REQUEST_ABORTED') || + userFriendlyError.is('NETWORK_ERROR') + ? t['error.NETWORK_ERROR']() + : userFriendlyError.is('TOO_MANY_REQUEST') + ? t['error.TOO_MANY_REQUEST']() + : t[`error.${userFriendlyError.name}`](userFriendlyError.data), + }); + } finally { + setIsConnecting(false); + } + }, [baseURL, changeState, serversService, t]); useEffect(() => { if (state.initialServerBaseUrl) { @@ -101,7 +130,7 @@ export const AddSelfhostedStep = ({ placeholder="https://your-server.com" error={!!error} disabled={isConnecting} - errorHint={t['com.affine.auth.sign.add-selfhosted.error']()} + errorHint={errorHint} onEnter={onConnect} />