diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f8922166d..ab5c2e6d9 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -124,7 +124,11 @@ jobs: push: ${{ !github.event.pull_request.head.repo.fork }} cache-from: type=registry,ref=ghcr.io/${{ github.repository_owner }}/immich-build-cache:${{matrix.image}} cache-to: ${{ steps.cache-target.outputs.cache-to }} - build-args: | - DEVICE=${{ matrix.device }} tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} + build-args: | + DEVICE=${{ matrix.device }} + BUILD_ID=${{ github.run_id }} + BUILD_IMAGE=${{ github.event_name == 'release' && github.ref_name || steps.metadata.outputs.tags }} + BUILD_SOURCE_REF=${{ github.ref_name }} + BUILD_SOURCE_COMMIT=${{ github.sha }} diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 67fb96bc6..afe85ca4e 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -26,6 +26,16 @@ services: - /etc/localtime:/etc/localtime:ro env_file: - .env + environment: + IMMICH_REPOSITORY: immich-app/immich + IMMICH_REPOSITORY_URL: https://github.com/immich-app/immich + IMMICH_SOURCE_REF: local + IMMICH_SOURCE_COMMIT: af2efbdbbddc27cd06142f22253ccbbbbeec1f55 + IMMICH_SOURCE_URL: https://github.com/immich-app/immich/commit/af2efbdbbddc27cd06142f22253ccbbbbeec1f55 + IMMICH_BUILD: '9654404849' + IMMICH_BUILD_URL: https://github.com/immich-app/immich/actions/runs/9654404849 + IMMICH_BUILD_IMAGE: development + IMMICH_BUILD_IMAGE_URL: https://github.com/immich-app/immich/pkgs/container/immich-server ulimits: nofile: soft: 1048576 @@ -107,7 +117,22 @@ services: interval: 5m start_interval: 30s start_period: 5m - command: ["postgres", "-c" ,"shared_preload_libraries=vectors.so", "-c", 'search_path="$$user", public, vectors', "-c", "logging_collector=on", "-c", "max_wal_size=2GB", "-c", "shared_buffers=512MB", "-c", "wal_compression=on"] + command: + [ + 'postgres', + '-c', + 'shared_preload_libraries=vectors.so', + '-c', + 'search_path="$$user", public, vectors', + '-c', + 'logging_collector=on', + '-c', + 'max_wal_size=2GB', + '-c', + 'shared_buffers=512MB', + '-c', + 'wal_compression=on', + ] # set IMMICH_METRICS=true in .env to enable metrics # immich-prometheus: diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 1b2e11fc6..04bcd0032 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -10,6 +10,11 @@ services: build: context: ../ dockerfile: server/Dockerfile + args: + - BUILD_ID=1234567890 + - BUILD_IMAGE=e2e + - BUILD_SOURCE_REF=e2e + - BUILD_SOURCE_COMMIT=e2eeeeeeeeeeeeeeeeee environment: - DB_HOSTNAME=database - DB_USERNAME=postgres diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 431971ac8..2711b8624 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -15,6 +15,39 @@ describe('/server-info', () => { nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); }); + describe('GET /server-info/about', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/server-info/about'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return about information', async () => { + const { status, body } = await request(app) + .get('/server-info/about') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + version: expect.any(String), + versionUrl: expect.any(String), + repository: 'immich-app/immich', + repositoryUrl: 'https://github.com/immich-app/immich', + build: '1234567890', + buildUrl: 'https://github.com/immich-app/immich/actions/runs/1234567890', + buildImage: 'e2e', + buildImageUrl: 'https://github.com/immich-app/immich/pkgs/container/immich-server', + sourceRef: 'e2e', + sourceCommit: 'e2eeeeeeeeeeeeeeeeee', + sourceUrl: 'https://github.com/immich-app/immich/commit/e2eeeeeeeeeeeeeeeeee', + nodejs: expect.any(String), + ffmpeg: expect.any(String), + imagemagick: expect.any(String), + libvips: expect.any(String), + exiftool: expect.any(String), + }); + }); + }); + describe('GET /server-info/storage', () => { it('should require authentication', async () => { const { status, body } = await request(app).get('/server-info/storage'); diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index e0ffdd537..691393eb6 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -171,6 +171,7 @@ Class | Method | HTTP request | Description *SearchApi* | [**searchPerson**](doc//SearchApi.md#searchperson) | **GET** /search/person | *SearchApi* | [**searchPlaces**](doc//SearchApi.md#searchplaces) | **GET** /search/places | *SearchApi* | [**searchSmart**](doc//SearchApi.md#searchsmart) | **POST** /search/smart | +*ServerInfoApi* | [**getAboutInfo**](doc//ServerInfoApi.md#getaboutinfo) | **GET** /server-info/about | *ServerInfoApi* | [**getServerConfig**](doc//ServerInfoApi.md#getserverconfig) | **GET** /server-info/config | *ServerInfoApi* | [**getServerFeatures**](doc//ServerInfoApi.md#getserverfeatures) | **GET** /server-info/features | *ServerInfoApi* | [**getServerStatistics**](doc//ServerInfoApi.md#getserverstatistics) | **GET** /server-info/statistics | @@ -360,6 +361,7 @@ Class | Method | HTTP request | Description - [SearchFacetResponseDto](doc//SearchFacetResponseDto.md) - [SearchResponseDto](doc//SearchResponseDto.md) - [SearchSuggestionType](doc//SearchSuggestionType.md) + - [ServerAboutResponseDto](doc//ServerAboutResponseDto.md) - [ServerConfigDto](doc//ServerConfigDto.md) - [ServerFeaturesDto](doc//ServerFeaturesDto.md) - [ServerMediaTypesResponseDto](doc//ServerMediaTypesResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 84f465f54..fafd17659 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -187,6 +187,7 @@ part 'model/search_facet_count_response_dto.dart'; part 'model/search_facet_response_dto.dart'; part 'model/search_response_dto.dart'; part 'model/search_suggestion_type.dart'; +part 'model/server_about_response_dto.dart'; part 'model/server_config_dto.dart'; part 'model/server_features_dto.dart'; part 'model/server_media_types_response_dto.dart'; diff --git a/mobile/openapi/lib/api/server_info_api.dart b/mobile/openapi/lib/api/server_info_api.dart index 76da103b7..2ddcaa630 100644 --- a/mobile/openapi/lib/api/server_info_api.dart +++ b/mobile/openapi/lib/api/server_info_api.dart @@ -16,6 +16,47 @@ class ServerInfoApi { final ApiClient apiClient; + /// Performs an HTTP 'GET /server-info/about' operation and returns the [Response]. + Future getAboutInfoWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server-info/about'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getAboutInfo() async { + final response = await getAboutInfoWithHttpInfo(); + 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), 'ServerAboutResponseDto',) as ServerAboutResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /server-info/config' operation and returns the [Response]. Future getServerConfigWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 1b3c6aed8..18a243ca4 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -436,6 +436,8 @@ class ApiClient { return SearchResponseDto.fromJson(value); case 'SearchSuggestionType': return SearchSuggestionTypeTypeTransformer().decode(value); + case 'ServerAboutResponseDto': + return ServerAboutResponseDto.fromJson(value); case 'ServerConfigDto': return ServerConfigDto.fromJson(value); case 'ServerFeaturesDto': diff --git a/mobile/openapi/lib/model/server_about_response_dto.dart b/mobile/openapi/lib/model/server_about_response_dto.dart new file mode 100644 index 000000000..3b38c4ebc --- /dev/null +++ b/mobile/openapi/lib/model/server_about_response_dto.dart @@ -0,0 +1,344 @@ +// +// 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 ServerAboutResponseDto { + /// Returns a new [ServerAboutResponseDto] instance. + ServerAboutResponseDto({ + this.build, + this.buildImage, + this.buildImageUrl, + this.buildUrl, + this.exiftool, + this.ffmpeg, + this.imagemagick, + this.libvips, + this.nodejs, + this.repository, + this.repositoryUrl, + this.sourceCommit, + this.sourceRef, + this.sourceUrl, + required this.version, + required this.versionUrl, + }); + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? build; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? buildImage; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? buildImageUrl; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? buildUrl; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? exiftool; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? ffmpeg; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? imagemagick; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? libvips; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? nodejs; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? repository; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? repositoryUrl; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? sourceCommit; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? sourceRef; + + /// + /// Please note: This property should have been non-nullable! Since the specification file + /// does not include a default value (using the "default:" property), however, the generated + /// source code must fall back to having a nullable type. + /// Consider adding a "default:" property in the specification file to hide this note. + /// + String? sourceUrl; + + String version; + + String versionUrl; + + @override + bool operator ==(Object other) => identical(this, other) || other is ServerAboutResponseDto && + other.build == build && + other.buildImage == buildImage && + other.buildImageUrl == buildImageUrl && + other.buildUrl == buildUrl && + other.exiftool == exiftool && + other.ffmpeg == ffmpeg && + other.imagemagick == imagemagick && + other.libvips == libvips && + other.nodejs == nodejs && + other.repository == repository && + other.repositoryUrl == repositoryUrl && + other.sourceCommit == sourceCommit && + other.sourceRef == sourceRef && + other.sourceUrl == sourceUrl && + other.version == version && + other.versionUrl == versionUrl; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (build == null ? 0 : build!.hashCode) + + (buildImage == null ? 0 : buildImage!.hashCode) + + (buildImageUrl == null ? 0 : buildImageUrl!.hashCode) + + (buildUrl == null ? 0 : buildUrl!.hashCode) + + (exiftool == null ? 0 : exiftool!.hashCode) + + (ffmpeg == null ? 0 : ffmpeg!.hashCode) + + (imagemagick == null ? 0 : imagemagick!.hashCode) + + (libvips == null ? 0 : libvips!.hashCode) + + (nodejs == null ? 0 : nodejs!.hashCode) + + (repository == null ? 0 : repository!.hashCode) + + (repositoryUrl == null ? 0 : repositoryUrl!.hashCode) + + (sourceCommit == null ? 0 : sourceCommit!.hashCode) + + (sourceRef == null ? 0 : sourceRef!.hashCode) + + (sourceUrl == null ? 0 : sourceUrl!.hashCode) + + (version.hashCode) + + (versionUrl.hashCode); + + @override + String toString() => 'ServerAboutResponseDto[build=$build, buildImage=$buildImage, buildImageUrl=$buildImageUrl, buildUrl=$buildUrl, exiftool=$exiftool, ffmpeg=$ffmpeg, imagemagick=$imagemagick, libvips=$libvips, nodejs=$nodejs, repository=$repository, repositoryUrl=$repositoryUrl, sourceCommit=$sourceCommit, sourceRef=$sourceRef, sourceUrl=$sourceUrl, version=$version, versionUrl=$versionUrl]'; + + Map toJson() { + final json = {}; + if (this.build != null) { + json[r'build'] = this.build; + } else { + // json[r'build'] = null; + } + if (this.buildImage != null) { + json[r'buildImage'] = this.buildImage; + } else { + // json[r'buildImage'] = null; + } + if (this.buildImageUrl != null) { + json[r'buildImageUrl'] = this.buildImageUrl; + } else { + // json[r'buildImageUrl'] = null; + } + if (this.buildUrl != null) { + json[r'buildUrl'] = this.buildUrl; + } else { + // json[r'buildUrl'] = null; + } + if (this.exiftool != null) { + json[r'exiftool'] = this.exiftool; + } else { + // json[r'exiftool'] = null; + } + if (this.ffmpeg != null) { + json[r'ffmpeg'] = this.ffmpeg; + } else { + // json[r'ffmpeg'] = null; + } + if (this.imagemagick != null) { + json[r'imagemagick'] = this.imagemagick; + } else { + // json[r'imagemagick'] = null; + } + if (this.libvips != null) { + json[r'libvips'] = this.libvips; + } else { + // json[r'libvips'] = null; + } + if (this.nodejs != null) { + json[r'nodejs'] = this.nodejs; + } else { + // json[r'nodejs'] = null; + } + if (this.repository != null) { + json[r'repository'] = this.repository; + } else { + // json[r'repository'] = null; + } + if (this.repositoryUrl != null) { + json[r'repositoryUrl'] = this.repositoryUrl; + } else { + // json[r'repositoryUrl'] = null; + } + if (this.sourceCommit != null) { + json[r'sourceCommit'] = this.sourceCommit; + } else { + // json[r'sourceCommit'] = null; + } + if (this.sourceRef != null) { + json[r'sourceRef'] = this.sourceRef; + } else { + // json[r'sourceRef'] = null; + } + if (this.sourceUrl != null) { + json[r'sourceUrl'] = this.sourceUrl; + } else { + // json[r'sourceUrl'] = null; + } + json[r'version'] = this.version; + json[r'versionUrl'] = this.versionUrl; + return json; + } + + /// Returns a new [ServerAboutResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static ServerAboutResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return ServerAboutResponseDto( + build: mapValueOfType(json, r'build'), + buildImage: mapValueOfType(json, r'buildImage'), + buildImageUrl: mapValueOfType(json, r'buildImageUrl'), + buildUrl: mapValueOfType(json, r'buildUrl'), + exiftool: mapValueOfType(json, r'exiftool'), + ffmpeg: mapValueOfType(json, r'ffmpeg'), + imagemagick: mapValueOfType(json, r'imagemagick'), + libvips: mapValueOfType(json, r'libvips'), + nodejs: mapValueOfType(json, r'nodejs'), + repository: mapValueOfType(json, r'repository'), + repositoryUrl: mapValueOfType(json, r'repositoryUrl'), + sourceCommit: mapValueOfType(json, r'sourceCommit'), + sourceRef: mapValueOfType(json, r'sourceRef'), + sourceUrl: mapValueOfType(json, r'sourceUrl'), + version: mapValueOfType(json, r'version')!, + versionUrl: mapValueOfType(json, r'versionUrl')!, + ); + } + 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 = ServerAboutResponseDto.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 = ServerAboutResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of ServerAboutResponseDto-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] = ServerAboutResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'version', + 'versionUrl', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 17f74d33b..d403cf753 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4718,6 +4718,38 @@ ] } }, + "/server-info/about": { + "get": { + "operationId": "getAboutInfo", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ServerAboutResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server Info" + ] + } + }, "/server-info/config": { "get": { "operationId": "getServerConfig", @@ -9630,6 +9662,63 @@ ], "type": "string" }, + "ServerAboutResponseDto": { + "properties": { + "build": { + "type": "string" + }, + "buildImage": { + "type": "string" + }, + "buildImageUrl": { + "type": "string" + }, + "buildUrl": { + "type": "string" + }, + "exiftool": { + "type": "string" + }, + "ffmpeg": { + "type": "string" + }, + "imagemagick": { + "type": "string" + }, + "libvips": { + "type": "string" + }, + "nodejs": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "repositoryUrl": { + "type": "string" + }, + "sourceCommit": { + "type": "string" + }, + "sourceRef": { + "type": "string" + }, + "sourceUrl": { + "type": "string" + }, + "version": { + "type": "string" + }, + "versionUrl": { + "type": "string" + } + }, + "required": [ + "version", + "versionUrl" + ], + "type": "object" + }, "ServerConfigDto": { "properties": { "externalDomain": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 143ec74e6..7a1da9d13 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -787,6 +787,24 @@ export type SmartSearchDto = { withDeleted?: boolean; withExif?: boolean; }; +export type ServerAboutResponseDto = { + build?: string; + buildImage?: string; + buildImageUrl?: string; + buildUrl?: string; + exiftool?: string; + ffmpeg?: string; + imagemagick?: string; + libvips?: string; + nodejs?: string; + repository?: string; + repositoryUrl?: string; + sourceCommit?: string; + sourceRef?: string; + sourceUrl?: string; + version: string; + versionUrl: string; +}; export type ServerConfigDto = { externalDomain: string; isInitialized: boolean; @@ -2363,6 +2381,14 @@ export function getSearchSuggestions({ country, make, model, state, $type }: { ...opts })); } +export function getAboutInfo(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: ServerAboutResponseDto; + }>("/server-info/about", { + ...opts + })); +} export function getServerConfig(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/Dockerfile b/server/Dockerfile index 877b4bec0..36d329708 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -59,6 +59,22 @@ RUN npm link && npm install -g @immich/cli && npm cache clean --force COPY LICENSE /licenses/LICENSE.txt COPY LICENSE /LICENSE ENV PATH="${PATH}:/usr/src/app/bin" + +ARG BUILD_ID +ARG BUILD_IMAGE +ARG BUILD_SOURCE_REF +ARG BUILD_SOURCE_COMMIT + +ENV IMMICH_BUILD=${BUILD_ID} +ENV IMMICH_BUILD_URL=https://github.com/immich-app/immich/actions/runs/${BUILD_ID} +ENV IMMICH_BUILD_IMAGE=${BUILD_IMAGE} +ENV IMMICH_BUILD_IMAGE_URL=https://github.com/immich-app/immich/pkgs/container/immich-server +ENV IMMICH_REPOSITORY=immich-app/immich +ENV IMMICH_REPOSITORY_URL=https://github.com/immich-app/immich +ENV IMMICH_SOURCE_REF=${BUILD_SOURCE_REF} +ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT} +ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT} + VOLUME /usr/src/app/upload EXPOSE 3001 ENTRYPOINT ["tini", "--", "/bin/bash"] diff --git a/server/src/config.ts b/server/src/config.ts index 624dd385a..b420fe43e 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -429,3 +429,15 @@ export const clsConfig: ClsModuleOptions = { }, }, }; + +export const getBuildMetadata = () => ({ + build: process.env.IMMICH_BUILD, + buildUrl: process.env.IMMICH_BUILD_URL, + buildImage: process.env.IMMICH_BUILD_IMAGE, + buildImageUrl: process.env.IMMICH_BUILD_IMAGE_URL, + repository: process.env.IMMICH_REPOSITORY, + repositoryUrl: process.env.IMMICH_REPOSITORY_URL, + sourceRef: process.env.IMMICH_SOURCE_REF, + sourceCommit: process.env.IMMICH_SOURCE_COMMIT, + sourceUrl: process.env.IMMICH_SOURCE_URL, +}); diff --git a/server/src/controllers/server-info.controller.ts b/server/src/controllers/server-info.controller.ts index 03968c1f5..2aaac4a0f 100644 --- a/server/src/controllers/server-info.controller.ts +++ b/server/src/controllers/server-info.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; import { + ServerAboutResponseDto, ServerConfigDto, ServerFeaturesDto, ServerMediaTypesResponseDto, @@ -22,6 +23,12 @@ export class ServerInfoController { private versionService: VersionService, ) {} + @Get('about') + @Authenticated() + getAboutInfo(): Promise { + return this.service.getAboutInfo(); + } + @Get('storage') @Authenticated() getStorage(): Promise { diff --git a/server/src/dtos/server-info.dto.ts b/server/src/dtos/server-info.dto.ts index 94a5b4df6..940c89c79 100644 --- a/server/src/dtos/server-info.dto.ts +++ b/server/src/dtos/server-info.dto.ts @@ -7,6 +7,29 @@ export class ServerPingResponse { res!: string; } +export class ServerAboutResponseDto { + version!: string; + versionUrl!: string; + + repository?: string; + repositoryUrl?: string; + + sourceRef?: string; + sourceCommit?: string; + sourceUrl?: string; + + build?: string; + buildUrl?: string; + buildImage?: string; + buildImageUrl?: string; + + nodejs?: string; + ffmpeg?: string; + imagemagick?: string; + libvips?: string; + exiftool?: string; +} + export class ServerStorageResponseDto { diskSize!: string; diskUse!: string; diff --git a/server/src/interfaces/server-info.interface.ts b/server/src/interfaces/server-info.interface.ts index a4168d4c3..6dc857dde 100644 --- a/server/src/interfaces/server-info.interface.ts +++ b/server/src/interfaces/server-info.interface.ts @@ -8,8 +8,17 @@ export interface GitHubRelease { body: string; } +export interface ServerBuildVersions { + nodejs: string; + ffmpeg: string; + libvips: string; + exiftool: string; + imagemagick: string; +} + export const IServerInfoRepository = 'IServerInfoRepository'; export interface IServerInfoRepository { getGitHubRelease(): Promise; + getBuildVersions(): Promise; } diff --git a/server/src/repositories/server-info.repository.ts b/server/src/repositories/server-info.repository.ts index 5f14a881c..c4b1e664a 100644 --- a/server/src/repositories/server-info.repository.ts +++ b/server/src/repositories/server-info.repository.ts @@ -1,10 +1,45 @@ -import { Injectable } from '@nestjs/common'; -import { GitHubRelease, IServerInfoRepository } from 'src/interfaces/server-info.interface'; +import { Inject, Injectable } from '@nestjs/common'; +import { exiftool } from 'exiftool-vendored'; +import { exec as execCallback } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { promisify } from 'node:util'; +import sharp from 'sharp'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { GitHubRelease, IServerInfoRepository, ServerBuildVersions } from 'src/interfaces/server-info.interface'; import { Instrumentation } from 'src/utils/instrumentation'; +const exec = promisify(execCallback); +const maybeFirstLine = async (command: string): Promise => { + try { + const { stdout } = await exec(command); + return stdout.trim().split('\n')[0] || ''; + } catch { + return ''; + } +}; + +type BuildLockfile = { + sources: Array<{ name: string; version: string }>; + packages: Array<{ name: string; version: string }>; +}; + +const getLockfileVersion = (name: string, lockfile?: BuildLockfile) => { + if (!lockfile) { + return; + } + + const items = [...(lockfile.sources || []), ...(lockfile?.packages || [])]; + const item = items.find((item) => item.name === name); + return item?.version; +}; + @Instrumentation() @Injectable() export class ServerInfoRepository implements IServerInfoRepository { + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { + this.logger.setContext(ServerInfoRepository.name); + } + async getGitHubRelease(): Promise { try { const response = await fetch('https://api.github.com/repos/immich-app/immich/releases/latest'); @@ -18,4 +53,25 @@ export class ServerInfoRepository implements IServerInfoRepository { throw new Error(`Failed to fetch GitHub release: ${error}`); } } + + async getBuildVersions(): Promise { + const [nodejsOutput, ffmpegOutput, magickOutput] = await Promise.all([ + maybeFirstLine('node --version'), + maybeFirstLine('ffmpeg -version'), + maybeFirstLine('convert --version'), + ]); + + const lockfile = await readFile('build-lock.json') + .then((buffer) => JSON.parse(buffer.toString())) + .catch(() => this.logger.warn('Failed to read build-lock.json')); + + return { + nodejs: nodejsOutput || process.env.NODE_VERSION || '', + exiftool: await exiftool.version(), + ffmpeg: getLockfileVersion('ffmpeg', lockfile) || ffmpegOutput.replaceAll('ffmpeg version', '') || '', + libvips: getLockfileVersion('libvips', lockfile) || sharp.versions.vips, + imagemagick: + getLockfileVersion('imagemagick', lockfile) || magickOutput.replaceAll('Version: ImageMagick ', '') || '', + }; + } } diff --git a/server/src/services/server-info.service.spec.ts b/server/src/services/server-info.service.spec.ts index 90d70b21f..b1200cadc 100644 --- a/server/src/services/server-info.service.spec.ts +++ b/server/src/services/server-info.service.spec.ts @@ -1,9 +1,11 @@ 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 { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { newUserRepositoryMock } from 'test/repositories/user.repository.mock'; @@ -13,16 +15,18 @@ describe(ServerInfoService.name, () => { let sut: ServerInfoService; let storageMock: Mocked; let userMock: Mocked; + let serverInfoMock: Mocked; let systemMock: Mocked; let loggerMock: Mocked; beforeEach(() => { storageMock = newStorageRepositoryMock(); userMock = newUserRepositoryMock(); + serverInfoMock = newServerInfoRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); - sut = new ServerInfoService(userMock, storageMock, systemMock, loggerMock); + sut = new ServerInfoService(userMock, storageMock, systemMock, serverInfoMock, loggerMock); }); it('should work', () => { diff --git a/server/src/services/server-info.service.ts b/server/src/services/server-info.service.ts index 5b0831c93..f077c4701 100644 --- a/server/src/services/server-info.service.ts +++ b/server/src/services/server-info.service.ts @@ -1,7 +1,10 @@ import { Inject, Injectable } from '@nestjs/common'; +import { getBuildMetadata } from 'src/config'; +import { serverVersion } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { + ServerAboutResponseDto, ServerConfigDto, ServerFeaturesDto, ServerMediaTypesResponseDto, @@ -12,6 +15,7 @@ import { } from 'src/dtos/server-info.dto'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; 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'; @@ -27,6 +31,7 @@ export class ServerInfoService { @Inject(IUserRepository) private userRepository: IUserRepository, @Inject(IStorageRepository) private storageRepository: IStorageRepository, @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, + @Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(ServerInfoService.name); @@ -42,6 +47,19 @@ export class ServerInfoService { } } + async getAboutInfo(): Promise { + const version = serverVersion.toString(); + const buildMetadata = getBuildMetadata(); + const buildVersions = await this.serverInfoRepository.getBuildVersions(); + + return { + version, + versionUrl: `https://github.com/immich-app/immich/releases/tag/${version}`, + ...buildMetadata, + ...buildVersions, + }; + } + async getStorage(): Promise { const libraryBase = StorageCore.getBaseFolder(StorageFolder.LIBRARY); const diskInfo = await this.storageRepository.checkDiskUsage(libraryBase); diff --git a/server/src/services/version.service.spec.ts b/server/src/services/version.service.spec.ts index 3bf6a24e1..74489e04e 100644 --- a/server/src/services/version.service.spec.ts +++ b/server/src/services/version.service.spec.ts @@ -10,7 +10,7 @@ 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 { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock'; import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; import { Mocked } from 'vitest'; diff --git a/server/test/repositories/system-info.repository.mock.ts b/server/test/repositories/server-info.repository.mock.ts similarity index 87% rename from server/test/repositories/system-info.repository.mock.ts rename to server/test/repositories/server-info.repository.mock.ts index 977d5dca2..f55933d3c 100644 --- a/server/test/repositories/system-info.repository.mock.ts +++ b/server/test/repositories/server-info.repository.mock.ts @@ -4,5 +4,6 @@ import { Mocked, vitest } from 'vitest'; export const newServerInfoRepositoryMock = (): Mocked => { return { getGitHubRelease: vitest.fn(), + getBuildVersions: vitest.fn(), }; }; diff --git a/web/src/lib/components/shared-components/server-about-modal.svelte b/web/src/lib/components/shared-components/server-about-modal.svelte new file mode 100644 index 000000000..d34717003 --- /dev/null +++ b/web/src/lib/components/shared-components/server-about-modal.svelte @@ -0,0 +1,156 @@ + + + + +
+
+ + +
+ +
+ +

+ {info.exiftool} +

+
+ +
+ +

+ {info.nodejs} +

+
+ +
+ +

+ {info.libvips} +

+
+ +
10 ? 'col-span-2' : ''}> + +

+ {info.imagemagick} +

+
+ +
10 ? 'col-span-2' : ''}> + +

+ {info.ffmpeg} +

+
+ + {#if info.repository && info.repositoryUrl} +
+ + +
+ {/if} + + {#if info.sourceRef && info.sourceCommit && info.sourceUrl} + + {/if} + + {#if info.build && info.buildUrl} +
+ + +
+ {/if} + + {#if info.buildImage && info.buildImage} +
+ + +
+ {/if} +
+
+
diff --git a/web/src/lib/components/shared-components/status-box.svelte b/web/src/lib/components/shared-components/status-box.svelte index f4d5a268c..cd13b3d25 100644 --- a/web/src/lib/components/shared-components/status-box.svelte +++ b/web/src/lib/components/shared-components/status-box.svelte @@ -1,19 +1,22 @@ +{#if isOpen} + (isOpen = false)} info={aboutInfo} /> +{/if} +

{$t('version')}

{#if $connected && version} - (isOpen = true)} + class="font-medium text-immich-primary dark:text-immich-dark-primary">{version} - {version} - {:else}

{$t('unknown')}

{/if} diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 02c1f3e2b..31e6ffdbc 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1,4 +1,5 @@ { + "about": "About", "account": "Account", "account_settings": "Account Settings", "acknowledge": "Acknowledge", @@ -380,6 +381,8 @@ "birthdate_saved": "Date of birth saved successfully", "birthdate_set_description": "Date of birth is used to calculate the age of this person at the time of a photo.", "blurred_background": "Blurred background", + "build": "Build", + "build_image": "Build Image", "bulk_delete_duplicates_confirmation": "Are you sure you want to bulk delete {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and permanently delete all other duplicates. You cannot undo this action!", "bulk_keep_duplicates_confirmation": "Are you sure you want to keep {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will resolve all duplicate groups without deleting anything.", "bulk_trash_duplicates_confirmation": "Are you sure you want to bulk trash {count, plural, one {# duplicate asset} other {# duplicate assets}}? This will keep the largest asset of each group and trash all other duplicates.", @@ -904,6 +907,7 @@ "repair": "Repair", "repair_no_results_message": "Untracked and missing files will show up here", "replace_with_upload": "Replace with upload", + "repository": "Repository", "require_password": "Require password", "require_user_to_change_password_on_first_login": "Require user to change password on first login", "reset": "Reset", @@ -1016,6 +1020,7 @@ "sort_oldest": "Oldest photo", "sort_recent": "Most recent photo", "sort_title": "Title", + "source": "Source", "stack": "Stack", "stack_selected_photos": "Stack selected photos", "stacked_assets_count": "Stacked {count, plural, one {# asset} other {# assets}}",