Skip to content

Commit

Permalink
feat(server): client version check (#9205)
Browse files Browse the repository at this point in the history
Co-authored-by: forehalo <forehalo@gmail.com>
  • Loading branch information
darkskygit and forehalo authored Feb 20, 2025
1 parent 4fee2a9 commit fa86f71
Show file tree
Hide file tree
Showing 17 changed files with 369 additions and 10 deletions.
2 changes: 2 additions & 0 deletions packages/backend/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
"react-dom": "19.0.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"semver": "^7.6.3",
"ses": "^1.10.0",
"socket.io": "^4.8.1",
"stripe": "^17.4.0",
Expand Down Expand Up @@ -119,6 +120,7 @@
"@types/on-headers": "^1.0.3",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@types/semver": "^7.5.8",
"@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2",
"ava": "^6.2.0",
Expand Down
148 changes: 148 additions & 0 deletions packages/backend/server/src/__tests__/version.spec.ts
Original file line number Diff line number Diff line change
@@ -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].'
);
});
2 changes: 2 additions & 0 deletions packages/backend/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -225,6 +226,7 @@ export function buildAppModule() {
// graphql server only
.useIf(
config => config.flavor.graphql,
VersionModule,
GqlModule,
StorageModule,
ServerConfigModule,
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/server/src/base/error/def.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
16 changes: 14 additions & 2 deletions packages/backend/server/src/base/error/errors.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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'
Expand All @@ -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,
});
15 changes: 10 additions & 5 deletions packages/backend/server/src/base/guard/guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
4 changes: 3 additions & 1 deletion packages/backend/server/src/core/auth/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export class AuthController {
}

@Public()
@UseNamedGuard('version')
@Post('/preflight')
async preflight(
@Body() params?: { email: string }
Expand Down Expand Up @@ -108,7 +109,7 @@ export class AuthController {
}

@Public()
@UseNamedGuard('captcha')
@UseNamedGuard('version', 'captcha')
@Post('/sign-in')
@Header('content-type', 'application/json')
async signIn(
Expand Down Expand Up @@ -260,6 +261,7 @@ export class AuthController {
}

@Public()
@UseNamedGuard('version')
@Post('/magic-link')
async magicLinkSignIn(
@Req() req: Request,
Expand Down
31 changes: 31 additions & 0 deletions packages/backend/server/src/core/version/config.ts
Original file line number Diff line number Diff line change
@@ -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',
},
});
40 changes: 40 additions & 0 deletions packages/backend/server/src/core/version/guard.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 13 additions & 0 deletions packages/backend/server/src/core/version/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Loading

0 comments on commit fa86f71

Please sign in to comment.