This commit is contained in:
@@ -54,6 +54,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
case 403: return 'forbidden';
|
||||
case 404: return 'not_found';
|
||||
case 409: return 'conflict';
|
||||
case 426: return 'upgrade_required';
|
||||
case 429: return 'too_many_requests';
|
||||
default: return 'internal_error';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { CanActivate, ExecutionContext, HttpException, Injectable } from '@nestjs/common';
|
||||
import { APP_VERSION_HEADER, isAppVersionSupported, minAppVersion } from './app-version';
|
||||
|
||||
/**
|
||||
* Refuses terminals running an app older than the minimum version, so they ask their user to update instead of
|
||||
* syncing with a backoffice they no longer understand. Runs before device auth: an outdated app gets this answer
|
||||
* even when its token is fine.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AppVersionGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const minVersion = minAppVersion();
|
||||
if (!isAppVersionSupported(request.headers[APP_VERSION_HEADER], minVersion)) {
|
||||
throw new HttpException(
|
||||
{ code: 'app_update_required', message: `Update the app to version ${minVersion} or newer` },
|
||||
426, // Upgrade Required; HttpStatus has no name for it
|
||||
);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { isAppVersionSupported, parseAppVersion } from './app-version';
|
||||
|
||||
describe('parseAppVersion', () => {
|
||||
it('reads the version and ignores the build number', () => {
|
||||
expect(parseAppVersion('1.4.2+37')).toEqual([1, 4, 2]);
|
||||
expect(parseAppVersion('2.0')).toEqual([2, 0]);
|
||||
});
|
||||
|
||||
it('rejects anything that is not a version', () => {
|
||||
expect(parseAppVersion(undefined)).toBeNull();
|
||||
expect(parseAppVersion('')).toBeNull();
|
||||
expect(parseAppVersion('latest')).toBeNull();
|
||||
expect(parseAppVersion(['1.0.0'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAppVersionSupported', () => {
|
||||
it('refuses apps that send no version', () => {
|
||||
expect(isAppVersionSupported(undefined, '1.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('compares each part as a number', () => {
|
||||
expect(isAppVersionSupported('1.10.0', '1.9.0')).toBe(true);
|
||||
expect(isAppVersionSupported('1.2.0', '1.10.0')).toBe(false);
|
||||
expect(isAppVersionSupported('1.2.0+99', '1.2.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the minimum itself and newer', () => {
|
||||
expect(isAppVersionSupported('1.2.0', '1.2.0')).toBe(true);
|
||||
expect(isAppVersionSupported('1.2', '1.2.0')).toBe(true);
|
||||
expect(isAppVersionSupported('2.0.0', '1.9.9')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* The oldest terminal app that may use the backoffice. Raise it with every app release that older versions must not
|
||||
* keep syncing past; MIN_APP_VERSION overrides it without a deploy.
|
||||
*/
|
||||
export const MIN_APP_VERSION = '1.4.0';
|
||||
|
||||
export const APP_VERSION_HEADER = 'x-app-version';
|
||||
|
||||
export function minAppVersion(): string {
|
||||
return process.env.MIN_APP_VERSION?.trim() || MIN_APP_VERSION;
|
||||
}
|
||||
|
||||
/** `1.4.2+37` → [1, 4, 2]; the build number after `+` is ignored. Null for anything that isn't a version */
|
||||
export function parseAppVersion(value: unknown): number[] | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const match = /^\s*v?(\d+(?:\.\d+)*)(?:[-+].*)?\s*$/.exec(value);
|
||||
return match ? match[1].split('.').map(Number) : null;
|
||||
}
|
||||
|
||||
/** Whether [version] is at least [minimum]. Apps from before the version header send none and are outdated */
|
||||
export function isAppVersionSupported(version: unknown, minimum = minAppVersion()): boolean {
|
||||
const current = parseAppVersion(version);
|
||||
const required = parseAppVersion(minimum);
|
||||
if (!required) return true;
|
||||
if (!current) return false;
|
||||
for (let i = 0; i < Math.max(current.length, required.length); i++) {
|
||||
const diff = (current[i] ?? 0) - (required[i] ?? 0);
|
||||
if (diff !== 0) return diff > 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { DevicesService } from './devices.service';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { DeviceAuthGuard } from '../auth/guards/device-auth.guard';
|
||||
import { AppVersionGuard } from './app-version.guard';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { RegisterDeviceDto } from './dto/register-device.dto';
|
||||
@@ -38,6 +39,7 @@ export class DevicesController {
|
||||
|
||||
@Post('register')
|
||||
@Throttle({ default: { limit: 10, ttl: 60000 } })
|
||||
@UseGuards(AppVersionGuard)
|
||||
@ApiOperation({ summary: 'Register a new device' })
|
||||
@ApiResponse({ status: 201, description: 'Device registered successfully' })
|
||||
async register(@Body() dto: RegisterDeviceDto) {
|
||||
@@ -48,7 +50,7 @@ export class DevicesController {
|
||||
// traffic from the venue's shared IP
|
||||
@Post(':deviceId/sync')
|
||||
@SkipThrottle()
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@UseGuards(AppVersionGuard, DeviceAuthGuard)
|
||||
@ApiOperation({ summary: 'Sync device state with server' })
|
||||
@ApiResponse({ status: 200, description: 'Sync successful, returns pending commands' })
|
||||
async sync(@Param('deviceId') deviceId: string, @Body() dto: SyncDto, @Req() req: any) {
|
||||
@@ -59,7 +61,7 @@ export class DevicesController {
|
||||
// so an offline edit and its picture follow the same rules as every other change
|
||||
@Post(':deviceId/images')
|
||||
@SkipThrottle()
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@UseGuards(AppVersionGuard, DeviceAuthGuard)
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_UPLOAD_BYTES } }))
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiOperation({ summary: 'Upload a product picture from a terminal' })
|
||||
@@ -69,7 +71,7 @@ export class DevicesController {
|
||||
|
||||
@Post(':deviceId/commands/:commandId/ack')
|
||||
@SkipThrottle()
|
||||
@UseGuards(DeviceAuthGuard)
|
||||
@UseGuards(AppVersionGuard, DeviceAuthGuard)
|
||||
@ApiOperation({ summary: 'Acknowledge a command result' })
|
||||
@ApiResponse({ status: 200, description: 'Command acknowledged' })
|
||||
async ackCommand(
|
||||
|
||||
Reference in New Issue
Block a user