mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 13:29:02 +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:
@@ -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);
|
||||
|
||||
+2
-1
@@ -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) => (
|
||||
<div>
|
||||
<p
|
||||
|
||||
@@ -78,7 +78,11 @@ export const AddSelfhostedStep = ({
|
||||
console.error(err);
|
||||
const userFriendlyError = UserFriendlyError.fromAny(err);
|
||||
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']());
|
||||
} else if (
|
||||
userFriendlyError.is('NETWORK_ERROR') ||
|
||||
|
||||
@@ -146,6 +146,7 @@ export function configureCloudModule(framework: Framework) {
|
||||
UrlService,
|
||||
GlobalDialogService,
|
||||
NbstoreService,
|
||||
ServerService,
|
||||
])
|
||||
.store(AuthStore, [
|
||||
FetchService,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import type { OAuthProviderType } from '@affine/graphql';
|
||||
import { type OAuthProviderType, ServerDeploymentType } from '@affine/graphql';
|
||||
import { track } from '@affine/track';
|
||||
import { OnEvent, Service } from '@toeverything/infra';
|
||||
import { nanoid } from 'nanoid';
|
||||
@@ -15,7 +15,9 @@ import { AccountLoggedIn } from '../events/account-logged-in';
|
||||
import { AccountLoggedOut } from '../events/account-logged-out';
|
||||
import { ServerStarted } from '../events/server-started';
|
||||
import type { AuthStore } from '../stores/auth';
|
||||
import { assertSupportedServerVersion } from '../stores/server-config';
|
||||
import type { FetchService } from './fetch';
|
||||
import type { ServerService } from './server';
|
||||
|
||||
@OnEvent(ApplicationFocused, e => 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<Record<string, string>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
} 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)
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user