mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-02 02:00:49 +08:00
fix(core): handle unsupported server error (#15164)
fix #15160 fix #15161 fix #15158 fix #15166 #### PR Dependency Tree * **PR #15164** 👈 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 a “server version too old” message for self-hosted servers, including the required upgrade version. * Sign-in and OAuth-related preflight steps now verify server compatibility before proceeding. * **Bug Fixes** * Improved error handling for missing/invalid server version responses and schema/type mismatches, mapping them to the upgrade instruction. * **Tests** * Added coverage for server version guarding and the resulting user-friendly error payload. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -907,6 +907,14 @@ export const USER_FRIENDLY_ERRORS = {
|
|||||||
message: ({ clientVersion, requiredVersion }) =>
|
message: ({ clientVersion, requiredVersion }) =>
|
||||||
`Unsupported client with version [${clientVersion}], required version is [${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 Errors
|
||||||
notification_not_found: {
|
notification_not_found: {
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ export type ErrorName =
|
|||||||
| keyof typeof ErrorNames
|
| keyof typeof ErrorNames
|
||||||
| 'NETWORK_ERROR'
|
| 'NETWORK_ERROR'
|
||||||
| 'CONTENT_TOO_LARGE'
|
| 'CONTENT_TOO_LARGE'
|
||||||
| 'REQUEST_ABORTED';
|
| 'REQUEST_ABORTED'
|
||||||
|
| 'UNSUPPORTED_SERVER_VERSION';
|
||||||
|
|
||||||
export interface UserFriendlyErrorResponse {
|
export interface UserFriendlyErrorResponse {
|
||||||
status: number;
|
status: number;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { AuthSession } from '@affine/core/modules/cloud/entities/session';
|
import { AuthSession } from '@affine/core/modules/cloud/entities/session';
|
||||||
import { AuthService } from '@affine/core/modules/cloud/services/auth';
|
import { AuthService } from '@affine/core/modules/cloud/services/auth';
|
||||||
import { FetchService } from '@affine/core/modules/cloud/services/fetch';
|
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 { AuthStore } from '@affine/core/modules/cloud/stores/auth';
|
||||||
import { GlobalDialogService } from '@affine/core/modules/dialogs/services/dialog';
|
import { GlobalDialogService } from '@affine/core/modules/dialogs/services/dialog';
|
||||||
import { NbstoreService } from '@affine/core/modules/storage';
|
import { NbstoreService } from '@affine/core/modules/storage';
|
||||||
import { UrlService } from '@affine/core/modules/url/services/url';
|
import { UrlService } from '@affine/core/modules/url/services/url';
|
||||||
|
import { ServerDeploymentType } from '@affine/graphql';
|
||||||
import { Framework } from '@toeverything/infra';
|
import { Framework } from '@toeverything/infra';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { describe, expect, test, vi } from 'vitest';
|
import { describe, expect, test, vi } from 'vitest';
|
||||||
@@ -41,12 +43,23 @@ describe('AuthService oauthPreflight', () => {
|
|||||||
framework.service(NbstoreService, {
|
framework.service(NbstoreService, {
|
||||||
realtime: { subscribe: () => of() },
|
realtime: { subscribe: () => of() },
|
||||||
} as any);
|
} as any);
|
||||||
|
framework.service(ServerService, {
|
||||||
|
server: {
|
||||||
|
['config$']: {
|
||||||
|
value: {
|
||||||
|
type: ServerDeploymentType.Selfhosted,
|
||||||
|
version: '0.27.0',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
framework.service(AuthService, [
|
framework.service(AuthService, [
|
||||||
FetchService,
|
FetchService,
|
||||||
AuthStore,
|
AuthStore,
|
||||||
UrlService,
|
UrlService,
|
||||||
GlobalDialogService,
|
GlobalDialogService,
|
||||||
NbstoreService,
|
NbstoreService,
|
||||||
|
ServerService,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const auth = framework.provider().get(AuthService);
|
const auth = framework.provider().get(AuthService);
|
||||||
|
|||||||
+2
-1
@@ -1,11 +1,12 @@
|
|||||||
import type { Server } from '@affine/core/modules/cloud';
|
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 { useLiveData } from '@toeverything/infra';
|
||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import semver from 'semver';
|
import semver from 'semver';
|
||||||
|
|
||||||
const rules = [
|
const rules = [
|
||||||
{
|
{
|
||||||
min: '0.23.0',
|
min: MIN_SUPPORTED_SERVER_VERSION,
|
||||||
tip: (receivedVersion: string, requiredVersion: string) => (
|
tip: (receivedVersion: string, requiredVersion: string) => (
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
|
|||||||
@@ -78,7 +78,11 @@ export const AddSelfhostedStep = ({
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
const userFriendlyError = UserFriendlyError.fromAny(err);
|
const userFriendlyError = UserFriendlyError.fromAny(err);
|
||||||
setError(true);
|
setError(true);
|
||||||
if (userFriendlyError.is('TOO_MANY_REQUEST')) {
|
if (userFriendlyError.is('UNSUPPORTED_SERVER_VERSION')) {
|
||||||
|
setErrorHint(
|
||||||
|
t[`error.${userFriendlyError.name}`](userFriendlyError.data)
|
||||||
|
);
|
||||||
|
} else if (userFriendlyError.is('TOO_MANY_REQUEST')) {
|
||||||
setErrorHint(t['error.TOO_MANY_REQUEST']());
|
setErrorHint(t['error.TOO_MANY_REQUEST']());
|
||||||
} else if (
|
} else if (
|
||||||
userFriendlyError.is('NETWORK_ERROR') ||
|
userFriendlyError.is('NETWORK_ERROR') ||
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export function configureCloudModule(framework: Framework) {
|
|||||||
UrlService,
|
UrlService,
|
||||||
GlobalDialogService,
|
GlobalDialogService,
|
||||||
NbstoreService,
|
NbstoreService,
|
||||||
|
ServerService,
|
||||||
])
|
])
|
||||||
.store(AuthStore, [
|
.store(AuthStore, [
|
||||||
FetchService,
|
FetchService,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { UserFriendlyError } from '@affine/error';
|
import { UserFriendlyError } from '@affine/error';
|
||||||
import type { OAuthProviderType } from '@affine/graphql';
|
import { type OAuthProviderType, ServerDeploymentType } from '@affine/graphql';
|
||||||
import { track } from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import { OnEvent, Service } from '@toeverything/infra';
|
import { OnEvent, Service } from '@toeverything/infra';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
@@ -15,7 +15,9 @@ import { AccountLoggedIn } from '../events/account-logged-in';
|
|||||||
import { AccountLoggedOut } from '../events/account-logged-out';
|
import { AccountLoggedOut } from '../events/account-logged-out';
|
||||||
import { ServerStarted } from '../events/server-started';
|
import { ServerStarted } from '../events/server-started';
|
||||||
import type { AuthStore } from '../stores/auth';
|
import type { AuthStore } from '../stores/auth';
|
||||||
|
import { assertSupportedServerVersion } from '../stores/server-config';
|
||||||
import type { FetchService } from './fetch';
|
import type { FetchService } from './fetch';
|
||||||
|
import type { ServerService } from './server';
|
||||||
|
|
||||||
@OnEvent(ApplicationFocused, e => e.onApplicationFocused)
|
@OnEvent(ApplicationFocused, e => e.onApplicationFocused)
|
||||||
@OnEvent(ServerStarted, e => e.onServerStarted)
|
@OnEvent(ServerStarted, e => e.onServerStarted)
|
||||||
@@ -28,7 +30,8 @@ export class AuthService extends Service {
|
|||||||
private readonly store: AuthStore,
|
private readonly store: AuthStore,
|
||||||
private readonly urlService: UrlService,
|
private readonly urlService: UrlService,
|
||||||
private readonly dialogService: GlobalDialogService,
|
private readonly dialogService: GlobalDialogService,
|
||||||
private readonly nbstoreService: NbstoreService
|
private readonly nbstoreService: NbstoreService,
|
||||||
|
private readonly serverService: ServerService
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
@@ -97,6 +100,7 @@ export class AuthService extends Service {
|
|||||||
challenge?: string,
|
challenge?: string,
|
||||||
redirectUrl?: string // url to redirect to after signed-in
|
redirectUrl?: string // url to redirect to after signed-in
|
||||||
) {
|
) {
|
||||||
|
this.assertSupportedServerVersion();
|
||||||
track.$.$.auth.signIn({ method: 'magic-link' });
|
track.$.$.auth.signIn({ method: 'magic-link' });
|
||||||
// Only native clients use `client_nonce` for magic-link/otp sign-in.
|
// Only native clients use `client_nonce` for magic-link/otp sign-in.
|
||||||
// Web needs to keep cross-device magic-link compatibility.
|
// Web needs to keep cross-device magic-link compatibility.
|
||||||
@@ -156,6 +160,7 @@ export class AuthService extends Service {
|
|||||||
client: string,
|
client: string,
|
||||||
/** @deprecated*/ redirectUrl?: string
|
/** @deprecated*/ redirectUrl?: string
|
||||||
): Promise<Record<string, string>> {
|
): Promise<Record<string, string>> {
|
||||||
|
this.assertSupportedServerVersion();
|
||||||
// OAuth callback requires `client_nonce` for all clients (including web).
|
// OAuth callback requires `client_nonce` for all clients (including web).
|
||||||
const clientNonce = this.setClientNonce();
|
const clientNonce = this.setClientNonce();
|
||||||
try {
|
try {
|
||||||
@@ -233,6 +238,7 @@ export class AuthService extends Service {
|
|||||||
verifyToken?: string;
|
verifyToken?: string;
|
||||||
challenge?: string;
|
challenge?: string;
|
||||||
}) {
|
}) {
|
||||||
|
this.assertSupportedServerVersion();
|
||||||
track.$.$.auth.signIn({ method: 'password' });
|
track.$.$.auth.signIn({ method: 'password' });
|
||||||
try {
|
try {
|
||||||
const user = await this.store.signInPassword(credential);
|
const user = await this.store.signInPassword(credential);
|
||||||
@@ -285,4 +291,10 @@ export class AuthService extends Service {
|
|||||||
this.store.setClientNonce(nonce);
|
this.store.setClientNonce(nonce);
|
||||||
return nonce;
|
return nonce;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertSupportedServerVersion() {
|
||||||
|
const config = this.serverService.server.config$.value;
|
||||||
|
if (config.type !== ServerDeploymentType.Selfhosted) return;
|
||||||
|
assertSupportedServerVersion(config.version);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,10 +8,13 @@ import {
|
|||||||
ServerFeature,
|
ServerFeature,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { Store } from '@toeverything/infra';
|
import { Store } from '@toeverything/infra';
|
||||||
|
import semver from 'semver';
|
||||||
|
|
||||||
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
|
export type ServerConfigType = ServerConfigQuery['serverConfig'] &
|
||||||
OauthProvidersQuery['serverConfig'];
|
OauthProvidersQuery['serverConfig'];
|
||||||
|
|
||||||
|
export const MIN_SUPPORTED_SERVER_VERSION = '0.27.0';
|
||||||
|
|
||||||
const NETWORK_ERROR_PATTERNS = [
|
const NETWORK_ERROR_PATTERNS = [
|
||||||
/failed to fetch/i,
|
/failed to fetch/i,
|
||||||
/network request failed/i,
|
/network request failed/i,
|
||||||
@@ -24,6 +27,40 @@ const NETWORK_ERROR_PATTERNS = [
|
|||||||
/err_[a-z_]+/i,
|
/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) {
|
function mapServerConfigError(error: unknown) {
|
||||||
const userFriendlyError = UserFriendlyError.fromAny(error);
|
const userFriendlyError = UserFriendlyError.fromAny(error);
|
||||||
if (
|
if (
|
||||||
@@ -36,6 +73,10 @@ function mapServerConfigError(error: unknown) {
|
|||||||
|
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
const detail = `${error.name}: ${error.message}`;
|
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))) {
|
if (NETWORK_ERROR_PATTERNS.some(pattern => pattern.test(detail))) {
|
||||||
return new UserFriendlyError({
|
return new UserFriendlyError({
|
||||||
status: 504,
|
status: 504,
|
||||||
@@ -74,6 +115,7 @@ export class ServerConfigStore extends Store {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
assertSupportedServerVersion(serverConfigData.serverConfig.version);
|
||||||
if (
|
if (
|
||||||
serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)
|
serverConfigData.serverConfig.features.includes(ServerFeature.OAuth)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@
|
|||||||
"en": 100,
|
"en": 100,
|
||||||
"es-AR": 93,
|
"es-AR": 93,
|
||||||
"es-CL": 94,
|
"es-CL": 94,
|
||||||
"es": 93,
|
"es": 92,
|
||||||
"fa": 92,
|
"fa": 92,
|
||||||
"fr": 97,
|
"fr": 96,
|
||||||
"hi": 1,
|
"hi": 1,
|
||||||
"it": 94,
|
"it": 94,
|
||||||
"ja": 92,
|
"ja": 92,
|
||||||
|
|||||||
@@ -9692,6 +9692,12 @@ export function useAFFiNEI18N(): {
|
|||||||
clientVersion: string;
|
clientVersion: string;
|
||||||
requiredVersion: string;
|
requiredVersion: string;
|
||||||
}>): 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.`
|
* `Notification not found.`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2393,6 +2393,7 @@
|
|||||||
"error.INVALID_LICENSE_UPDATE_PARAMS": "Invalid license update params. {{reason}}",
|
"error.INVALID_LICENSE_UPDATE_PARAMS": "Invalid license update params. {{reason}}",
|
||||||
"error.LICENSE_EXPIRED": "License has expired.",
|
"error.LICENSE_EXPIRED": "License has expired.",
|
||||||
"error.UNSUPPORTED_CLIENT_VERSION": "Unsupported client with version [{{clientVersion}}], required version is [{{requiredVersion}}].",
|
"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.NOTIFICATION_NOT_FOUND": "Notification not found.",
|
||||||
"error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.",
|
"error.MENTION_USER_DOC_ACCESS_DENIED": "Mentioned user can not access doc {{docId}}.",
|
||||||
"error.MENTION_USER_ONESELF_DENIED": "You can not mention yourself.",
|
"error.MENTION_USER_ONESELF_DENIED": "You can not mention yourself.",
|
||||||
|
|||||||
@@ -2393,6 +2393,7 @@
|
|||||||
"error.INVALID_LICENSE_UPDATE_PARAMS": "无效的许可证更新参数。{{reason}}",
|
"error.INVALID_LICENSE_UPDATE_PARAMS": "无效的许可证更新参数。{{reason}}",
|
||||||
"error.LICENSE_EXPIRED": "许可证已过期。",
|
"error.LICENSE_EXPIRED": "许可证已过期。",
|
||||||
"error.UNSUPPORTED_CLIENT_VERSION": "不支持的客户端版本 [{{clientVersion}}],所需版本为 [{{requiredVersion}}]。",
|
"error.UNSUPPORTED_CLIENT_VERSION": "不支持的客户端版本 [{{clientVersion}}],所需版本为 [{{requiredVersion}}]。",
|
||||||
|
"error.UNSUPPORTED_SERVER_VERSION": "此 AFFiNE 服务器版本过旧,当前客户端需要连接 {{requiredVersion}} 的服务器。请升级服务器后重试。",
|
||||||
"error.NOTIFICATION_NOT_FOUND": "未找到通知。",
|
"error.NOTIFICATION_NOT_FOUND": "未找到通知。",
|
||||||
"error.MENTION_USER_DOC_ACCESS_DENIED": "提到的用户无法访问文档 {{docId}}。",
|
"error.MENTION_USER_DOC_ACCESS_DENIED": "提到的用户无法访问文档 {{docId}}。",
|
||||||
"error.MENTION_USER_ONESELF_DENIED": "你不能提及你自己。",
|
"error.MENTION_USER_ONESELF_DENIED": "你不能提及你自己。",
|
||||||
|
|||||||
Reference in New Issue
Block a user