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

@@ -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;
}
}