1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-01 08:31:59 +00:00

refactor(server): new version check (#9555)

This commit is contained in:
Jason Rasmussen 2024-05-17 12:22:39 -04:00 committed by GitHub
parent 4807fc40a6
commit c03981ac1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 257 additions and 102 deletions

View file

@ -17,7 +17,7 @@ export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
export const ONE_HOUR = Duration.fromObject({ hours: 1 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 });
export const envName = (process.env.NODE_ENV || 'development').toUpperCase(); export const envName = (process.env.NODE_ENV || 'development').toUpperCase();
export const isDev = process.env.NODE_ENV === 'development'; export const isDev = () => process.env.NODE_ENV === 'development';
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload';
export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www';

View file

@ -12,11 +12,15 @@ import {
} from 'src/dtos/server-info.dto'; } from 'src/dtos/server-info.dto';
import { Authenticated } from 'src/middleware/auth.guard'; import { Authenticated } from 'src/middleware/auth.guard';
import { ServerInfoService } from 'src/services/server-info.service'; import { ServerInfoService } from 'src/services/server-info.service';
import { VersionService } from 'src/services/version.service';
@ApiTags('Server Info') @ApiTags('Server Info')
@Controller('server-info') @Controller('server-info')
export class ServerInfoController { export class ServerInfoController {
constructor(private service: ServerInfoService) {} constructor(
private service: ServerInfoService,
private versionService: VersionService,
) {}
@Get() @Get()
@Authenticated() @Authenticated()
@ -31,7 +35,7 @@ export class ServerInfoController {
@Get('version') @Get('version')
getServerVersion(): ServerVersionResponseDto { getServerVersion(): ServerVersionResponseDto {
return this.service.getVersion(); return this.versionService.getVersion();
} }
@Get('features') @Get('features')

View file

@ -1,7 +1,6 @@
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger'; import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
import type { DateTime } from 'luxon';
import { SystemConfigThemeDto } from 'src/dtos/system-config.dto'; import { SystemConfigThemeDto } from 'src/dtos/system-config.dto';
import { IVersion, VersionType } from 'src/utils/version'; import { IVersion } from 'src/utils/version';
export class ServerPingResponse { export class ServerPingResponse {
@ApiResponseProperty({ type: String, example: 'pong' }) @ApiResponseProperty({ type: String, example: 'pong' })
@ -112,8 +111,9 @@ export class ServerFeaturesDto {
} }
export interface ReleaseNotification { export interface ReleaseNotification {
isAvailable: VersionType; isAvailable: boolean;
checkedAt: DateTime<boolean> | null; /** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto; serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto; releaseVersion: ServerVersionResponseDto;
} }

View file

@ -14,10 +14,14 @@ export enum SystemMetadataKey {
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
ADMIN_ONBOARDING = 'admin-onboarding', ADMIN_ONBOARDING = 'admin-onboarding',
SYSTEM_CONFIG = 'system-config', SYSTEM_CONFIG = 'system-config',
VERSION_CHECK_STATE = 'version-check-state',
} }
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> { export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>; [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
} }

View file

@ -100,6 +100,9 @@ export enum JobName {
// Notification // Notification
NOTIFY_SIGNUP = 'notify-signup', NOTIFY_SIGNUP = 'notify-signup',
SEND_EMAIL = 'notification-send-email', SEND_EMAIL = 'notification-send-email',
// Version check
VERSION_CHECK = 'version-check',
} }
export const JOBS_ASSET_PAGINATION_SIZE = 1000; export const JOBS_ASSET_PAGINATION_SIZE = 1000;
@ -243,7 +246,10 @@ export type JobItem =
// Notification // Notification
| { name: JobName.SEND_EMAIL; data: IEmailJob } | { name: JobName.SEND_EMAIL; data: IEmailJob }
| { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob }; | { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob }
// Version check
| { name: JobName.VERSION_CHECK; data: IBaseJob };
export enum JobStatus { export enum JobStatus {
SUCCESS = 'success', SUCCESS = 'success',

View file

@ -86,6 +86,9 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
// Notification // Notification
[JobName.SEND_EMAIL]: QueueName.NOTIFICATION, [JobName.SEND_EMAIL]: QueueName.NOTIFICATION,
[JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION, [JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION,
// Version check
[JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK,
}; };
@Instrumentation() @Instrumentation()

View file

@ -12,6 +12,7 @@ import { ServerInfoService } from 'src/services/server-info.service';
import { SharedLinkService } from 'src/services/shared-link.service'; import { SharedLinkService } from 'src/services/shared-link.service';
import { StorageService } from 'src/services/storage.service'; import { StorageService } from 'src/services/storage.service';
import { SystemConfigService } from 'src/services/system-config.service'; import { SystemConfigService } from 'src/services/system-config.service';
import { VersionService } from 'src/services/version.service';
import { OpenGraphTags } from 'src/utils/misc'; import { OpenGraphTags } from 'src/utils/misc';
const render = (index: string, meta: OpenGraphTags) => { const render = (index: string, meta: OpenGraphTags) => {
@ -44,6 +45,7 @@ export class ApiService {
private sharedLinkService: SharedLinkService, private sharedLinkService: SharedLinkService,
private storageService: StorageService, private storageService: StorageService,
private databaseService: DatabaseService, private databaseService: DatabaseService,
private versionService: VersionService,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
) { ) {
this.logger.setContext(ApiService.name); this.logger.setContext(ApiService.name);
@ -51,7 +53,7 @@ export class ApiService {
@Interval(ONE_HOUR.as('milliseconds')) @Interval(ONE_HOUR.as('milliseconds'))
async onVersionCheck() { async onVersionCheck() {
await this.serverService.handleVersionCheck(); await this.versionService.handleQueueVersionCheck();
} }
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
@ -64,6 +66,7 @@ export class ApiService {
await this.configService.init(); await this.configService.init();
this.storageService.init(); this.storageService.init();
await this.serverService.init(); await this.serverService.init();
await this.versionService.init();
this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`);
} }

View file

@ -32,6 +32,7 @@ import { TagService } from 'src/services/tag.service';
import { TimelineService } from 'src/services/timeline.service'; import { TimelineService } from 'src/services/timeline.service';
import { TrashService } from 'src/services/trash.service'; import { TrashService } from 'src/services/trash.service';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
export const services = [ export const services = [
ApiService, ApiService,
@ -68,4 +69,5 @@ export const services = [
TimelineService, TimelineService,
TrashService, TrashService,
UserService, UserService,
VersionService,
]; ];

View file

@ -16,6 +16,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service';
import { StorageService } from 'src/services/storage.service'; import { StorageService } from 'src/services/storage.service';
import { SystemConfigService } from 'src/services/system-config.service'; import { SystemConfigService } from 'src/services/system-config.service';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
import { VersionService } from 'src/services/version.service';
import { otelSDK } from 'src/utils/instrumentation'; import { otelSDK } from 'src/utils/instrumentation';
@Injectable() @Injectable()
@ -37,6 +38,7 @@ export class MicroservicesService {
private storageService: StorageService, private storageService: StorageService,
private userService: UserService, private userService: UserService,
private duplicateService: DuplicateService, private duplicateService: DuplicateService,
private versionService: VersionService,
) {} ) {}
async init() { async init() {
@ -89,6 +91,7 @@ export class MicroservicesService {
[JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(),
[JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data),
[JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data),
[JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(),
}); });
await this.metadataService.init(); await this.metadataService.init();

View file

@ -1,37 +1,28 @@
import { serverVersion } from 'src/constants';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface'; import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerInfoService } from 'src/services/server-info.service'; import { ServerInfoService } from 'src/services/server-info.service';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest'; import { Mocked } from 'vitest';
describe(ServerInfoService.name, () => { describe(ServerInfoService.name, () => {
let sut: ServerInfoService; let sut: ServerInfoService;
let eventMock: Mocked<IEventRepository>;
let serverInfoMock: Mocked<IServerInfoRepository>;
let storageMock: Mocked<IStorageRepository>; let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>; let userMock: Mocked<IUserRepository>;
let systemMock: Mocked<ISystemMetadataRepository>; let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>; let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => { beforeEach(() => {
eventMock = newEventRepositoryMock();
serverInfoMock = newServerInfoRepositoryMock();
storageMock = newStorageRepositoryMock(); storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock(); userMock = newUserRepositoryMock();
systemMock = newSystemMetadataRepositoryMock(); systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock(); loggerMock = newLoggerRepositoryMock();
sut = new ServerInfoService(eventMock, userMock, serverInfoMock, storageMock, systemMock, loggerMock); sut = new ServerInfoService(userMock, storageMock, systemMock, loggerMock);
}); });
it('should work', () => { it('should work', () => {
@ -154,12 +145,6 @@ describe(ServerInfoService.name, () => {
}); });
}); });
describe('getVersion', () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual(serverVersion);
});
});
describe('getFeatures', () => { describe('getFeatures', () => {
it('should respond the server features', async () => { it('should respond the server features', async () => {
await expect(sut.getFeatures()).resolves.toEqual({ await expect(sut.getFeatures()).resolves.toEqual({

View file

@ -1,9 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { isDev, serverVersion } from 'src/constants';
import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { StorageCore, StorageFolder } from 'src/cores/storage.core';
import { SystemConfigCore } from 'src/cores/system-config.core'; import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnServerEvent } from 'src/decorators';
import { import {
ServerConfigDto, ServerConfigDto,
ServerFeaturesDto, ServerFeaturesDto,
@ -14,27 +11,20 @@ import {
UsageByUserDto, UsageByUserDto,
} from 'src/dtos/server-info.dto'; } from 'src/dtos/server-info.dto';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
import { asHumanReadable } from 'src/utils/bytes'; import { asHumanReadable } from 'src/utils/bytes';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc';
import { Version } from 'src/utils/version';
@Injectable() @Injectable()
export class ServerInfoService { export class ServerInfoService {
private configCore: SystemConfigCore; private configCore: SystemConfigCore;
private releaseVersion = serverVersion;
private releaseVersionCheckedAt: DateTime | null = null;
constructor( constructor(
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@ -43,11 +33,7 @@ export class ServerInfoService {
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
} }
onConnect() {}
async init(): Promise<void> { async init(): Promise<void> {
await this.handleVersionCheck();
const featureFlags = await this.getFeatures(); const featureFlags = await this.getFeatures();
if (featureFlags.configFile) { if (featureFlags.configFile) {
await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, {
@ -77,10 +63,6 @@ export class ServerInfoService {
return { res: 'pong' }; return { res: 'pong' };
} }
getVersion() {
return serverVersion;
}
async getFeatures(): Promise<ServerFeaturesDto> { async getFeatures(): Promise<ServerFeaturesDto> {
const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } = const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } =
await this.configCore.getConfig(); await this.configCore.getConfig();
@ -152,57 +134,4 @@ export class ServerInfoService {
sidecar: Object.keys(mimeTypes.sidecar), sidecar: Object.keys(mimeTypes.sidecar),
}; };
} }
async handleVersionCheck(): Promise<boolean> {
try {
if (isDev) {
return true;
}
const { newVersionCheck } = await this.configCore.getConfig();
if (!newVersionCheck.enabled) {
return true;
}
// check once per hour (max)
if (this.releaseVersionCheckedAt && DateTime.now().diff(this.releaseVersionCheckedAt).as('minutes') < 60) {
return true;
}
const githubRelease = await this.repository.getGitHubRelease();
const githubVersion = Version.fromString(githubRelease.tag_name);
const publishedAt = new Date(githubRelease.published_at);
this.releaseVersion = githubVersion;
this.releaseVersionCheckedAt = DateTime.now();
if (githubVersion.isNewerThan(serverVersion)) {
this.logger.log(`Found ${githubVersion.toString()}, released at ${publishedAt.toLocaleString()}`);
this.newReleaseNotification();
}
} catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
}
return true;
}
@OnServerEvent(ServerEvent.WEBSOCKET_CONNECT)
onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) {
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
this.newReleaseNotification(userId);
}
private newReleaseNotification(userId?: string) {
const event = ClientEvent.NEW_RELEASE;
const payload = {
isAvailable: this.releaseVersion.isNewerThan(serverVersion),
checkedAt: this.releaseVersionCheckedAt,
serverVersion,
releaseVersion: this.releaseVersion,
};
userId
? this.eventRepository.clientSend(event, userId, payload)
: this.eventRepository.clientBroadcast(event, payload);
}
} }

View file

@ -0,0 +1,110 @@
import { DateTime } from 'luxon';
import { serverVersion } from 'src/constants';
import { SystemMetadataKey } from 'src/entities/system-metadata.entity';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { VersionService } from 'src/services/version.service';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/system-info.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { Mocked } from 'vitest';
const mockRelease = (version = '100.0.0') => ({
id: 1,
url: 'https://api.github.com/repos/owner/repo/releases/1',
tag_name: 'v' + version,
name: 'Release 1000',
created_at: DateTime.utc().toISO(),
published_at: DateTime.utc().toISO(),
body: '',
});
describe(VersionService.name, () => {
let sut: VersionService;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let serverMock: Mocked<IServerInfoRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
serverMock = newServerInfoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new VersionService(eventMock, jobMock, serverMock, systemMock, loggerMock);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('getVersion', () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual(serverVersion);
});
});
describe('handQueueVersionCheck', () => {
it('should queue a version check job', async () => {
await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined();
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.VERSION_CHECK, data: {} });
});
});
describe('handVersionCheck', () => {
beforeEach(() => {
process.env.NODE_ENV = 'production';
});
it('should not run in dev mode', async () => {
process.env.NODE_ENV = 'development';
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED);
});
it('should not run if the last check was < 60 minutes ago', async () => {
systemMock.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 5 }).toISO(),
releaseVersion: '1.0.0',
});
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SKIPPED);
});
it('should run if it has been > 60 minutes', async () => {
serverMock.getGitHubRelease.mockResolvedValue(mockRelease());
systemMock.get.mockResolvedValue({
checkedAt: DateTime.utc().minus({ minutes: 65 }).toISO(),
releaseVersion: '1.0.0',
});
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(systemMock.set).toHaveBeenCalled();
expect(loggerMock.log).toHaveBeenCalled();
expect(eventMock.clientBroadcast).toHaveBeenCalled();
});
it('should not notify if the version is equal', async () => {
serverMock.getGitHubRelease.mockResolvedValue(mockRelease(serverVersion.toString()));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.SUCCESS);
expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.VERSION_CHECK_STATE, {
checkedAt: expect.any(String),
releaseVersion: serverVersion.toString(),
});
expect(eventMock.clientBroadcast).not.toHaveBeenCalled();
});
it('should handle a github error', async () => {
serverMock.getGitHubRelease.mockRejectedValue(new Error('GitHub is down'));
await expect(sut.handleVersionCheck()).resolves.toEqual(JobStatus.FAILED);
expect(systemMock.set).not.toHaveBeenCalled();
expect(eventMock.clientBroadcast).not.toHaveBeenCalled();
expect(loggerMock.warn).toHaveBeenCalled();
});
});
});

View file

@ -0,0 +1,105 @@
import { Inject, Injectable } from '@nestjs/common';
import { DateTime } from 'luxon';
import { isDev, serverVersion } from 'src/constants';
import { SystemConfigCore } from 'src/cores/system-config.core';
import { OnServerEvent } from 'src/decorators';
import { ReleaseNotification } from 'src/dtos/server-info.dto';
import { SystemMetadataKey, VersionCheckMetadata } from 'src/entities/system-metadata.entity';
import { ClientEvent, IEventRepository, ServerEvent, ServerEventMap } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { Version } from 'src/utils/version';
const asNotification = ({ releaseVersion, checkedAt }: VersionCheckMetadata): ReleaseNotification => {
const version = Version.fromString(releaseVersion);
return {
isAvailable: version.isNewerThan(serverVersion) !== 0,
checkedAt,
serverVersion,
releaseVersion: version,
};
};
@Injectable()
export class VersionService {
private configCore: SystemConfigCore;
constructor(
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
@Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
) {
this.logger.setContext(VersionService.name);
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
}
async init(): Promise<void> {
await this.handleVersionCheck();
}
getVersion() {
return serverVersion;
}
async handleQueueVersionCheck() {
await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} });
}
async handleVersionCheck(): Promise<JobStatus> {
try {
this.logger.debug('Running version check');
if (isDev()) {
return JobStatus.SKIPPED;
}
const { newVersionCheck } = await this.configCore.getConfig();
if (!newVersionCheck.enabled) {
return JobStatus.SKIPPED;
}
const versionCheck = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE);
if (versionCheck?.checkedAt) {
const lastUpdate = DateTime.fromISO(versionCheck.checkedAt);
const elapsedTime = DateTime.now().diff(lastUpdate).as('minutes');
// check once per hour (max)
if (elapsedTime < 60) {
return JobStatus.SKIPPED;
}
}
const githubRelease = await this.repository.getGitHubRelease();
const githubVersion = Version.fromString(githubRelease.tag_name);
const metadata: VersionCheckMetadata = {
checkedAt: DateTime.utc().toISO(),
releaseVersion: githubVersion.toString(),
};
await this.systemMetadataRepository.set(SystemMetadataKey.VERSION_CHECK_STATE, metadata);
if (githubVersion.isNewerThan(serverVersion)) {
const publishedAt = new Date(githubRelease.published_at);
this.logger.log(`Found ${githubVersion.toString()}, released at ${publishedAt.toLocaleString()}`);
this.eventRepository.clientBroadcast(ClientEvent.NEW_RELEASE, asNotification(metadata));
}
} catch (error: Error | any) {
this.logger.warn(`Unable to run version check: ${error}`, error?.stack);
return JobStatus.FAILED;
}
return JobStatus.SUCCESS;
}
@OnServerEvent(ServerEvent.WEBSOCKET_CONNECT)
async onWebsocketConnection({ userId }: ServerEventMap[ServerEvent.WEBSOCKET_CONNECT]) {
this.eventRepository.clientSend(ClientEvent.SERVER_VERSION, userId, serverVersion);
const metadata = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE);
if (metadata) {
this.eventRepository.clientSend(ClientEvent.NEW_RELEASE, userId, asNotification(metadata));
}
}
}

View file

@ -11,7 +11,7 @@ import _ from 'lodash';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { CLIP_MODEL_INFO, serverVersion } from 'src/constants'; import { CLIP_MODEL_INFO, isDev, serverVersion } from 'src/constants';
import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto'; import { ImmichCookie, ImmichHeader } from 'src/dtos/auth.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Metadata } from 'src/middleware/auth.guard'; import { Metadata } from 'src/middleware/auth.guard';
@ -174,7 +174,7 @@ const patchOpenAPI = (document: OpenAPIObject) => {
return document; return document;
}; };
export const useSwagger = (app: INestApplication, isDevelopment: boolean) => { export const useSwagger = (app: INestApplication) => {
const config = new DocumentBuilder() const config = new DocumentBuilder()
.setTitle('Immich') .setTitle('Immich')
.setDescription('Immich API') .setDescription('Immich API')
@ -211,7 +211,7 @@ export const useSwagger = (app: INestApplication, isDevelopment: boolean) => {
SwaggerModule.setup('doc', app, specification, customOptions); SwaggerModule.setup('doc', app, specification, customOptions);
if (isDevelopment) { if (isDev()) {
// Generate API Documentation only in development mode // Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json'); const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' }); writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' });

View file

@ -29,11 +29,11 @@ async function bootstrap() {
app.set('etag', 'strong'); app.set('etag', 'strong');
app.use(cookieParser()); app.use(cookieParser());
app.use(json({ limit: '10mb' })); app.use(json({ limit: '10mb' }));
if (isDev) { if (isDev()) {
app.enableCors(); app.enableCors();
} }
app.useWebSocketAdapter(new WebSocketAdapter(app)); app.useWebSocketAdapter(new WebSocketAdapter(app));
useSwagger(app, isDev); useSwagger(app);
app.setGlobalPrefix('api', { exclude: excludePaths }); app.setGlobalPrefix('api', { exclude: excludePaths });
if (existsSync(WEB_ROOT)) { if (existsSync(WEB_ROOT)) {

View file

@ -6,7 +6,8 @@ import { user } from './user.store';
export interface ReleaseEvent { export interface ReleaseEvent {
isAvailable: boolean; isAvailable: boolean;
checkedAt: Date; /** ISO8601 */
checkedAt: string;
serverVersion: ServerVersionResponseDto; serverVersion: ServerVersionResponseDto;
releaseVersion: ServerVersionResponseDto; releaseVersion: ServerVersionResponseDto;
} }