From 5268dc4ee2b54cc16b2ddf9a9a07b810f04172ec Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Tue, 27 May 2025 16:33:23 +0200 Subject: [PATCH] feat: version check endpoint (#18572) --- mobile/openapi/README.md | 3 + mobile/openapi/lib/api.dart | 1 + mobile/openapi/lib/api/server_api.dart | 41 +++++++ .../openapi/lib/api/system_metadata_api.dart | 41 +++++++ mobile/openapi/lib/api_client.dart | 2 + .../version_check_state_response_dto.dart | 115 ++++++++++++++++++ open-api/immich-openapi-specs.json | 81 ++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 20 +++ .../src/controllers/server.controller.spec.ts | 3 + server/src/controllers/server.controller.ts | 9 ++ .../controllers/system-metadata.controller.ts | 12 +- server/src/dtos/system-metadata.dto.ts | 5 + .../src/services/system-metadata.service.ts | 6 + 13 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 mobile/openapi/lib/model/version_check_state_response_dto.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 64b85939b3d..ab9814d0f0b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -192,6 +192,7 @@ Class | Method | HTTP request | Description *ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage | *ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types | *ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme | +*ServerApi* | [**getVersionCheck**](doc//ServerApi.md#getversioncheck) | **GET** /server/version-check | *ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history | *ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping | *ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | @@ -226,6 +227,7 @@ Class | Method | HTTP request | Description *SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | *SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | *SystemMetadataApi* | [**getReverseGeocodingState**](doc//SystemMetadataApi.md#getreversegeocodingstate) | **GET** /system-metadata/reverse-geocoding-state | +*SystemMetadataApi* | [**getVersionCheckState**](doc//SystemMetadataApi.md#getversioncheckstate) | **GET** /system-metadata/version-check-state | *SystemMetadataApi* | [**updateAdminOnboarding**](doc//SystemMetadataApi.md#updateadminonboarding) | **POST** /system-metadata/admin-onboarding | *TagsApi* | [**bulkTagAssets**](doc//TagsApi.md#bulktagassets) | **PUT** /tags/assets | *TagsApi* | [**createTag**](doc//TagsApi.md#createtag) | **POST** /tags | @@ -525,6 +527,7 @@ Class | Method | HTTP request | Description - [ValidateLibraryDto](doc//ValidateLibraryDto.md) - [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md) - [ValidateLibraryResponseDto](doc//ValidateLibraryResponseDto.md) + - [VersionCheckStateResponseDto](doc//VersionCheckStateResponseDto.md) - [VideoCodec](doc//VideoCodec.md) - [VideoContainer](doc//VideoContainer.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index aa8ae348aa4..d3a342db6c1 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -320,6 +320,7 @@ part 'model/validate_access_token_response_dto.dart'; part 'model/validate_library_dto.dart'; part 'model/validate_library_import_path_response_dto.dart'; part 'model/validate_library_response_dto.dart'; +part 'model/version_check_state_response_dto.dart'; part 'model/video_codec.dart'; part 'model/video_container.dart'; diff --git a/mobile/openapi/lib/api/server_api.dart b/mobile/openapi/lib/api/server_api.dart index 629949db32e..a0fd54f3d2b 100644 --- a/mobile/openapi/lib/api/server_api.dart +++ b/mobile/openapi/lib/api/server_api.dart @@ -418,6 +418,47 @@ class ServerApi { return null; } + /// Performs an HTTP 'GET /server/version-check' operation and returns the [Response]. + Future getVersionCheckWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/server/version-check'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getVersionCheck() async { + final response = await getVersionCheckWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'VersionCheckStateResponseDto',) as VersionCheckStateResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /server/version-history' operation and returns the [Response]. Future getVersionHistoryWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api/system_metadata_api.dart b/mobile/openapi/lib/api/system_metadata_api.dart index 3bd8bddcac5..3fcceb8e420 100644 --- a/mobile/openapi/lib/api/system_metadata_api.dart +++ b/mobile/openapi/lib/api/system_metadata_api.dart @@ -98,6 +98,47 @@ class SystemMetadataApi { return null; } + /// Performs an HTTP 'GET /system-metadata/version-check-state' operation and returns the [Response]. + Future getVersionCheckStateWithHttpInfo() async { + // ignore: prefer_const_declarations + final apiPath = r'/system-metadata/version-check-state'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getVersionCheckState() async { + final response = await getVersionCheckStateWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'VersionCheckStateResponseDto',) as VersionCheckStateResponseDto; + + } + return null; + } + /// Performs an HTTP 'POST /system-metadata/admin-onboarding' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index a1240c800c9..cc01fd2c064 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -696,6 +696,8 @@ class ApiClient { return ValidateLibraryImportPathResponseDto.fromJson(value); case 'ValidateLibraryResponseDto': return ValidateLibraryResponseDto.fromJson(value); + case 'VersionCheckStateResponseDto': + return VersionCheckStateResponseDto.fromJson(value); case 'VideoCodec': return VideoCodecTypeTransformer().decode(value); case 'VideoContainer': diff --git a/mobile/openapi/lib/model/version_check_state_response_dto.dart b/mobile/openapi/lib/model/version_check_state_response_dto.dart new file mode 100644 index 00000000000..d3f9a6cd959 --- /dev/null +++ b/mobile/openapi/lib/model/version_check_state_response_dto.dart @@ -0,0 +1,115 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class VersionCheckStateResponseDto { + /// Returns a new [VersionCheckStateResponseDto] instance. + VersionCheckStateResponseDto({ + required this.checkedAt, + required this.releaseVersion, + }); + + String? checkedAt; + + String? releaseVersion; + + @override + bool operator ==(Object other) => identical(this, other) || other is VersionCheckStateResponseDto && + other.checkedAt == checkedAt && + other.releaseVersion == releaseVersion; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (checkedAt == null ? 0 : checkedAt!.hashCode) + + (releaseVersion == null ? 0 : releaseVersion!.hashCode); + + @override + String toString() => 'VersionCheckStateResponseDto[checkedAt=$checkedAt, releaseVersion=$releaseVersion]'; + + Map toJson() { + final json = {}; + if (this.checkedAt != null) { + json[r'checkedAt'] = this.checkedAt; + } else { + // json[r'checkedAt'] = null; + } + if (this.releaseVersion != null) { + json[r'releaseVersion'] = this.releaseVersion; + } else { + // json[r'releaseVersion'] = null; + } + return json; + } + + /// Returns a new [VersionCheckStateResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static VersionCheckStateResponseDto? fromJson(dynamic value) { + upgradeDto(value, "VersionCheckStateResponseDto"); + if (value is Map) { + final json = value.cast(); + + return VersionCheckStateResponseDto( + checkedAt: mapValueOfType(json, r'checkedAt'), + releaseVersion: mapValueOfType(json, r'releaseVersion'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = VersionCheckStateResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = VersionCheckStateResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of VersionCheckStateResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = VersionCheckStateResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'checkedAt', + 'releaseVersion', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f42f4204bba..536d366c578 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -5563,6 +5563,38 @@ ] } }, + "/server/version-check": { + "get": { + "operationId": "getVersionCheck", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionCheckStateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server" + ] + } + }, "/server/version-history": { "get": { "operationId": "getVersionHistory", @@ -6846,6 +6878,38 @@ ] } }, + "/system-metadata/version-check-state": { + "get": { + "operationId": "getVersionCheckState", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/VersionCheckStateResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "System Metadata" + ] + } + }, "/tags": { "get": { "operationId": "getAllTags", @@ -14939,6 +15003,23 @@ }, "type": "object" }, + "VersionCheckStateResponseDto": { + "properties": { + "checkedAt": { + "nullable": true, + "type": "string" + }, + "releaseVersion": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "checkedAt", + "releaseVersion" + ], + "type": "object" + }, "VideoCodec": { "enum": [ "h264", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 866e9cb6bd5..b119bc01f4d 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1076,6 +1076,10 @@ export type ServerVersionResponseDto = { minor: number; patch: number; }; +export type VersionCheckStateResponseDto = { + checkedAt: string | null; + releaseVersion: string | null; +}; export type ServerVersionHistoryResponseDto = { createdAt: string; id: string; @@ -2947,6 +2951,14 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function getVersionCheck(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: VersionCheckStateResponseDto; + }>("/server/version-check", { + ...opts + })); +} export function getVersionHistory(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -3284,6 +3296,14 @@ export function getReverseGeocodingState(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function getVersionCheckState(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: VersionCheckStateResponseDto; + }>("/system-metadata/version-check-state", { + ...opts + })); +} export function getAllTags(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/src/controllers/server.controller.spec.ts b/server/src/controllers/server.controller.spec.ts index cc373162ebc..6b00490d288 100644 --- a/server/src/controllers/server.controller.spec.ts +++ b/server/src/controllers/server.controller.spec.ts @@ -1,5 +1,6 @@ import { ServerController } from 'src/controllers/server.controller'; import { ServerService } from 'src/services/server.service'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; import { VersionService } from 'src/services/version.service'; import request from 'supertest'; import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils'; @@ -7,11 +8,13 @@ import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils' describe(ServerController.name, () => { let ctx: ControllerContext; const serverService = mockBaseService(ServerService); + const systemMetadataService = mockBaseService(SystemMetadataService); const versionService = mockBaseService(VersionService); beforeAll(async () => { ctx = await controllerSetup(ServerController, [ { provide: ServerService, useValue: serverService }, + { provide: SystemMetadataService, useValue: systemMetadataService }, { provide: VersionService, useValue: versionService }, ]); return () => ctx.close(); diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index 8327ff6d1d4..267fc42ef4a 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -13,8 +13,10 @@ import { ServerVersionHistoryResponseDto, ServerVersionResponseDto, } from 'src/dtos/server.dto'; +import { VersionCheckStateResponseDto } from 'src/dtos/system-metadata.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { ServerService } from 'src/services/server.service'; +import { SystemMetadataService } from 'src/services/system-metadata.service'; import { VersionService } from 'src/services/version.service'; @ApiTags('Server') @@ -22,6 +24,7 @@ import { VersionService } from 'src/services/version.service'; export class ServerController { constructor( private service: ServerService, + private systemMetadataService: SystemMetadataService, private versionService: VersionService, ) {} @@ -96,4 +99,10 @@ export class ServerController { getServerLicense(): Promise { return this.service.getLicense(); } + + @Get('version-check') + @Authenticated() + getVersionCheck(): Promise { + return this.systemMetadataService.getVersionCheckState(); + } } diff --git a/server/src/controllers/system-metadata.controller.ts b/server/src/controllers/system-metadata.controller.ts index bca5c65d8e4..71c37d02c43 100644 --- a/server/src/controllers/system-metadata.controller.ts +++ b/server/src/controllers/system-metadata.controller.ts @@ -1,6 +1,10 @@ import { Body, Controller, Get, HttpCode, HttpStatus, Post } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto } from 'src/dtos/system-metadata.dto'; +import { + AdminOnboardingUpdateDto, + ReverseGeocodingStateResponseDto, + VersionCheckStateResponseDto, +} from 'src/dtos/system-metadata.dto'; import { Permission } from 'src/enum'; import { Authenticated } from 'src/middleware/auth.guard'; import { SystemMetadataService } from 'src/services/system-metadata.service'; @@ -28,4 +32,10 @@ export class SystemMetadataController { getReverseGeocodingState(): Promise { return this.service.getReverseGeocodingState(); } + + @Get('version-check-state') + @Authenticated({ permission: Permission.SYSTEM_METADATA_READ, admin: true }) + getVersionCheckState(): Promise { + return this.service.getVersionCheckState(); + } } diff --git a/server/src/dtos/system-metadata.dto.ts b/server/src/dtos/system-metadata.dto.ts index 1c044353417..c8e64f23005 100644 --- a/server/src/dtos/system-metadata.dto.ts +++ b/server/src/dtos/system-metadata.dto.ts @@ -13,3 +13,8 @@ export class ReverseGeocodingStateResponseDto { lastUpdate!: string | null; lastImportFileName!: string | null; } + +export class VersionCheckStateResponseDto { + checkedAt!: string | null; + releaseVersion!: string | null; +} diff --git a/server/src/services/system-metadata.service.ts b/server/src/services/system-metadata.service.ts index 93449c7a7b5..750e6b1d0bb 100644 --- a/server/src/services/system-metadata.service.ts +++ b/server/src/services/system-metadata.service.ts @@ -3,6 +3,7 @@ import { AdminOnboardingResponseDto, AdminOnboardingUpdateDto, ReverseGeocodingStateResponseDto, + VersionCheckStateResponseDto, } from 'src/dtos/system-metadata.dto'; import { SystemMetadataKey } from 'src/enum'; import { BaseService } from 'src/services/base.service'; @@ -24,4 +25,9 @@ export class SystemMetadataService extends BaseService { const value = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); return { lastUpdate: null, lastImportFileName: null, ...value }; } + + async getVersionCheckState(): Promise { + const value = await this.systemMetadataRepository.get(SystemMetadataKey.VERSION_CHECK_STATE); + return { checkedAt: null, releaseVersion: null, ...value }; + } }