From c03981ac1d87ade23a96652291320feac1e5bad7 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 17 May 2024 12:22:39 -0400 Subject: [PATCH] refactor(server): new version check (#9555) --- server/src/constants.ts | 2 +- .../src/controllers/server-info.controller.ts | 8 +- server/src/dtos/server-info.dto.ts | 8 +- server/src/entities/system-metadata.entity.ts | 4 + server/src/interfaces/job.interface.ts | 8 +- server/src/repositories/job.repository.ts | 3 + server/src/services/api.service.ts | 5 +- server/src/services/index.ts | 2 + server/src/services/microservices.service.ts | 3 + .../src/services/server-info.service.spec.ts | 17 +-- server/src/services/server-info.service.ts | 71 ----------- server/src/services/version.service.spec.ts | 110 ++++++++++++++++++ server/src/services/version.service.ts | 105 +++++++++++++++++ server/src/utils/misc.ts | 6 +- server/src/workers/api.ts | 4 +- web/src/lib/stores/websocket.ts | 3 +- 16 files changed, 257 insertions(+), 102 deletions(-) create mode 100644 server/src/services/version.service.spec.ts create mode 100644 server/src/services/version.service.ts diff --git a/server/src/constants.ts b/server/src/constants.ts index b6d6de815e..937dcf373a 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -17,7 +17,7 @@ export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 }); export const ONE_HOUR = Duration.fromObject({ hours: 1 }); 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 WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index 758c50299a..960acfe3fd 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -12,11 +12,15 @@ import { } from 'src/dtos/server-info.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { ServerInfoService } from 'src/services/server-info.service'; +import { VersionService } from 'src/services/version.service'; @ApiTags('Server Info') @Controller('server-info') export class ServerInfoController { - constructor(private service: ServerInfoService) {} + constructor( + private service: ServerInfoService, + private versionService: VersionService, + ) {} @Get() @Authenticated() @@ -31,7 +35,7 @@ export class ServerInfoController { @Get('version') getServerVersion(): ServerVersionResponseDto { - return this.service.getVersion(); + return this.versionService.getVersion(); } @Get('features') diff --git a/server/src/dtos/server-info.dto.ts b/server/src/dtos/server-info.dto.ts index 210e1f894f..11a3bd1250 100644 --- a/server/src/dtos/server-info.dto.ts +++ b/server/src/dtos/server-info.dto.ts @@ -1,7 +1,6 @@ import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger'; -import type { DateTime } from 'luxon'; import { SystemConfigThemeDto } from 'src/dtos/system-config.dto'; -import { IVersion, VersionType } from 'src/utils/version'; +import { IVersion } from 'src/utils/version'; export class ServerPingResponse { @ApiResponseProperty({ type: String, example: 'pong' }) @@ -112,8 +111,9 @@ export class ServerFeaturesDto { } export interface ReleaseNotification { - isAvailable: VersionType; - checkedAt: DateTime | null; + isAvailable: boolean; + /** ISO8601 */ + checkedAt: string; serverVersion: ServerVersionResponseDto; releaseVersion: ServerVersionResponseDto; } diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index fcbc66edae..b097c21200 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -14,10 +14,14 @@ export enum SystemMetadataKey { REVERSE_GEOCODING_STATE = 'reverse-geocoding-state', ADMIN_ONBOARDING = 'admin-onboarding', SYSTEM_CONFIG = 'system-config', + VERSION_CHECK_STATE = 'version-check-state', } +export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; + export interface SystemMetadata extends Record> { [SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string }; [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; [SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial; + [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; } diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index e5ba7f43eb..37401df896 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -100,6 +100,9 @@ export enum JobName { // Notification NOTIFY_SIGNUP = 'notify-signup', SEND_EMAIL = 'notification-send-email', + + // Version check + VERSION_CHECK = 'version-check', } export const JOBS_ASSET_PAGINATION_SIZE = 1000; @@ -243,7 +246,10 @@ export type JobItem = // Notification | { 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 { SUCCESS = 'success', diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index c708ea3767..606549454d 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -86,6 +86,9 @@ export const JOBS_TO_QUEUE: Record = { // Notification [JobName.SEND_EMAIL]: QueueName.NOTIFICATION, [JobName.NOTIFY_SIGNUP]: QueueName.NOTIFICATION, + + // Version check + [JobName.VERSION_CHECK]: QueueName.BACKGROUND_TASK, }; @Instrumentation() diff --git a/server/src/services/api.service.ts b/server/src/services/api.service.ts index fb9912da95..9c786a848f 100644 --- a/server/src/services/api.service.ts +++ b/server/src/services/api.service.ts @@ -12,6 +12,7 @@ import { ServerInfoService } from 'src/services/server-info.service'; import { SharedLinkService } from 'src/services/shared-link.service'; import { StorageService } from 'src/services/storage.service'; import { SystemConfigService } from 'src/services/system-config.service'; +import { VersionService } from 'src/services/version.service'; import { OpenGraphTags } from 'src/utils/misc'; const render = (index: string, meta: OpenGraphTags) => { @@ -44,6 +45,7 @@ export class ApiService { private sharedLinkService: SharedLinkService, private storageService: StorageService, private databaseService: DatabaseService, + private versionService: VersionService, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ApiService.name); @@ -51,7 +53,7 @@ export class ApiService { @Interval(ONE_HOUR.as('milliseconds')) async onVersionCheck() { - await this.serverService.handleVersionCheck(); + await this.versionService.handleQueueVersionCheck(); } @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) @@ -64,6 +66,7 @@ export class ApiService { await this.configService.init(); this.storageService.init(); await this.serverService.init(); + await this.versionService.init(); this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`); } diff --git a/server/src/services/index.ts b/server/src/services/index.ts index f130da2349..c9331c00c7 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -32,6 +32,7 @@ import { TagService } from 'src/services/tag.service'; import { TimelineService } from 'src/services/timeline.service'; import { TrashService } from 'src/services/trash.service'; import { UserService } from 'src/services/user.service'; +import { VersionService } from 'src/services/version.service'; export const services = [ ApiService, @@ -68,4 +69,5 @@ export const services = [ TimelineService, TrashService, UserService, + VersionService, ]; diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 2c8302fb1d..1b6abe68f4 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -16,6 +16,7 @@ import { StorageTemplateService } from 'src/services/storage-template.service'; import { StorageService } from 'src/services/storage.service'; import { SystemConfigService } from 'src/services/system-config.service'; import { UserService } from 'src/services/user.service'; +import { VersionService } from 'src/services/version.service'; import { otelSDK } from 'src/utils/instrumentation'; @Injectable() @@ -37,6 +38,7 @@ export class MicroservicesService { private storageService: StorageService, private userService: UserService, private duplicateService: DuplicateService, + private versionService: VersionService, ) {} async init() { @@ -89,6 +91,7 @@ export class MicroservicesService { [JobName.LIBRARY_QUEUE_CLEANUP]: () => this.libraryService.handleQueueCleanup(), [JobName.SEND_EMAIL]: (data) => this.notificationService.handleSendEmail(data), [JobName.NOTIFY_SIGNUP]: (data) => this.notificationService.handleUserSignup(data), + [JobName.VERSION_CHECK]: () => this.versionService.handleVersionCheck(), }); await this.metadataService.init(); diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index ff1a73c216..41f2e95e22 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -1,37 +1,28 @@ -import { serverVersion } from 'src/constants'; -import { IEventRepository } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository } from 'src/interfaces/user.interface'; 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 { 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 { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; import { Mocked } from 'vitest'; describe(ServerInfoService.name, () => { let sut: ServerInfoService; - let eventMock: Mocked; - let serverInfoMock: Mocked; let storageMock: Mocked; let userMock: Mocked; let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { - eventMock = newEventRepositoryMock(); - serverInfoMock = newServerInfoRepositoryMock(); storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new ServerInfoService(eventMock, userMock, serverInfoMock, storageMock, systemMock, loggerMock); + sut = new ServerInfoService(userMock, storageMock, systemMock, loggerMock); }); 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', () => { it('should respond the server features', async () => { await expect(sut.getFeatures()).resolves.toEqual({ diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index 7531b326b2..9e27e9d7ac 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -1,9 +1,6 @@ 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 { SystemConfigCore } from 'src/cores/system-config.core'; -import { OnServerEvent } from 'src/decorators'; import { ServerConfigDto, ServerFeaturesDto, @@ -14,27 +11,20 @@ import { UsageByUserDto, } from 'src/dtos/server-info.dto'; 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 { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { IStorageRepository } from 'src/interfaces/storage.interface'; import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface'; import { asHumanReadable } from 'src/utils/bytes'; import { mimeTypes } from 'src/utils/mime-types'; import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc'; -import { Version } from 'src/utils/version'; @Injectable() export class ServerInfoService { private configCore: SystemConfigCore; - private releaseVersion = serverVersion; - private releaseVersionCheckedAt: DateTime | null = null; constructor( - @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IUserRepository) private userRepository: IUserRepository, - @Inject(IServerInfoRepository) private repository: IServerInfoRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @@ -43,11 +33,7 @@ export class ServerInfoService { this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); } - onConnect() {} - async init(): Promise { - await this.handleVersionCheck(); - const featureFlags = await this.getFeatures(); if (featureFlags.configFile) { await this.systemMetadataRepository.set(SystemMetadataKey.ADMIN_ONBOARDING, { @@ -77,10 +63,6 @@ export class ServerInfoService { return { res: 'pong' }; } - getVersion() { - return serverVersion; - } - async getFeatures(): Promise { const { reverseGeocoding, map, machineLearning, trash, oauth, passwordLogin, notifications } = await this.configCore.getConfig(); @@ -152,57 +134,4 @@ export class ServerInfoService { sidecar: Object.keys(mimeTypes.sidecar), }; } - - async handleVersionCheck(): Promise { - 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); - } } diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts new file mode 100644 index 0000000000..35bb260628 --- /dev/null +++ b/server/src/services/version.service.spec.ts @@ -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; + let jobMock: Mocked; + let serverMock: Mocked; + let systemMock: Mocked; + let loggerMock: Mocked; + + 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(); + }); + }); +}); diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts new file mode 100644 index 0000000000..5c3a15f622 --- /dev/null +++ b/server/src/services/version.service.ts @@ -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 { + await this.handleVersionCheck(); + } + + getVersion() { + return serverVersion; + } + + async handleQueueVersionCheck() { + await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} }); + } + + async handleVersionCheck(): Promise { + 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)); + } + } +} diff --git a/server/src/utils/misc.ts b/server/src/utils/misc.ts index db4687c514..e34a19112d 100644 --- a/server/src/utils/misc.ts +++ b/server/src/utils/misc.ts @@ -11,7 +11,7 @@ import _ from 'lodash'; import { writeFileSync } from 'node:fs'; import path from 'node:path'; 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 { ILoggerRepository } from 'src/interfaces/logger.interface'; import { Metadata } from 'src/middleware/auth.guard'; @@ -174,7 +174,7 @@ const patchOpenAPI = (document: OpenAPIObject) => { return document; }; -export const useSwagger = (app: INestApplication, isDevelopment: boolean) => { +export const useSwagger = (app: INestApplication) => { const config = new DocumentBuilder() .setTitle('Immich') .setDescription('Immich API') @@ -211,7 +211,7 @@ export const useSwagger = (app: INestApplication, isDevelopment: boolean) => { SwaggerModule.setup('doc', app, specification, customOptions); - if (isDevelopment) { + if (isDev()) { // Generate API Documentation only in development mode const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json'); writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' }); diff --git a/server/src/workers/api.ts b/server/src/workers/api.ts index 2423eac072..b6ad3a28fd 100644 --- a/server/src/workers/api.ts +++ b/server/src/workers/api.ts @@ -29,11 +29,11 @@ async function bootstrap() { app.set('etag', 'strong'); app.use(cookieParser()); app.use(json({ limit: '10mb' })); - if (isDev) { + if (isDev()) { app.enableCors(); } app.useWebSocketAdapter(new WebSocketAdapter(app)); - useSwagger(app, isDev); + useSwagger(app); app.setGlobalPrefix('api', { exclude: excludePaths }); if (existsSync(WEB_ROOT)) { diff --git a/web/src/lib/stores/websocket.ts b/web/src/lib/stores/websocket.ts index c0244027e2..9142b3174e 100644 --- a/web/src/lib/stores/websocket.ts +++ b/web/src/lib/stores/websocket.ts @@ -6,7 +6,8 @@ import { user } from './user.store'; export interface ReleaseEvent { isAvailable: boolean; - checkedAt: Date; + /** ISO8601 */ + checkedAt: string; serverVersion: ServerVersionResponseDto; releaseVersion: ServerVersionResponseDto; }