diff --git a/packages/backend/server/src/base/error/def.ts b/packages/backend/server/src/base/error/def.ts index be7682d564..27b9b77619 100644 --- a/packages/backend/server/src/base/error/def.ts +++ b/packages/backend/server/src/base/error/def.ts @@ -907,6 +907,14 @@ export const USER_FRIENDLY_ERRORS = { message: ({ clientVersion, requiredVersion }) => `Unsupported client with version [${clientVersion}], required version is [${requiredVersion}].`, }, + unsupported_server_version: { + type: 'action_forbidden', + args: { + requiredVersion: 'string', + }, + message: ({ requiredVersion }) => + `This AFFiNE server is too old for this client. Please upgrade the server to ${requiredVersion}.`, + }, // Notification Errors notification_not_found: { diff --git a/packages/common/error/src/index.ts b/packages/common/error/src/index.ts index d24af347f2..044ac7fcae 100644 --- a/packages/common/error/src/index.ts +++ b/packages/common/error/src/index.ts @@ -5,7 +5,8 @@ export type ErrorName = | keyof typeof ErrorNames | 'NETWORK_ERROR' | 'CONTENT_TOO_LARGE' - | 'REQUEST_ABORTED'; + | 'REQUEST_ABORTED' + | 'UNSUPPORTED_SERVER_VERSION'; export interface UserFriendlyErrorResponse { status: number; diff --git a/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts index 634069893e..1054659c42 100644 --- a/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts +++ b/packages/frontend/core/src/__tests__/auth-client-nonce.spec.ts @@ -1,10 +1,12 @@ import { AuthSession } from '@affine/core/modules/cloud/entities/session'; import { AuthService } from '@affine/core/modules/cloud/services/auth'; import { FetchService } from '@affine/core/modules/cloud/services/fetch'; +import { ServerService } from '@affine/core/modules/cloud/services/server'; import { AuthStore } from '@affine/core/modules/cloud/stores/auth'; import { GlobalDialogService } from '@affine/core/modules/dialogs/services/dialog'; import { NbstoreService } from '@affine/core/modules/storage'; import { UrlService } from '@affine/core/modules/url/services/url'; +import { ServerDeploymentType } from '@affine/graphql'; import { Framework } from '@toeverything/infra'; import { of } from 'rxjs'; import { describe, expect, test, vi } from 'vitest'; @@ -41,12 +43,23 @@ describe('AuthService oauthPreflight', () => { framework.service(NbstoreService, { realtime: { subscribe: () => of() }, } as any); + framework.service(ServerService, { + server: { + ['config$']: { + value: { + type: ServerDeploymentType.Selfhosted, + version: '0.27.0', + }, + }, + }, + } as any); framework.service(AuthService, [ FetchService, AuthStore, UrlService, GlobalDialogService, NbstoreService, + ServerService, ]); const auth = framework.provider().get(AuthService); diff --git a/packages/frontend/core/src/components/hooks/affine/use-selfhost-login-version-guard.tsx b/packages/frontend/core/src/components/hooks/affine/use-selfhost-login-version-guard.tsx index 03deadf703..ec4daaa872 100644 --- a/packages/frontend/core/src/components/hooks/affine/use-selfhost-login-version-guard.tsx +++ b/packages/frontend/core/src/components/hooks/affine/use-selfhost-login-version-guard.tsx @@ -1,11 +1,12 @@ import type { Server } from '@affine/core/modules/cloud'; +import { MIN_SUPPORTED_SERVER_VERSION } from '@affine/core/modules/cloud/stores/server-config'; import { useLiveData } from '@toeverything/infra'; import { cssVarV2 } from '@toeverything/theme/v2'; import semver from 'semver'; const rules = [ { - min: '0.23.0', + min: MIN_SUPPORTED_SERVER_VERSION, tip: (receivedVersion: string, requiredVersion: string) => (

e.onApplicationFocused) @OnEvent(ServerStarted, e => e.onServerStarted) @@ -28,7 +30,8 @@ export class AuthService extends Service { private readonly store: AuthStore, private readonly urlService: UrlService, private readonly dialogService: GlobalDialogService, - private readonly nbstoreService: NbstoreService + private readonly nbstoreService: NbstoreService, + private readonly serverService: ServerService ) { super(); @@ -97,6 +100,7 @@ export class AuthService extends Service { challenge?: string, redirectUrl?: string // url to redirect to after signed-in ) { + this.assertSupportedServerVersion(); track.$.$.auth.signIn({ method: 'magic-link' }); // Only native clients use `client_nonce` for magic-link/otp sign-in. // Web needs to keep cross-device magic-link compatibility. @@ -156,6 +160,7 @@ export class AuthService extends Service { client: string, /** @deprecated*/ redirectUrl?: string ): Promise> { + this.assertSupportedServerVersion(); // OAuth callback requires `client_nonce` for all clients (including web). const clientNonce = this.setClientNonce(); try { @@ -233,6 +238,7 @@ export class AuthService extends Service { verifyToken?: string; challenge?: string; }) { + this.assertSupportedServerVersion(); track.$.$.auth.signIn({ method: 'password' }); try { const user = await this.store.signInPassword(credential); @@ -285,4 +291,10 @@ export class AuthService extends Service { this.store.setClientNonce(nonce); return nonce; } + + private assertSupportedServerVersion() { + const config = this.serverService.server.config$.value; + if (config.type !== ServerDeploymentType.Selfhosted) return; + assertSupportedServerVersion(config.version); + } } diff --git a/packages/frontend/core/src/modules/cloud/stores/server-config.spec.ts b/packages/frontend/core/src/modules/cloud/stores/server-config.spec.ts new file mode 100644 index 0000000000..0aa67afb86 --- /dev/null +++ b/packages/frontend/core/src/modules/cloud/stores/server-config.spec.ts @@ -0,0 +1,43 @@ +import { UserFriendlyError } from '@affine/error'; +import { describe, expect, test } from 'vitest'; + +import { + assertSupportedServerVersion, + MIN_SUPPORTED_SERVER_VERSION, +} from './server-config'; + +describe('server config version guard', () => { + test('accepts supported server versions', () => { + expect(() => assertSupportedServerVersion('0.27.0')).not.toThrow(); + expect(() => assertSupportedServerVersion('0.28.0')).not.toThrow(); + }); + + test('rejects old server versions', () => { + expect(() => assertSupportedServerVersion('0.26.9')).toThrow( + UserFriendlyError + ); + }); + + test('rejects missing or invalid server versions', () => { + for (const version of [undefined, null, '', 'not-a-version']) { + expect(() => assertSupportedServerVersion(version)).toThrow( + UserFriendlyError + ); + } + }); + + test('reports the required server version', () => { + expect.assertions(2); + + try { + assertSupportedServerVersion('0.26.0'); + } catch (error) { + const userFriendlyError = UserFriendlyError.fromAny(error); + expect(userFriendlyError.name).toBe('UNSUPPORTED_SERVER_VERSION'); + expect(userFriendlyError.data).toMatchObject({ + requiredVersion: `>=${MIN_SUPPORTED_SERVER_VERSION}`, + serverVersion: '0.26.0', + }); + } + }); +}); diff --git a/packages/frontend/core/src/modules/cloud/stores/server-config.ts b/packages/frontend/core/src/modules/cloud/stores/server-config.ts index 7e2a8533b1..d4085f6e04 100644 --- a/packages/frontend/core/src/modules/cloud/stores/server-config.ts +++ b/packages/frontend/core/src/modules/cloud/stores/server-config.ts @@ -8,10 +8,13 @@ import { ServerFeature, } from '@affine/graphql'; import { Store } from '@toeverything/infra'; +import semver from 'semver'; export type ServerConfigType = ServerConfigQuery['serverConfig'] & OauthProvidersQuery['serverConfig']; +export const MIN_SUPPORTED_SERVER_VERSION = '0.27.0'; + const NETWORK_ERROR_PATTERNS = [ /failed to fetch/i, /network request failed/i, @@ -24,6 +27,40 @@ const NETWORK_ERROR_PATTERNS = [ /err_[a-z_]+/i, ]; +const MISSING_SERVER_VERSION_PATTERNS = [ + /cannot query field ["']?version["']? on type ["']?serverconfigtype["']?/i, + /field ["']?version["']? is not defined by type ["']?serverconfigtype["']?/i, +]; + +export function createUnsupportedServerVersionError(version?: string | null) { + const receivedVersion = version || 'unknown'; + return new UserFriendlyError({ + status: 426, + code: 'UNSUPPORTED_SERVER_VERSION', + type: 'UNSUPPORTED_SERVER_VERSION', + name: 'UNSUPPORTED_SERVER_VERSION', + message: `Unsupported server with version [${receivedVersion}], required version is [>=${MIN_SUPPORTED_SERVER_VERSION}].`, + data: { + serverVersion: receivedVersion, + requiredVersion: `>=${MIN_SUPPORTED_SERVER_VERSION}`, + }, + }); +} + +export function assertSupportedServerVersion(version?: string | null) { + if (!version) { + throw createUnsupportedServerVersionError(version); + } + + const normalized = semver.valid(version, { loose: true }); + if ( + !normalized || + semver.lt(normalized, MIN_SUPPORTED_SERVER_VERSION, { loose: true }) + ) { + throw createUnsupportedServerVersionError(version); + } +} + function mapServerConfigError(error: unknown) { const userFriendlyError = UserFriendlyError.fromAny(error); if ( @@ -36,6 +73,10 @@ function mapServerConfigError(error: unknown) { if (error instanceof Error) { const detail = `${error.name}: ${error.message}`; + if (MISSING_SERVER_VERSION_PATTERNS.some(pattern => pattern.test(detail))) { + return createUnsupportedServerVersionError(); + } + if (NETWORK_ERROR_PATTERNS.some(pattern => pattern.test(detail))) { return new UserFriendlyError({ status: 504, @@ -74,6 +115,7 @@ export class ServerConfigStore extends Store { }, }, }); + assertSupportedServerVersion(serverConfigData.serverConfig.version); if ( serverConfigData.serverConfig.features.includes(ServerFeature.OAuth) ) { diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 5fd462405b..2f8d498007 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -7,9 +7,9 @@ "en": 100, "es-AR": 93, "es-CL": 94, - "es": 93, + "es": 92, "fa": 92, - "fr": 97, + "fr": 96, "hi": 1, "it": 94, "ja": 92, diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index b1e7056556..cd587934c3 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -9692,6 +9692,12 @@ export function useAFFiNEI18N(): { clientVersion: string; requiredVersion: string; }>): string; + /** + * `This AFFiNE server is too old for this client. Please upgrade the server to {{requiredVersion}}.` + */ + ["error.UNSUPPORTED_SERVER_VERSION"](options: { + readonly requiredVersion: string; + }): string; /** * `Notification not found.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index a7bb6ed134..1b1053b6c5 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -2393,6 +2393,7 @@ "error.INVALID_LICENSE_UPDATE_PARAMS": "Invalid license update params. {{reason}}", "error.LICENSE_EXPIRED": "License has expired.", "error.UNSUPPORTED_CLIENT_VERSION": "Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}].", + "error.UNSUPPORTED_SERVER_VERSION": "This AFFiNE server is too old for this client. Please upgrade the server to {{requiredVersion}}.", "error.NOTIFICATION_NOT_FOUND": "Notification not found.", "error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.", "error.MENTION_USER_ONESELF_DENIED": "You can not mention yourself.", diff --git a/packages/frontend/i18n/src/resources/zh-Hans.json b/packages/frontend/i18n/src/resources/zh-Hans.json index aaf84ea868..1bb75e6be2 100644 --- a/packages/frontend/i18n/src/resources/zh-Hans.json +++ b/packages/frontend/i18n/src/resources/zh-Hans.json @@ -2393,6 +2393,7 @@ "error.INVALID_LICENSE_UPDATE_PARAMS": "无效的许可证更新参数。{{reason}}", "error.LICENSE_EXPIRED": "许可证已过期。", "error.UNSUPPORTED_CLIENT_VERSION": "不支持的客户端版本 [{{clientVersion}}],所需版本为 [{{requiredVersion}}]。", + "error.UNSUPPORTED_SERVER_VERSION": "此 AFFiNE 服务器版本过旧,当前客户端需要连接 {{requiredVersion}} 的服务器。请升级服务器后重试。", "error.NOTIFICATION_NOT_FOUND": "未找到通知。", "error.MENTION_USER_DOC_ACCESS_DENIED": "提到的用户无法访问文档 {{docId}}。", "error.MENTION_USER_ONESELF_DENIED": "你不能提及你自己。",