feat(server): client version check (#9205)

Co-authored-by: forehalo <forehalo@gmail.com>
This commit is contained in:
DarkSky
2025-02-20 15:50:22 +08:00
committed by GitHub
parent 4fee2a9c4b
commit fa86f71853
17 changed files with 369 additions and 10 deletions

View File

@@ -0,0 +1,148 @@
import { Controller, Get } from '@nestjs/common';
import test from 'ava';
import Sinon from 'sinon';
import { AppModule } from '../app.module';
import { Runtime, UseNamedGuard } from '../base';
import { Public } from '../core/auth/guard';
import { VersionService } from '../core/version/service';
import { createTestingApp, TestingApp } from './utils';
@Public()
@Controller('/guarded')
class GuardedController {
@UseNamedGuard('version')
@Get('/test')
test() {
return 'test';
}
}
let app: TestingApp;
let runtime: Sinon.SinonStubbedInstance<Runtime>;
let version: VersionService;
function checkVersion(enabled = true) {
runtime.fetch.withArgs('client/versionControl.enabled').resolves(enabled);
runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('>=0.20.0');
}
test.before(async () => {
app = await createTestingApp({
imports: [AppModule],
controllers: [GuardedController],
tapModule: m => {
m.overrideProvider(Runtime).useValue(Sinon.createStubInstance(Runtime));
},
});
runtime = app.get(Runtime);
version = app.get(VersionService, { strict: false });
});
test.beforeEach(async () => {
Sinon.reset();
checkVersion(true);
});
test.after.always(async () => {
await app.close();
});
test('should passthrough if version check is not enabled', async t => {
checkVersion(false);
const spy = Sinon.spy(version, 'checkVersion');
let res = await app.GET('/guarded/test');
t.is(res.status, 200);
res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');
t.is(res.status, 200);
res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
t.is(res.status, 200);
t.true(spy.notCalled);
spy.restore();
});
test('should passthrough is version range is invalid', async t => {
runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('invalid');
let res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
t.is(res.status, 200);
});
test('should pass if client version is allowed', async t => {
let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');
t.is(res.status, 200);
res = await app.GET('/guarded/test').set('x-affine-version', '0.21.0');
t.is(res.status, 200);
runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('>=0.19.0');
res = await app.GET('/guarded/test').set('x-affine-version', '0.19.0');
t.is(res.status, 200);
});
test('should fail if client version is not set or invalid', async t => {
let res = await app.GET('/guarded/test');
t.is(res.status, 403);
t.is(
res.body.message,
'Unsupported client with version [unset_or_invalid], required version is [>=0.20.0].'
);
res = await app.GET('/guarded/test').set('x-affine-version', 'invalid');
t.is(res.status, 403);
t.is(
res.body.message,
'Unsupported client with version [invalid], required version is [>=0.20.0].'
);
});
test('should tell upgrade if client version is lower than allowed', async t => {
runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('>=0.21.0 <=0.22.0');
let res = await app.GET('/guarded/test').set('x-affine-version', '0.20.0');
t.is(res.status, 403);
t.is(
res.body.message,
'Unsupported client with version [0.20.0], required version is [>=0.21.0 <=0.22.0].'
);
});
test('should tell downgrade if client version is higher than allowed', async t => {
runtime.fetch
.withArgs('client/versionControl.requiredVersion')
.resolves('>=0.20.0 <=0.22.0');
let res = await app.GET('/guarded/test').set('x-affine-version', '0.23.0');
t.is(res.status, 403);
t.is(
res.body.message,
'Unsupported client with version [0.23.0], required version is [>=0.20.0 <=0.22.0].'
);
});

View File

@@ -49,6 +49,7 @@ import { SelfhostModule } from './core/selfhost';
import { StorageModule } from './core/storage';
import { SyncModule } from './core/sync';
import { UserModule } from './core/user';
import { VersionModule } from './core/version';
import { WorkspaceModule } from './core/workspaces';
import { ModelsModule } from './models';
import { REGISTERED_PLUGINS } from './plugins';
@@ -225,6 +226,7 @@ export function buildAppModule() {
// graphql server only
.useIf(
config => config.flavor.graphql,
VersionModule,
GqlModule,
StorageModule,
ServerConfigModule,

View File

@@ -724,4 +724,15 @@ export const USER_FRIENDLY_ERRORS = {
message: ({ limit }) =>
`You cannot downgrade the workspace from team workspace because there are more than ${limit} members that are currently active.`,
},
// version errors
unsupported_client_version: {
type: 'action_forbidden',
args: {
clientVersion: 'string',
requiredVersion: 'string',
},
message: ({ clientVersion, requiredVersion }) =>
`Unsupported client with version [${clientVersion}], required version is [${requiredVersion}].`,
},
} satisfies Record<string, UserFriendlyErrorOptions>;

View File

@@ -794,6 +794,17 @@ export class WorkspaceMembersExceedLimitToDowngrade extends UserFriendlyError {
super('bad_request', 'workspace_members_exceed_limit_to_downgrade', message, args);
}
}
@ObjectType()
class UnsupportedClientVersionDataType {
@Field() clientVersion!: string
@Field() requiredVersion!: string
}
export class UnsupportedClientVersion extends UserFriendlyError {
constructor(args: UnsupportedClientVersionDataType, message?: string | ((args: UnsupportedClientVersionDataType) => string)) {
super('action_forbidden', 'unsupported_client_version', message, args);
}
}
export enum ErrorNames {
INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST,
@@ -895,7 +906,8 @@ export enum ErrorNames {
LICENSE_NOT_FOUND,
INVALID_LICENSE_TO_ACTIVATE,
INVALID_LICENSE_UPDATE_PARAMS,
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE
WORKSPACE_MEMBERS_EXCEED_LIMIT_TO_DOWNGRADE,
UNSUPPORTED_CLIENT_VERSION
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'
@@ -904,5 +916,5 @@ registerEnumType(ErrorNames, {
export const ErrorDataUnionType = createUnionType({
name: 'ErrorDataUnion',
types: () =>
[GraphqlBadRequestDataType, QueryTooLongDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType] as const,
[GraphqlBadRequestDataType, QueryTooLongDataType, WrongSignInCredentialsDataType, UnknownOauthProviderDataType, MissingOauthQueryParameterDataType, InvalidEmailDataType, InvalidPasswordLengthDataType, WorkspacePermissionNotFoundDataType, SpaceNotFoundDataType, MemberNotFoundInSpaceDataType, NotInSpaceDataType, AlreadyInSpaceDataType, SpaceAccessDeniedDataType, SpaceOwnerNotFoundDataType, SpaceShouldHaveOnlyOneOwnerDataType, DocNotFoundDataType, DocActionDeniedDataType, VersionRejectedDataType, InvalidHistoryTimestampDataType, DocHistoryNotFoundDataType, BlobNotFoundDataType, ExpectToGrantDocUserRolesDataType, ExpectToRevokeDocUserRolesDataType, ExpectToUpdateDocUserRoleDataType, UnsupportedSubscriptionPlanDataType, SubscriptionAlreadyExistsDataType, SubscriptionNotExistsDataType, SameSubscriptionRecurringDataType, SubscriptionPlanNotFoundDataType, CopilotDocNotFoundDataType, CopilotMessageNotFoundDataType, CopilotPromptNotFoundDataType, CopilotProviderSideErrorDataType, CopilotInvalidContextDataType, CopilotContextFileNotSupportedDataType, CopilotFailedToModifyContextDataType, CopilotFailedToMatchContextDataType, RuntimeConfigNotFoundDataType, InvalidRuntimeConfigTypeDataType, InvalidLicenseUpdateParamsDataType, WorkspaceMembersExceedLimitToDowngradeDataType, UnsupportedClientVersionDataType] as const,
});

View File

@@ -18,14 +18,19 @@ export class BasicGuard implements CanActivate {
async canActivate(context: ExecutionContext) {
// get registered guard name
const providerName = this.reflector.get<string>(
const providerName = this.reflector.get<string[]>(
BasicGuardSymbol,
context.getHandler()
);
const provider = GUARD_PROVIDER[providerName as NamedGuards];
if (provider) {
return await provider.canActivate(context);
if (Array.isArray(providerName) && providerName.length > 0) {
for (const name of providerName) {
const provider = GUARD_PROVIDER[name as NamedGuards];
if (provider) {
const ret = await provider.canActivate(context);
if (!ret) return false;
}
}
}
return true;
@@ -46,5 +51,5 @@ export class BasicGuard implements CanActivate {
* }
* ```
*/
export const UseNamedGuard = (name: NamedGuards) =>
export const UseNamedGuard = (...name: NamedGuards[]) =>
applyDecorators(UseGuards(BasicGuard), SetMetadata(BasicGuardSymbol, name));

View File

@@ -79,6 +79,7 @@ export class AuthController {
}
@Public()
@UseNamedGuard('version')
@Post('/preflight')
async preflight(
@Body() params?: { email: string }
@@ -108,7 +109,7 @@ export class AuthController {
}
@Public()
@UseNamedGuard('captcha')
@UseNamedGuard('version', 'captcha')
@Post('/sign-in')
@Header('content-type', 'application/json')
async signIn(
@@ -260,6 +261,7 @@ export class AuthController {
}
@Public()
@UseNamedGuard('version')
@Post('/magic-link')
async magicLinkSignIn(
@Req() req: Request,

View File

@@ -0,0 +1,31 @@
import { defineRuntimeConfig, ModuleConfig } from '../../base/config';
export interface VersionConfig {
versionControl: {
enabled: boolean;
requiredVersion: string;
};
}
declare module '../../base/config' {
interface AppConfig {
client: ModuleConfig<never, VersionConfig>;
}
}
declare module '../../base/guard' {
interface RegisterGuardName {
version: 'version';
}
}
defineRuntimeConfig('client', {
'versionControl.enabled': {
desc: 'Whether check version of client before accessing the server.',
default: false,
},
'versionControl.requiredVersion': {
desc: "Allowed version range of the app that allowed to access the server. Requires 'client/versionControl.enabled' to be true to take effect.",
default: '>=0.20.0',
},
});

View File

@@ -0,0 +1,40 @@
import type {
CanActivate,
ExecutionContext,
OnModuleInit,
} from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import {
getRequestResponseFromContext,
GuardProvider,
Runtime,
} from '../../base';
import { VersionService } from './service';
@Injectable()
export class VersionGuardProvider
extends GuardProvider
implements CanActivate, OnModuleInit
{
name = 'version' as const;
constructor(
private readonly runtime: Runtime,
private readonly version: VersionService
) {
super();
}
async canActivate(context: ExecutionContext) {
if (!(await this.runtime.fetch('client/versionControl.enabled'))) {
return true;
}
const { req } = getRequestResponseFromContext(context);
const version = req.headers['x-affine-version'] as string | undefined;
return this.version.checkVersion(version);
}
}

View File

@@ -0,0 +1,13 @@
import './config';
import { Module } from '@nestjs/common';
import { VersionGuardProvider } from './guard';
import { VersionService } from './service';
@Module({
providers: [VersionService, VersionGuardProvider],
})
export class VersionModule {}
export type { VersionConfig } from './config';

View File

@@ -0,0 +1,59 @@
import { Injectable, Logger } from '@nestjs/common';
import semver from 'semver';
import { Runtime, UnsupportedClientVersion } from '../../base';
@Injectable()
export class VersionService {
private readonly logger = new Logger(VersionService.name);
constructor(private readonly runtime: Runtime) {}
async checkVersion(clientVersion?: string) {
const requiredVersion = await this.runtime.fetch(
'client/versionControl.requiredVersion'
);
const range = await this.getVersionRange(requiredVersion);
if (!range) {
// ignore invalid allowed version config
return true;
}
if (!clientVersion || !semver.satisfies(clientVersion, range)) {
throw new UnsupportedClientVersion({
clientVersion: clientVersion ?? 'unset_or_invalid',
requiredVersion,
});
}
return true;
}
private readonly cachedVersionRange = new Map<
string,
semver.Range | undefined
>();
private async getVersionRange(versionRange: string) {
if (this.cachedVersionRange.has(versionRange)) {
return this.cachedVersionRange.get(versionRange);
}
let range: semver.Range | undefined;
try {
range = new semver.Range(versionRange, { loose: false });
if (!semver.validRange(range)) {
range = undefined;
}
} catch {
range = undefined;
}
if (!range) {
this.logger.error(`invalid version range: ${versionRange}`);
}
this.cachedVersionRange.set(versionRange, range);
return range;
}
}

View File

@@ -16,6 +16,7 @@ import {
OauthAccountAlreadyConnected,
OauthStateExpired,
UnknownOauthProvider,
UseNamedGuard,
} from '../../base';
import { AuthService, Public } from '../../core/auth';
import { Models } from '../../models';
@@ -34,6 +35,7 @@ export class OAuthController {
) {}
@Public()
@UseNamedGuard('version')
@Post('/preflight')
@HttpCode(HttpStatus.OK)
async preflight(
@@ -63,6 +65,7 @@ export class OAuthController {
}
@Public()
@UseNamedGuard('version')
@Post('/callback')
@HttpCode(HttpStatus.OK)
async callback(

View File

@@ -316,7 +316,7 @@ type EditorType {
name: String!
}
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
union ErrorDataUnion = AlreadyInSpaceDataType | BlobNotFoundDataType | CopilotContextFileNotSupportedDataType | CopilotDocNotFoundDataType | CopilotFailedToMatchContextDataType | CopilotFailedToModifyContextDataType | CopilotInvalidContextDataType | CopilotMessageNotFoundDataType | CopilotPromptNotFoundDataType | CopilotProviderSideErrorDataType | DocActionDeniedDataType | DocHistoryNotFoundDataType | DocNotFoundDataType | ExpectToGrantDocUserRolesDataType | ExpectToRevokeDocUserRolesDataType | ExpectToUpdateDocUserRoleDataType | GraphqlBadRequestDataType | InvalidEmailDataType | InvalidHistoryTimestampDataType | InvalidLicenseUpdateParamsDataType | InvalidPasswordLengthDataType | InvalidRuntimeConfigTypeDataType | MemberNotFoundInSpaceDataType | MissingOauthQueryParameterDataType | NotInSpaceDataType | QueryTooLongDataType | RuntimeConfigNotFoundDataType | SameSubscriptionRecurringDataType | SpaceAccessDeniedDataType | SpaceNotFoundDataType | SpaceOwnerNotFoundDataType | SpaceShouldHaveOnlyOneOwnerDataType | SubscriptionAlreadyExistsDataType | SubscriptionNotExistsDataType | SubscriptionPlanNotFoundDataType | UnknownOauthProviderDataType | UnsupportedClientVersionDataType | UnsupportedSubscriptionPlanDataType | VersionRejectedDataType | WorkspaceMembersExceedLimitToDowngradeDataType | WorkspacePermissionNotFoundDataType | WrongSignInCredentialsDataType
enum ErrorNames {
ACCESS_DENIED
@@ -409,6 +409,7 @@ enum ErrorNames {
TOO_MANY_REQUEST
UNKNOWN_OAUTH_PROVIDER
UNSPLASH_IS_NOT_CONFIGURED
UNSUPPORTED_CLIENT_VERSION
UNSUPPORTED_SUBSCRIPTION_PLAN
USER_AVATAR_NOT_FOUND
USER_NOT_FOUND
@@ -1101,6 +1102,11 @@ type UnknownOauthProviderDataType {
name: String!
}
type UnsupportedClientVersionDataType {
clientVersion: String!
requiredVersion: String!
}
type UnsupportedSubscriptionPlanDataType {
plan: String!
}