diff --git a/e2e/docker-compose.yml b/e2e/docker-compose.yml index 04bcd0032f..93f3c9145d 100644 --- a/e2e/docker-compose.yml +++ b/e2e/docker-compose.yml @@ -22,6 +22,7 @@ services: - DB_DATABASE_NAME=immich - IMMICH_MACHINE_LEARNING_ENABLED=false - IMMICH_METRICS=true + - IMMICH_ENV=testing volumes: - upload:/usr/src/app/upload - ./test-assets:/test-assets diff --git a/e2e/src/api/specs/server-info.e2e-spec.ts b/e2e/src/api/specs/server-info.e2e-spec.ts index 2711b86241..092eab3ec5 100644 --- a/e2e/src/api/specs/server-info.e2e-spec.ts +++ b/e2e/src/api/specs/server-info.e2e-spec.ts @@ -44,6 +44,7 @@ describe('/server-info', () => { imagemagick: expect.any(String), libvips: expect.any(String), exiftool: expect.any(String), + licensed: false, }); }); }); diff --git a/e2e/src/api/specs/server.e2e-spec.ts b/e2e/src/api/specs/server.e2e-spec.ts index 808ce36363..0ab53a0cb3 100644 --- a/e2e/src/api/specs/server.e2e-spec.ts +++ b/e2e/src/api/specs/server.e2e-spec.ts @@ -5,6 +5,12 @@ import { app, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; +const serverLicense = { + licenseKey: 'IMSV-6ECZ-91TE-WZRM-Q7AQ-MBN4-UW48-2CPT-71X9', + activationKey: + '4kJUNUWMq13J14zqPFm1NodRcI6MV6DeOGvQNIgrM8Sc9nv669wyEVvFw1Nz4Kb1W7zLWblOtXEQzpRRqC4r4fKjewJxfbpeo9sEsqAVIfl4Ero-Vp1Dg21-sVdDGZEAy2oeTCXAyCT5d1JqrqR6N1qTAm4xOx9ujXQRFYhjRG8uwudw7_Q49pF18Tj5OEv9qCqElxztoNck4i6O_azsmsoOQrLIENIWPh3EynBN3ESpYERdCgXO8MlWeuG14_V1HbNjnJPZDuvYg__YfMzoOEtfm1sCqEaJ2Ww-BaX7yGfuCL4XsuZlCQQNHjfscy_WywVfIZPKCiW8QR74i0cSzQ', +}; + describe('/server', () => { let admin: LoginResponseDto; let nonAdmin: LoginResponseDto; @@ -44,6 +50,7 @@ describe('/server', () => { imagemagick: expect.any(String), libvips: expect.any(String), exiftool: expect.any(String), + licensed: false, }); }); }); @@ -197,4 +204,104 @@ describe('/server', () => { }); }); }); + + describe('GET /server/license', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/server/license'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should only work for admins', async () => { + const { status, body } = await request(app) + .get('/server/license') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should return the server license', async () => { + await request(app).put('/server/license').set('Authorization', `Bearer ${admin.accessToken}`).send(serverLicense); + const { status, body } = await request(app) + .get('/server/license') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + ...serverLicense, + activatedAt: expect.any(String), + }); + }); + }); + + describe('DELETE /server/license', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).delete('/server/license'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should only work for admins', async () => { + const { status, body } = await request(app) + .delete('/server/license') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should delete the server license', async () => { + await request(app) + .delete('/server/license') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(serverLicense); + const { status } = await request(app).get('/server/license').set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + }); + }); + + describe('PUT /server/license', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put('/server/license'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should only work for admins', async () => { + const { status, body } = await request(app) + .put('/server/license') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(403); + expect(body).toEqual(errorDto.forbidden); + }); + + it('should set the server license', async () => { + const { status, body } = await request(app) + .put('/server/license') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(serverLicense); + expect(status).toBe(200); + expect(body).toEqual({ ...serverLicense, activatedAt: expect.any(String) }); + const { body: licenseBody } = await request(app) + .get('/server/license') + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(licenseBody).toEqual({ ...serverLicense, activatedAt: expect.any(String) }); + }); + + it('should reject license not starting with IMSV-', async () => { + const { status, body } = await request(app) + .put('/server/license') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ licenseKey: 'IMCL-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', activationKey: 'activationKey' }); + expect(status).toBe(400); + expect(body.message).toBe('Invalid license key'); + }); + + it('should reject license with invalid activation key', async () => { + const { status, body } = await request(app) + .put('/server/license') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send({ licenseKey: serverLicense.licenseKey, activationKey: `invalid${serverLicense.activationKey}` }); + expect(status).toBe(400); + expect(body.message).toBe('Invalid license key'); + }); + }); }); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index b1ef4f2f86..15fe3de3be 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -5,6 +5,12 @@ import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; import { beforeAll, describe, expect, it } from 'vitest'; +const userLicense = { + licenseKey: 'IMCL-FF69-TUK1-RWZU-V9Q8-QGQS-S5GC-X4R2-UFK4', + activationKey: + 'KuX8KsktrBSiXpQMAH0zLgA5SpijXVr_PDkzLdWUlAogCTMBZ0I3KCHXK0eE9EEd7harxup8_EHMeqAWeHo5VQzol6LGECpFv585U9asXD4Zc-UXt3mhJr2uhazqipBIBwJA2YhmUCDy8hiyiGsukDQNu9Rg9C77UeoKuZBWVjWUBWG0mc1iRqfvF0faVM20w53czAzlhaMxzVGc3Oimbd7xi_CAMSujF_2y8QpA3X2fOVkQkzdcH9lV0COejl7IyH27zQQ9HrlrXv3Lai5Hw67kNkaSjmunVBxC5PS0TpKoc9SfBJMaAGWnaDbjhjYUrm-8nIDQnoeEAidDXVAdPw', +}; + describe('/users', () => { let admin: LoginResponseDto; let deletedUser: LoginResponseDto; @@ -72,6 +78,24 @@ describe('/users', () => { quotaUsageInBytes: 0, }); }); + + it('should get my user with license info', async () => { + const { status: licenseStatus } = await request(app) + .put(`/users/me/license`) + .send(userLicense) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(licenseStatus).toBe(200); + const { status, body } = await request(app) + .get(`/users/me`) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ + id: nonAdmin.userId, + email: nonAdmin.userEmail, + quotaUsageInBytes: 0, + license: userLicense, + }); + }); }); describe('PUT /users/me', () => { @@ -236,4 +260,81 @@ describe('/users', () => { }); }); }); + + describe('GET /server/license', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/users/me/license'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should return the user license', async () => { + await request(app) + .put('/users/me/license') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`) + .send(userLicense); + const { status, body } = await request(app) + .get('/users/me/license') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual({ + ...userLicense, + activatedAt: expect.any(String), + }); + }); + }); + + describe('PUT /users/me/license', () => { + it('should require authentication', async () => { + const { status } = await request(app).put(`/users/me/license`); + expect(status).toEqual(401); + }); + + it('should set the user license', async () => { + const { status, body } = await request(app) + .put(`/users/me/license`) + .send(userLicense) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(200); + expect(body).toMatchObject({ ...userLicense, activatedAt: expect.any(String) }); + expect(status).toBe(200); + expect(body).toEqual({ ...userLicense, activatedAt: expect.any(String) }); + const { body: licenseBody } = await request(app) + .get('/users/me/license') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(licenseBody).toEqual({ ...userLicense, activatedAt: expect.any(String) }); + }); + + it('should reject license not starting with IMCL-', async () => { + const { status, body } = await request(app) + .put('/users/me/license') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`) + .send({ licenseKey: 'IMSV-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD-ABCD', activationKey: 'activationKey' }); + expect(status).toBe(400); + expect(body.message).toBe('Invalid license key'); + }); + + it('should reject license with invalid activation key', async () => { + const { status, body } = await request(app) + .put('/users/me/license') + .set('Authorization', `Bearer ${nonAdmin.accessToken}`) + .send({ licenseKey: userLicense.licenseKey, activationKey: `invalid${userLicense.activationKey}` }); + expect(status).toBe(400); + expect(body.message).toBe('Invalid license key'); + }); + }); + + describe('DELETE /users/me/license', () => { + it('should require authentication', async () => { + const { status } = await request(app).put(`/users/me/license`); + expect(status).toEqual(401); + }); + + it('should delete the user license', async () => { + const { status } = await request(app) + .delete(`/users/me/license`) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(200); + }); + }); }); diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index efa01e6850..f4e4193f86 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -81,6 +81,7 @@ export const signupResponseDto = { quotaUsageInBytes: 0, quotaSizeInBytes: null, status: 'active', + license: null, }, }; diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 046ccb5b61..0ce9545208 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -180,6 +180,9 @@ 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 | +*ServerApi* | [**deleteServerLicense**](doc//ServerApi.md#deleteserverlicense) | **DELETE** /server/license | +*ServerApi* | [**getServerLicense**](doc//ServerApi.md#getserverlicense) | **GET** /server/license | +*ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license | *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 | @@ -224,11 +227,14 @@ Class | Method | HTTP request | Description *TrashApi* | [**restoreTrash**](doc//TrashApi.md#restoretrash) | **POST** /trash/restore | *UsersApi* | [**createProfileImage**](doc//UsersApi.md#createprofileimage) | **POST** /users/profile-image | *UsersApi* | [**deleteProfileImage**](doc//UsersApi.md#deleteprofileimage) | **DELETE** /users/profile-image | +*UsersApi* | [**deleteUserLicense**](doc//UsersApi.md#deleteuserlicense) | **DELETE** /users/me/license | *UsersApi* | [**getMyPreferences**](doc//UsersApi.md#getmypreferences) | **GET** /users/me/preferences | *UsersApi* | [**getMyUser**](doc//UsersApi.md#getmyuser) | **GET** /users/me | *UsersApi* | [**getProfileImage**](doc//UsersApi.md#getprofileimage) | **GET** /users/{id}/profile-image | *UsersApi* | [**getUser**](doc//UsersApi.md#getuser) | **GET** /users/{id} | +*UsersApi* | [**getUserLicense**](doc//UsersApi.md#getuserlicense) | **GET** /users/me/license | *UsersApi* | [**searchUsers**](doc//UsersApi.md#searchusers) | **GET** /users | +*UsersApi* | [**setUserLicense**](doc//UsersApi.md#setuserlicense) | **PUT** /users/me/license | *UsersApi* | [**updateMyPreferences**](doc//UsersApi.md#updatemypreferences) | **PUT** /users/me/preferences | *UsersApi* | [**updateMyUser**](doc//UsersApi.md#updatemyuser) | **PUT** /users/me | *UsersAdminApi* | [**createUserAdmin**](doc//UsersAdminApi.md#createuseradmin) | **POST** /admin/users | @@ -326,6 +332,8 @@ Class | Method | HTTP request | Description - [JobStatusDto](doc//JobStatusDto.md) - [LibraryResponseDto](doc//LibraryResponseDto.md) - [LibraryStatsResponseDto](doc//LibraryStatsResponseDto.md) + - [LicenseKeyDto](doc//LicenseKeyDto.md) + - [LicenseResponseDto](doc//LicenseResponseDto.md) - [LogLevel](doc//LogLevel.md) - [LoginCredentialDto](doc//LoginCredentialDto.md) - [LoginResponseDto](doc//LoginResponseDto.md) @@ -430,6 +438,7 @@ Class | Method | HTTP request | Description - [UserAdminResponseDto](doc//UserAdminResponseDto.md) - [UserAdminUpdateDto](doc//UserAdminUpdateDto.md) - [UserAvatarColor](doc//UserAvatarColor.md) + - [UserLicense](doc//UserLicense.md) - [UserPreferencesResponseDto](doc//UserPreferencesResponseDto.md) - [UserPreferencesUpdateDto](doc//UserPreferencesUpdateDto.md) - [UserResponseDto](doc//UserResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 695a482e82..cda2bac4c5 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -49,6 +49,7 @@ part 'api/o_auth_api.dart'; part 'api/partners_api.dart'; part 'api/people_api.dart'; part 'api/search_api.dart'; +part 'api/server_api.dart'; part 'api/server_info_api.dart'; part 'api/sessions_api.dart'; part 'api/shared_links_api.dart'; @@ -144,6 +145,8 @@ part 'model/job_settings_dto.dart'; part 'model/job_status_dto.dart'; part 'model/library_response_dto.dart'; part 'model/library_stats_response_dto.dart'; +part 'model/license_key_dto.dart'; +part 'model/license_response_dto.dart'; part 'model/log_level.dart'; part 'model/login_credential_dto.dart'; part 'model/login_response_dto.dart'; @@ -248,6 +251,7 @@ part 'model/user_admin_delete_dto.dart'; part 'model/user_admin_response_dto.dart'; part 'model/user_admin_update_dto.dart'; part 'model/user_avatar_color.dart'; +part 'model/user_license.dart'; part 'model/user_preferences_response_dto.dart'; part 'model/user_preferences_update_dto.dart'; part 'model/user_response_dto.dart'; diff --git a/mobile/openapi/lib/api/server_api.dart b/mobile/openapi/lib/api/server_api.dart new file mode 100644 index 0000000000..4987c6cd9c --- /dev/null +++ b/mobile/openapi/lib/api/server_api.dart @@ -0,0 +1,139 @@ +// +// 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 ServerApi { + ServerApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'DELETE /server/license' operation and returns the [Response]. + Future deleteServerLicenseWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/license'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future deleteServerLicense() async { + final response = await deleteServerLicenseWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Performs an HTTP 'GET /server/license' operation and returns the [Response]. + Future getServerLicenseWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/server/license'; + + // 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 getServerLicense() async { + final response = await getServerLicenseWithHttpInfo(); + 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), 'Object',) as Object; + + } + return null; + } + + /// Performs an HTTP 'PUT /server/license' operation and returns the [Response]. + /// Parameters: + /// + /// * [LicenseKeyDto] licenseKeyDto (required): + Future setServerLicenseWithHttpInfo(LicenseKeyDto licenseKeyDto,) async { + // ignore: prefer_const_declarations + final path = r'/server/license'; + + // ignore: prefer_final_locals + Object? postBody = licenseKeyDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [LicenseKeyDto] licenseKeyDto (required): + Future setServerLicense(LicenseKeyDto licenseKeyDto,) async { + final response = await setServerLicenseWithHttpInfo(licenseKeyDto,); + 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), 'LicenseResponseDto',) as LicenseResponseDto; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api/users_api.dart b/mobile/openapi/lib/api/users_api.dart index 55e184e304..b2b9fa8826 100644 --- a/mobile/openapi/lib/api/users_api.dart +++ b/mobile/openapi/lib/api/users_api.dart @@ -106,6 +106,39 @@ class UsersApi { } } + /// Performs an HTTP 'DELETE /users/me/license' operation and returns the [Response]. + Future deleteUserLicenseWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/users/me/license'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future deleteUserLicense() async { + final response = await deleteUserLicenseWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + /// Performs an HTTP 'GET /users/me/preferences' operation and returns the [Response]. Future getMyPreferencesWithHttpInfo() async { // ignore: prefer_const_declarations @@ -284,6 +317,47 @@ class UsersApi { return null; } + /// Performs an HTTP 'GET /users/me/license' operation and returns the [Response]. + Future getUserLicenseWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/users/me/license'; + + // 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 getUserLicense() async { + final response = await getUserLicenseWithHttpInfo(); + 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), 'LicenseResponseDto',) as LicenseResponseDto; + + } + return null; + } + /// Performs an HTTP 'GET /users' operation and returns the [Response]. Future searchUsersWithHttpInfo() async { // ignore: prefer_const_declarations @@ -328,6 +402,53 @@ class UsersApi { return null; } + /// Performs an HTTP 'PUT /users/me/license' operation and returns the [Response]. + /// Parameters: + /// + /// * [LicenseKeyDto] licenseKeyDto (required): + Future setUserLicenseWithHttpInfo(LicenseKeyDto licenseKeyDto,) async { + // ignore: prefer_const_declarations + final path = r'/users/me/license'; + + // ignore: prefer_final_locals + Object? postBody = licenseKeyDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [LicenseKeyDto] licenseKeyDto (required): + Future setUserLicense(LicenseKeyDto licenseKeyDto,) async { + final response = await setUserLicenseWithHttpInfo(licenseKeyDto,); + 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), 'LicenseResponseDto',) as LicenseResponseDto; + + } + return null; + } + /// Performs an HTTP 'PUT /users/me/preferences' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 18a243ca4c..32490a6820 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -348,6 +348,10 @@ class ApiClient { return LibraryResponseDto.fromJson(value); case 'LibraryStatsResponseDto': return LibraryStatsResponseDto.fromJson(value); + case 'LicenseKeyDto': + return LicenseKeyDto.fromJson(value); + case 'LicenseResponseDto': + return LicenseResponseDto.fromJson(value); case 'LogLevel': return LogLevelTypeTransformer().decode(value); case 'LoginCredentialDto': @@ -556,6 +560,8 @@ class ApiClient { return UserAdminUpdateDto.fromJson(value); case 'UserAvatarColor': return UserAvatarColorTypeTransformer().decode(value); + case 'UserLicense': + return UserLicense.fromJson(value); case 'UserPreferencesResponseDto': return UserPreferencesResponseDto.fromJson(value); case 'UserPreferencesUpdateDto': diff --git a/mobile/openapi/lib/model/license_key_dto.dart b/mobile/openapi/lib/model/license_key_dto.dart new file mode 100644 index 0000000000..aece85f81e --- /dev/null +++ b/mobile/openapi/lib/model/license_key_dto.dart @@ -0,0 +1,106 @@ +// +// 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 LicenseKeyDto { + /// Returns a new [LicenseKeyDto] instance. + LicenseKeyDto({ + required this.activationKey, + required this.licenseKey, + }); + + String activationKey; + + String licenseKey; + + @override + bool operator ==(Object other) => identical(this, other) || other is LicenseKeyDto && + other.activationKey == activationKey && + other.licenseKey == licenseKey; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (activationKey.hashCode) + + (licenseKey.hashCode); + + @override + String toString() => 'LicenseKeyDto[activationKey=$activationKey, licenseKey=$licenseKey]'; + + Map toJson() { + final json = {}; + json[r'activationKey'] = this.activationKey; + json[r'licenseKey'] = this.licenseKey; + return json; + } + + /// Returns a new [LicenseKeyDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static LicenseKeyDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return LicenseKeyDto( + activationKey: mapValueOfType(json, r'activationKey')!, + licenseKey: mapValueOfType(json, r'licenseKey')!, + ); + } + 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 = LicenseKeyDto.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 = LicenseKeyDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of LicenseKeyDto-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] = LicenseKeyDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'activationKey', + 'licenseKey', + }; +} + diff --git a/mobile/openapi/lib/model/license_response_dto.dart b/mobile/openapi/lib/model/license_response_dto.dart new file mode 100644 index 0000000000..f83668af57 --- /dev/null +++ b/mobile/openapi/lib/model/license_response_dto.dart @@ -0,0 +1,114 @@ +// +// 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 LicenseResponseDto { + /// Returns a new [LicenseResponseDto] instance. + LicenseResponseDto({ + required this.activatedAt, + required this.activationKey, + required this.licenseKey, + }); + + DateTime activatedAt; + + String activationKey; + + String licenseKey; + + @override + bool operator ==(Object other) => identical(this, other) || other is LicenseResponseDto && + other.activatedAt == activatedAt && + other.activationKey == activationKey && + other.licenseKey == licenseKey; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (activatedAt.hashCode) + + (activationKey.hashCode) + + (licenseKey.hashCode); + + @override + String toString() => 'LicenseResponseDto[activatedAt=$activatedAt, activationKey=$activationKey, licenseKey=$licenseKey]'; + + Map toJson() { + final json = {}; + json[r'activatedAt'] = this.activatedAt.toUtc().toIso8601String(); + json[r'activationKey'] = this.activationKey; + json[r'licenseKey'] = this.licenseKey; + return json; + } + + /// Returns a new [LicenseResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static LicenseResponseDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return LicenseResponseDto( + activatedAt: mapDateTime(json, r'activatedAt', r'')!, + activationKey: mapValueOfType(json, r'activationKey')!, + licenseKey: mapValueOfType(json, r'licenseKey')!, + ); + } + 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 = LicenseResponseDto.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 = LicenseResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of LicenseResponseDto-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] = LicenseResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'activatedAt', + 'activationKey', + 'licenseKey', + }; +} + diff --git a/mobile/openapi/lib/model/server_about_response_dto.dart b/mobile/openapi/lib/model/server_about_response_dto.dart index 3b38c4ebcc..9c71d1fccd 100644 --- a/mobile/openapi/lib/model/server_about_response_dto.dart +++ b/mobile/openapi/lib/model/server_about_response_dto.dart @@ -21,6 +21,7 @@ class ServerAboutResponseDto { this.ffmpeg, this.imagemagick, this.libvips, + required this.licensed, this.nodejs, this.repository, this.repositoryUrl, @@ -95,6 +96,8 @@ class ServerAboutResponseDto { /// String? libvips; + bool licensed; + /// /// 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 @@ -157,6 +160,7 @@ class ServerAboutResponseDto { other.ffmpeg == ffmpeg && other.imagemagick == imagemagick && other.libvips == libvips && + other.licensed == licensed && other.nodejs == nodejs && other.repository == repository && other.repositoryUrl == repositoryUrl && @@ -177,6 +181,7 @@ class ServerAboutResponseDto { (ffmpeg == null ? 0 : ffmpeg!.hashCode) + (imagemagick == null ? 0 : imagemagick!.hashCode) + (libvips == null ? 0 : libvips!.hashCode) + + (licensed.hashCode) + (nodejs == null ? 0 : nodejs!.hashCode) + (repository == null ? 0 : repository!.hashCode) + (repositoryUrl == null ? 0 : repositoryUrl!.hashCode) + @@ -187,7 +192,7 @@ class ServerAboutResponseDto { (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]'; + String toString() => 'ServerAboutResponseDto[build=$build, buildImage=$buildImage, buildImageUrl=$buildImageUrl, buildUrl=$buildUrl, exiftool=$exiftool, ffmpeg=$ffmpeg, imagemagick=$imagemagick, libvips=$libvips, licensed=$licensed, nodejs=$nodejs, repository=$repository, repositoryUrl=$repositoryUrl, sourceCommit=$sourceCommit, sourceRef=$sourceRef, sourceUrl=$sourceUrl, version=$version, versionUrl=$versionUrl]'; Map toJson() { final json = {}; @@ -231,6 +236,7 @@ class ServerAboutResponseDto { } else { // json[r'libvips'] = null; } + json[r'licensed'] = this.licensed; if (this.nodejs != null) { json[r'nodejs'] = this.nodejs; } else { @@ -282,6 +288,7 @@ class ServerAboutResponseDto { ffmpeg: mapValueOfType(json, r'ffmpeg'), imagemagick: mapValueOfType(json, r'imagemagick'), libvips: mapValueOfType(json, r'libvips'), + licensed: mapValueOfType(json, r'licensed')!, nodejs: mapValueOfType(json, r'nodejs'), repository: mapValueOfType(json, r'repository'), repositoryUrl: mapValueOfType(json, r'repositoryUrl'), @@ -337,6 +344,7 @@ class ServerAboutResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'licensed', 'version', 'versionUrl', }; diff --git a/mobile/openapi/lib/model/user_admin_response_dto.dart b/mobile/openapi/lib/model/user_admin_response_dto.dart index 8060fa7cfc..af1ad3ad1c 100644 --- a/mobile/openapi/lib/model/user_admin_response_dto.dart +++ b/mobile/openapi/lib/model/user_admin_response_dto.dart @@ -19,6 +19,7 @@ class UserAdminResponseDto { required this.email, required this.id, required this.isAdmin, + required this.license, required this.name, required this.oauthId, required this.profileImagePath, @@ -42,6 +43,8 @@ class UserAdminResponseDto { bool isAdmin; + UserLicense? license; + String name; String oauthId; @@ -68,6 +71,7 @@ class UserAdminResponseDto { other.email == email && other.id == id && other.isAdmin == isAdmin && + other.license == license && other.name == name && other.oauthId == oauthId && other.profileImagePath == profileImagePath && @@ -87,6 +91,7 @@ class UserAdminResponseDto { (email.hashCode) + (id.hashCode) + (isAdmin.hashCode) + + (license == null ? 0 : license!.hashCode) + (name.hashCode) + (oauthId.hashCode) + (profileImagePath.hashCode) + @@ -98,7 +103,7 @@ class UserAdminResponseDto { (updatedAt.hashCode); @override - String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'UserAdminResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, license=$license, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -112,6 +117,11 @@ class UserAdminResponseDto { json[r'email'] = this.email; json[r'id'] = this.id; json[r'isAdmin'] = this.isAdmin; + if (this.license != null) { + json[r'license'] = this.license; + } else { + // json[r'license'] = null; + } json[r'name'] = this.name; json[r'oauthId'] = this.oauthId; json[r'profileImagePath'] = this.profileImagePath; @@ -150,6 +160,7 @@ class UserAdminResponseDto { email: mapValueOfType(json, r'email')!, id: mapValueOfType(json, r'id')!, isAdmin: mapValueOfType(json, r'isAdmin')!, + license: UserLicense.fromJson(json[r'license']), name: mapValueOfType(json, r'name')!, oauthId: mapValueOfType(json, r'oauthId')!, profileImagePath: mapValueOfType(json, r'profileImagePath')!, @@ -212,6 +223,7 @@ class UserAdminResponseDto { 'email', 'id', 'isAdmin', + 'license', 'name', 'oauthId', 'profileImagePath', diff --git a/mobile/openapi/lib/model/user_license.dart b/mobile/openapi/lib/model/user_license.dart new file mode 100644 index 0000000000..c7abb085f2 --- /dev/null +++ b/mobile/openapi/lib/model/user_license.dart @@ -0,0 +1,114 @@ +// +// 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 UserLicense { + /// Returns a new [UserLicense] instance. + UserLicense({ + required this.activatedAt, + required this.activationKey, + required this.licenseKey, + }); + + DateTime activatedAt; + + String activationKey; + + String licenseKey; + + @override + bool operator ==(Object other) => identical(this, other) || other is UserLicense && + other.activatedAt == activatedAt && + other.activationKey == activationKey && + other.licenseKey == licenseKey; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (activatedAt.hashCode) + + (activationKey.hashCode) + + (licenseKey.hashCode); + + @override + String toString() => 'UserLicense[activatedAt=$activatedAt, activationKey=$activationKey, licenseKey=$licenseKey]'; + + Map toJson() { + final json = {}; + json[r'activatedAt'] = this.activatedAt.toUtc().toIso8601String(); + json[r'activationKey'] = this.activationKey; + json[r'licenseKey'] = this.licenseKey; + return json; + } + + /// Returns a new [UserLicense] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UserLicense? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return UserLicense( + activatedAt: mapDateTime(json, r'activatedAt', r'')!, + activationKey: mapValueOfType(json, r'activationKey')!, + licenseKey: mapValueOfType(json, r'licenseKey')!, + ); + } + 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 = UserLicense.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 = UserLicense.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UserLicense-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] = UserLicense.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'activatedAt', + 'activationKey', + 'licenseKey', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 6db23aa8c4..232de27796 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -4994,6 +4994,101 @@ } } }, + "/server/license": { + "delete": { + "operationId": "deleteServerLicense", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server" + ] + }, + "get": { + "operationId": "getServerLicense", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server" + ] + }, + "put": { + "operationId": "setServerLicense", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicenseKeyDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicenseResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Server" + ] + } + }, "/sessions": { "delete": { "operationId": "deleteAllSessions", @@ -6594,6 +6689,101 @@ ] } }, + "/users/me/license": { + "delete": { + "operationId": "deleteUserLicense", + "parameters": [], + "responses": { + "200": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users" + ] + }, + "get": { + "operationId": "getUserLicense", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicenseResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users" + ] + }, + "put": { + "operationId": "setUserLicense", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicenseKeyDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LicenseResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Users" + ] + } + }, "/users/me/preferences": { "get": { "operationId": "getMyPreferences", @@ -8765,6 +8955,43 @@ ], "type": "object" }, + "LicenseKeyDto": { + "properties": { + "activationKey": { + "type": "string" + }, + "licenseKey": { + "pattern": "/IM(SV|CL)(-[\\dA-Za-z]{4}){8}/", + "type": "string" + } + }, + "required": [ + "activationKey", + "licenseKey" + ], + "type": "object" + }, + "LicenseResponseDto": { + "properties": { + "activatedAt": { + "format": "date-time", + "type": "string" + }, + "activationKey": { + "type": "string" + }, + "licenseKey": { + "pattern": "/IM(SV|CL)(-[\\dA-Za-z]{4}){8}/", + "type": "string" + } + }, + "required": [ + "activatedAt", + "activationKey", + "licenseKey" + ], + "type": "object" + }, "LogLevel": { "enum": [ "verbose", @@ -9752,6 +9979,9 @@ "libvips": { "type": "string" }, + "licensed": { + "type": "boolean" + }, "nodejs": { "type": "string" }, @@ -9778,6 +10008,7 @@ } }, "required": [ + "licensed", "version", "versionUrl" ], @@ -11330,6 +11561,14 @@ "isAdmin": { "type": "boolean" }, + "license": { + "allOf": [ + { + "$ref": "#/components/schemas/UserLicense" + } + ], + "nullable": true + }, "name": { "type": "string" }, @@ -11371,6 +11610,7 @@ "email", "id", "isAdmin", + "license", "name", "oauthId", "profileImagePath", @@ -11425,6 +11665,26 @@ ], "type": "string" }, + "UserLicense": { + "properties": { + "activatedAt": { + "format": "date-time", + "type": "string" + }, + "activationKey": { + "type": "string" + }, + "licenseKey": { + "type": "string" + } + }, + "required": [ + "activatedAt", + "activationKey", + "licenseKey" + ], + "type": "object" + }, "UserPreferencesResponseDto": { "properties": { "avatar": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 5c8ee9c70f..2a81038a83 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -38,6 +38,11 @@ export type ActivityCreateDto = { export type ActivityStatisticsResponseDto = { comments: number; }; +export type UserLicense = { + activatedAt: string; + activationKey: string; + licenseKey: string; +}; export type UserAdminResponseDto = { avatarColor: UserAvatarColor; createdAt: string; @@ -45,6 +50,7 @@ export type UserAdminResponseDto = { email: string; id: string; isAdmin: boolean; + license: (UserLicense) | null; name: string; oauthId: string; profileImagePath: string; @@ -800,6 +806,7 @@ export type ServerAboutResponseDto = { ffmpeg?: string; imagemagick?: string; libvips?: string; + licensed: boolean; nodejs?: string; repository?: string; repositoryUrl?: string; @@ -873,6 +880,15 @@ export type ServerVersionResponseDto = { minor: number; patch: number; }; +export type LicenseKeyDto = { + activationKey: string; + licenseKey: string; +}; +export type LicenseResponseDto = { + activatedAt: string; + activationKey: string; + licenseKey: string; +}; export type SessionResponseDto = { createdAt: string; current: boolean; @@ -2484,6 +2500,32 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) { ...opts })); } +export function deleteServerLicense(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/server/license", { + ...opts, + method: "DELETE" + })); +} +export function getServerLicense(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: object; + }>("/server/license", { + ...opts + })); +} +export function setServerLicense({ licenseKeyDto }: { + licenseKeyDto: LicenseKeyDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: LicenseResponseDto; + }>("/server/license", oazapfts.json({ + ...opts, + method: "PUT", + body: licenseKeyDto + }))); +} export function deleteAllSessions(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchText("/sessions", { ...opts, @@ -2892,6 +2934,32 @@ export function updateMyUser({ userUpdateMeDto }: { body: userUpdateMeDto }))); } +export function deleteUserLicense(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText("/users/me/license", { + ...opts, + method: "DELETE" + })); +} +export function getUserLicense(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: LicenseResponseDto; + }>("/users/me/license", { + ...opts + })); +} +export function setUserLicense({ licenseKeyDto }: { + licenseKeyDto: LicenseKeyDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: LicenseResponseDto; + }>("/users/me/license", oazapfts.json({ + ...opts, + method: "PUT", + body: licenseKeyDto + }))); +} export function getMyPreferences(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; diff --git a/server/src/config.ts b/server/src/config.ts index 7b4fa6ebfd..ea4ebf9d50 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -361,7 +361,7 @@ export const immichAppConfig: ConfigModuleOptions = { envFilePath: '.env', isGlobal: true, validationSchema: Joi.object({ - IMMICH_ENV: Joi.string().optional().valid('development', 'production').default('production'), + IMMICH_ENV: Joi.string().optional().valid('development', 'testing', 'production').default('production'), IMMICH_LOG_LEVEL: Joi.string() .optional() .valid(...Object.values(LogLevel)), @@ -441,3 +441,29 @@ export const getBuildMetadata = () => ({ sourceCommit: process.env.IMMICH_SOURCE_COMMIT, sourceUrl: process.env.IMMICH_SOURCE_URL, }); + +const clientLicensePublicKeyProd = + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUF2LzdTMzJjUkE1KysxTm5WRHNDTQpzcFAvakpISU1xT0pYRm5oNE53QTJPcHorUk1mZGNvOTJQc09naCt3d1FlRXYxVTJjMnBqelRpUS8ybHJLcS9rCnpKUmxYd2M0Y1Vlc1FETUpPRitQMnFPTlBiQUprWHZDWFlCVUxpdENJa29Md2ZoU0dOanlJS2FSRGhkL3ROeU4KOCtoTlJabllUMWhTSWo5U0NrS3hVQ096YXRQVjRtQ0RlclMrYkUrZ0VVZVdwOTlWOWF6dkYwRkltblRXcFFTdwpjOHdFWmdPTWg0c3ZoNmFpY3dkemtQQ3dFTGFrMFZhQkgzMUJFVUNRTGI5K0FJdEhBVXRKQ0t4aGI1V2pzMXM5CmJyWGZpMHZycGdjWi82RGFuWTJxZlNQem5PbXZEMkZycmxTMXE0SkpOM1ZvN1d3LzBZeS95TWNtelRXWmhHdWgKVVFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; + +const clientLicensePublicKeyStaging = + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFuSUNyTm5jbGpPSC9JdTNtWVVaRQp0dGJLV1c3OGRuajl5M0U2ekk3dU1NUndEckdYWFhkTGhkUDFxSWtlZHh0clVVeUpCMWR4R04yQW91S082MlNGCldrbU9PTmNGQlRBWFZTdjhUNVY0S0VwWnFQYWEwaXpNaGxMaE5sRXEvY1ZKdllrWlh1Z2x6b1o3cG1nbzFSdHgKam1iRm5NNzhrYTFRUUJqOVdLaEw2eWpWRUl2MDdVS0lKWHBNTnNuS2g1V083MjZhYmMzSE9udTlETjY5VnFFRQo3dGZrUnRWNmx2U1NzMkFVMngzT255cHA4ek53b0lPTWRibGsyb09aWWROZzY0Y3l2SzJoU0FlU3NVMFRyOVc5Ckgra0Y5QlNCNlk0QXl0QlVkSmkrK2pMSW5HM2Q5cU9ieFVzTlYrN05mRkF5NjJkL0xNR0xSOC9OUFc0U0s3c0MKRlFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; + +export const getClientLicensePublicKey = (): string => { + if (process.env.IMMICH_ENV === 'production') { + return clientLicensePublicKeyProd; + } + return clientLicensePublicKeyStaging; +}; + +const serverLicensePublicKeyProd = + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUFvcG5ZRGEwYS9kVTVJZUc3NGlFRQpNd2RBS2pzTmN6TGRDcVJkMVo5eTVUMndqTzdlWUlPZUpUc2wzNTBzUjBwNEtmU1VEU1h2QzlOcERwYzF0T0tsCjVzaEMvQXhwdlFBTENva0Y0anQ4dnJyZDlmQ2FYYzFUcVJiT21uaGl1Z0Q2dmtyME8vRmIzVURpM1UwVHZoUFAKbFBkdlNhd3pMcldaUExmbUhWVnJiclNLbW45SWVTZ3kwN3VrV1RJeUxzY2lOcnZuQnl3c0phUmVEdW9OV1BCSApVL21vMm1YYThtNHdNV2hpWGVoaUlPUXFNdVNVZ1BlQ3NXajhVVngxQ0dsUnpQREEwYlZOUXZlS1hXVnhjRUk2ClVMRWdKeTJGNDlsSDArYVlDbUJmN05FcjZWUTJXQjk1ZXZUS1hLdm4wcUlNN25nRmxjVUF3NmZ1VjFjTkNUSlMKNndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; + +const serverLicensePublicKeyStaging = + 'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FROEFNSUlCQ2dLQ0FRRUE3Sy8yd3ZLUS9NdU8ydi9MUm5saAoyUy9zTHhDOGJiTEw1UUlKOGowQ3BVZW40YURlY2dYMUpKUmtGNlpUVUtpNTdTbEhtS3RSM2JOTzJmdTBUUVg5Ck5WMEJzVzllZVB0MmlTMWl4VVFmTzRObjdvTjZzbEtac01qd29RNGtGRGFmM3VHTlZJc0dMb3UxVWRLUVhpeDEKUlRHcXVTb3NZVjNWRlk3Q1hGYTVWaENBL3poVXNsNGFuVXp3eEF6M01jUFVlTXBaenYvbVZiQlRKVzBPSytWZgpWQUJvMXdYMkVBanpBekVHVzQ3Vko4czhnMnQrNHNPaHFBNStMQjBKVzlORUg5QUpweGZzWE4zSzVtM00yNUJVClZXcTlRYStIdHRENnJ0bnAvcUFweXVkWUdwZk9HYTRCUlZTR1MxMURZM0xrb2FlRzYwUEU5NHpoYjduOHpMWkgKelFJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0tDQo='; + +export const getServerLicensePublicKey = (): string => { + if (process.env.IMMICH_ENV === 'production') { + return serverLicensePublicKeyProd; + } + return serverLicensePublicKeyStaging; +}; diff --git a/server/src/controllers/server.controller.ts b/server/src/controllers/server.controller.ts index 45d992908f..b98ca38a80 100644 --- a/server/src/controllers/server.controller.ts +++ b/server/src/controllers/server.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Put } from '@nestjs/common'; import { ApiExcludeEndpoint, ApiTags } from '@nestjs/swagger'; +import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, ServerConfigDto, @@ -79,4 +80,22 @@ export class ServerController { getSupportedMediaTypes(): ServerMediaTypesResponseDto { return this.service.getSupportedMediaTypes(); } + + @Put('license') + @Authenticated({ admin: true }) + setServerLicense(@Body() license: LicenseKeyDto): Promise { + return this.service.setLicense(license); + } + + @Delete('license') + @Authenticated({ admin: true }) + deleteServerLicense(): Promise { + return this.service.deleteLicense(); + } + + @Get('license') + @Authenticated({ admin: true }) + getServerLicense(): Promise { + return this.service.getLicense(); + } } diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 82b9d67692..01b2258390 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -17,6 +17,7 @@ import { import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { NextFunction, Response } from 'express'; import { AuthDto } from 'src/dtos/auth.dto'; +import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; @@ -68,6 +69,24 @@ export class UserController { return this.service.updateMyPreferences(auth, dto); } + @Get('me/license') + @Authenticated() + getUserLicense(@Auth() auth: AuthDto): LicenseResponseDto { + return this.service.getLicense(auth); + } + + @Put('me/license') + @Authenticated() + async setUserLicense(@Auth() auth: AuthDto, @Body() license: LicenseKeyDto): Promise { + return this.service.setLicense(auth, license); + } + + @Delete('me/license') + @Authenticated() + async deleteUserLicense(@Auth() auth: AuthDto): Promise { + await this.service.deleteLicense(auth); + } + @Get(':id') @Authenticated() getUser(@Param() { id }: UUIDParamDto): Promise { diff --git a/server/src/dtos/license.dto.ts b/server/src/dtos/license.dto.ts new file mode 100644 index 0000000000..6020d06b6f --- /dev/null +++ b/server/src/dtos/license.dto.ts @@ -0,0 +1,16 @@ +import { IsNotEmpty, IsString, Matches } from 'class-validator'; + +export class LicenseKeyDto { + @IsString() + @IsNotEmpty() + @Matches(/IM(SV|CL)(-[\dA-Za-z]{4}){8}/) + licenseKey!: string; + + @IsString() + @IsNotEmpty() + activationKey!: string; +} + +export class LicenseResponseDto extends LicenseKeyDto { + activatedAt!: Date; +} diff --git a/server/src/dtos/server.dto.ts b/server/src/dtos/server.dto.ts index 940c89c793..9c18b0b4fe 100644 --- a/server/src/dtos/server.dto.ts +++ b/server/src/dtos/server.dto.ts @@ -28,6 +28,8 @@ export class ServerAboutResponseDto { imagemagick?: string; libvips?: string; exiftool?: string; + + licensed!: boolean; } export class ServerStorageResponseDto { diff --git a/server/src/dtos/user.dto.ts b/server/src/dtos/user.dto.ts index 63bac60d06..11767ed4a6 100644 --- a/server/src/dtos/user.dto.ts +++ b/server/src/dtos/user.dto.ts @@ -1,10 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { IsBoolean, IsEmail, IsNotEmpty, IsNumber, IsPositive, IsString } from 'class-validator'; -import { UserAvatarColor } from 'src/entities/user-metadata.entity'; +import { UserAvatarColor, UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity'; import { UserEntity, UserStatus } from 'src/entities/user.entity'; import { getPreferences } from 'src/utils/preferences'; -import { Optional, ValidateBoolean, toEmail, toSanitized } from 'src/validation'; +import { Optional, toEmail, toSanitized, ValidateBoolean } from 'src/validation'; export class UserUpdateMeDto { @Optional() @@ -33,6 +33,12 @@ export class UserResponseDto { avatarColor!: UserAvatarColor; } +export class UserLicense { + licenseKey!: string; + activationKey!: string; + activatedAt!: Date; +} + export const mapUser = (entity: UserEntity): UserResponseDto => { return { id: entity.id, @@ -130,9 +136,13 @@ export class UserAdminResponseDto extends UserResponseDto { quotaUsageInBytes!: number | null; @ApiProperty({ enumName: 'UserStatus', enum: UserStatus }) status!: string; + license!: UserLicense | null; } export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto { + const license = entity.metadata.find( + (item): item is UserMetadataEntity => item.key === UserMetadataKey.LICENSE, + )?.value; return { ...mapUser(entity), storageLabel: entity.storageLabel, @@ -145,5 +155,6 @@ export function mapUserAdmin(entity: UserEntity): UserAdminResponseDto { quotaSizeInBytes: entity.quotaSizeInBytes, quotaUsageInBytes: entity.quotaUsageInBytes, status: entity.status, + license: license ?? null, }; } diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index b097c21200..c72c20f2a1 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -15,6 +15,7 @@ export enum SystemMetadataKey { ADMIN_ONBOARDING = 'admin-onboarding', SYSTEM_CONFIG = 'system-config', VERSION_CHECK_STATE = 'version-check-state', + LICENSE = 'license', } export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string }; @@ -24,4 +25,5 @@ export interface SystemMetadata extends Record; [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; + [SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; } diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts index 6ee4601963..37384a6ba9 100644 --- a/server/src/entities/user-metadata.entity.ts +++ b/server/src/entities/user-metadata.entity.ts @@ -73,8 +73,10 @@ export const getDefaultPreferences = (user: { email: string }): UserPreferences export enum UserMetadataKey { PREFERENCES = 'preferences', + LICENSE = 'license', } export interface UserMetadata extends Record> { [UserMetadataKey.PREFERENCES]: DeepPartial; + [UserMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date }; } diff --git a/server/src/interfaces/crypto.interface.ts b/server/src/interfaces/crypto.interface.ts index e7ad2b045b..c661695cf7 100644 --- a/server/src/interfaces/crypto.interface.ts +++ b/server/src/interfaces/crypto.interface.ts @@ -5,6 +5,7 @@ export interface ICryptoRepository { randomUUID(): string; hashFile(filePath: string | Buffer): Promise; hashSha256(data: string): string; + verifySha256(data: string, encrypted: string, publicKey: string): boolean; hashSha1(data: string | Buffer): Buffer; hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise; compareBcrypt(data: string | Buffer, encrypted: string): boolean; diff --git a/server/src/interfaces/system-metadata.interface.ts b/server/src/interfaces/system-metadata.interface.ts index 677474460f..fd83d33ee9 100644 --- a/server/src/interfaces/system-metadata.interface.ts +++ b/server/src/interfaces/system-metadata.interface.ts @@ -5,5 +5,6 @@ export const ISystemMetadataRepository = 'ISystemMetadataRepository'; export interface ISystemMetadataRepository { get(key: T): Promise; set(key: T, value: SystemMetadata[T]): Promise; + delete(key: T): Promise; readFile(filename: string): Promise; } diff --git a/server/src/interfaces/user.interface.ts b/server/src/interfaces/user.interface.ts index 87ed2ccb08..3353d45dce 100644 --- a/server/src/interfaces/user.interface.ts +++ b/server/src/interfaces/user.interface.ts @@ -33,6 +33,7 @@ export interface IUserRepository { create(user: Partial): Promise; update(id: string, user: Partial): Promise; upsertMetadata(id: string, item: { key: T; value: UserMetadata[T] }): Promise; + deleteMetadata(id: string, key: T): Promise; delete(user: UserEntity, hard?: boolean): Promise; updateUsage(id: string, delta: number): Promise; syncUsage(id?: string): Promise; diff --git a/server/src/repositories/crypto.repository.ts b/server/src/repositories/crypto.repository.ts index 9102715a17..72e75ef174 100644 --- a/server/src/repositories/crypto.repository.ts +++ b/server/src/repositories/crypto.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { compareSync, hash } from 'bcrypt'; -import { createHash, randomBytes, randomUUID } from 'node:crypto'; +import { createHash, createPublicKey, createVerify, randomBytes, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { Instrumentation } from 'src/utils/instrumentation'; @@ -28,6 +28,21 @@ export class CryptoRepository implements ICryptoRepository { return createHash('sha256').update(value).digest('base64'); } + verifySha256(value: string, encryptedValue: string, publicKey: string) { + const publicKeyBuffer = Buffer.from(publicKey, 'base64'); + const cryptoPublicKey = createPublicKey({ + key: publicKeyBuffer, + type: 'spki', + format: 'pem', + }); + + const verifier = createVerify('SHA256'); + verifier.update(value); + verifier.end(); + const encryptedValueBuffer = Buffer.from(encryptedValue, 'base64'); + return verifier.verify(cryptoPublicKey, encryptedValueBuffer); + } + hashSha1(value: string | Buffer): Buffer { return createHash('sha1').update(value).digest(); } diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index aa03102502..d4e58bf74a 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -26,6 +26,10 @@ export class SystemMetadataRepository implements ISystemMetadataRepository { await this.repository.upsert({ key, value }, { conflictPaths: { key: true } }); } + async delete(key: T): Promise { + await this.repository.delete({ key }); + } + readFile(filename: string): Promise { return readFile(filename, { encoding: 'utf8' }); } diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 1e77633bbf..c64d5a3655 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetEntity } from 'src/entities/asset.entity'; -import { UserMetadata, UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity'; +import { UserMetadata, UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IUserRepository, @@ -89,13 +89,14 @@ export class UserRepository implements IUserRepository { return this.save({ ...user, id }); } - async upsertMetadata( - id: string, - { key, value }: { key: T; value: UserMetadata[T] }, - ) { + async upsertMetadata(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { await this.metadataRepository.upsert({ userId: id, key, value }, { conflictPaths: { userId: true, key: true } }); } + async deleteMetadata(id: string, key: T) { + await this.metadataRepository.delete({ userId: id, key }); + } + async delete(user: UserEntity, hard?: boolean): Promise { return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user); } diff --git a/server/src/services/server.service.spec.ts b/server/src/services/server.service.spec.ts index b1fbb9c2e9..6c7ef03627 100644 --- a/server/src/services/server.service.spec.ts +++ b/server/src/services/server.service.spec.ts @@ -1,9 +1,12 @@ +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { ICryptoRepository } from 'src/interfaces/crypto.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 { ServerService } from 'src/services/server.service'; +import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock'; 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'; @@ -18,6 +21,7 @@ describe(ServerService.name, () => { let serverInfoMock: Mocked; let systemMock: Mocked; let loggerMock: Mocked; + let cryptoMock: Mocked; beforeEach(() => { storageMock = newStorageRepositoryMock(); @@ -25,8 +29,9 @@ describe(ServerService.name, () => { serverInfoMock = newServerInfoRepositoryMock(); systemMock = newSystemMetadataRepositoryMock(); loggerMock = newLoggerRepositoryMock(); + cryptoMock = newCryptoRepositoryMock(); - sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock); + sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock); }); it('should work', () => { @@ -249,4 +254,33 @@ describe(ServerService.name, () => { expect(userMock.getUserStats).toHaveBeenCalled(); }); }); + + describe('setLicense', () => { + it('should save license if valid', async () => { + systemMock.set.mockResolvedValue(); + + const license = { licenseKey: 'IMSV-license-key', activationKey: 'activation-key' }; + await sut.setLicense(license); + + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.LICENSE, expect.any(Object)); + }); + + it('should not save license if invalid', async () => { + userMock.upsertMetadata.mockResolvedValue(); + + const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; + const call = sut.setLicense(license); + await expect(call).rejects.toThrowError('Invalid license key'); + expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + }); + }); + + describe('deleteLicense', () => { + it('should delete license', async () => { + userMock.upsertMetadata.mockResolvedValue(); + + await sut.deleteLicense(); + expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + }); + }); }); diff --git a/server/src/services/server.service.ts b/server/src/services/server.service.ts index e257b435c1..b477f0f35c 100644 --- a/server/src/services/server.service.ts +++ b/server/src/services/server.service.ts @@ -1,8 +1,9 @@ -import { Inject, Injectable } from '@nestjs/common'; -import { getBuildMetadata } from 'src/config'; +import { BadRequestException, Inject, Injectable } from '@nestjs/common'; +import { getBuildMetadata, getServerLicensePublicKey } 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 { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; import { ServerAboutResponseDto, ServerConfigDto, @@ -14,6 +15,7 @@ import { UsageByUserDto, } from 'src/dtos/server.dto'; import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { ICryptoRepository } from 'src/interfaces/crypto.interface'; import { OnEvents } from 'src/interfaces/event.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; @@ -34,6 +36,7 @@ export class ServerService implements OnEvents { @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, @Inject(IServerInfoRepository) private serverInfoRepository: IServerInfoRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, ) { this.logger.setContext(ServerService.name); this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger); @@ -53,10 +56,12 @@ export class ServerService implements OnEvents { const version = `v${serverVersion.toString()}`; const buildMetadata = getBuildMetadata(); const buildVersions = await this.serverInfoRepository.getBuildVersions(); + const licensed = await this.systemMetadataRepository.get(SystemMetadataKey.LICENSE); return { version, versionUrl: `https://github.com/immich-app/immich/releases/tag/${version}`, + licensed: !!licensed, ...buildMetadata, ...buildVersions, }; @@ -154,4 +159,36 @@ export class ServerService implements OnEvents { sidecar: Object.keys(mimeTypes.sidecar), }; } + + async deleteLicense(): Promise { + await this.systemMetadataRepository.delete(SystemMetadataKey.LICENSE); + } + + async getLicense(): Promise { + return this.systemMetadataRepository.get(SystemMetadataKey.LICENSE); + } + + async setLicense(dto: LicenseKeyDto): Promise { + if (!dto.licenseKey.startsWith('IMSV-')) { + throw new BadRequestException('Invalid license key'); + } + const licenseValid = this.cryptoRepository.verifySha256( + dto.licenseKey, + dto.activationKey, + getServerLicensePublicKey(), + ); + + if (!licenseValid) { + throw new BadRequestException('Invalid license key'); + } + + const licenseData = { + ...dto, + activatedAt: new Date(), + }; + + await this.systemMetadataRepository.set(SystemMetadataKey.LICENSE, licenseData); + + return licenseData; + } } diff --git a/server/src/services/user.service.spec.ts b/server/src/services/user.service.spec.ts index f5141a3e35..54fe05df89 100644 --- a/server/src/services/user.service.spec.ts +++ b/server/src/services/user.service.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { UserMetadataKey } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; @@ -285,6 +286,38 @@ describe(UserService.name, () => { }); }); + describe('setLicense', () => { + it('should save license if valid', async () => { + userMock.upsertMetadata.mockResolvedValue(); + + const license = { licenseKey: 'IMCL-license-key', activationKey: 'activation-key' }; + await sut.setLicense(authStub.user1, license); + + expect(userMock.upsertMetadata).toHaveBeenCalledWith(authStub.user1.user.id, { + key: UserMetadataKey.LICENSE, + value: expect.any(Object), + }); + }); + + it('should not save license if invalid', async () => { + userMock.upsertMetadata.mockResolvedValue(); + + const license = { licenseKey: 'license-key', activationKey: 'activation-key' }; + const call = sut.setLicense(authStub.admin, license); + await expect(call).rejects.toThrowError('Invalid license key'); + expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + }); + }); + + describe('deleteLicense', () => { + it('should delete license', async () => { + userMock.upsertMetadata.mockResolvedValue(); + + await sut.deleteLicense(authStub.admin); + expect(userMock.upsertMetadata).not.toHaveBeenCalled(); + }); + }); + describe('handleUserSyncUsage', () => { it('should sync usage', async () => { await sut.handleUserSyncUsage(); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 8626745f90..0ee42b8081 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -1,13 +1,15 @@ import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { DateTime } from 'luxon'; +import { getClientLicensePublicKey } from 'src/config'; import { SALT_ROUNDS } from 'src/constants'; import { StorageCore, StorageFolder } from 'src/cores/storage.core'; import { SystemConfigCore } from 'src/cores/system-config.core'; import { AuthDto } from 'src/dtos/auth.dto'; -import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences } from 'src/dtos/user-preferences.dto'; +import { LicenseKeyDto, LicenseResponseDto } from 'src/dtos/license.dto'; +import { mapPreferences, UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto'; import { CreateProfileImageResponseDto, mapCreateProfileImageResponse } from 'src/dtos/user-profile.dto'; -import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; -import { UserMetadataKey } from 'src/entities/user-metadata.entity'; +import { mapUser, mapUserAdmin, UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto'; +import { UserMetadataEntity, UserMetadataKey } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; @@ -123,6 +125,47 @@ export class UserService { }); } + getLicense({ user }: AuthDto): LicenseResponseDto { + const license = user.metadata.find( + (item): item is UserMetadataEntity => item.key === UserMetadataKey.LICENSE, + ); + if (!license) { + throw new NotFoundException(); + } + return license.value; + } + + async deleteLicense({ user }: AuthDto): Promise { + await this.userRepository.deleteMetadata(user.id, UserMetadataKey.LICENSE); + } + + async setLicense(auth: AuthDto, license: LicenseKeyDto): Promise { + if (!license.licenseKey.startsWith('IMCL-')) { + throw new BadRequestException('Invalid license key'); + } + const licenseValid = this.cryptoRepository.verifySha256( + license.licenseKey, + license.activationKey, + getClientLicensePublicKey(), + ); + + if (!licenseValid) { + throw new BadRequestException('Invalid license key'); + } + + const licenseData = { + ...license, + activatedAt: new Date(), + }; + + await this.userRepository.upsertMetadata(auth.user.id, { + key: UserMetadataKey.LICENSE, + value: licenseData, + }); + + return licenseData; + } + async handleUserSyncUsage(): Promise { await this.userRepository.syncUsage(); return JobStatus.SUCCESS; diff --git a/server/test/fixtures/auth.stub.ts b/server/test/fixtures/auth.stub.ts index e260ee4e8f..bbb53d4db6 100644 --- a/server/test/fixtures/auth.stub.ts +++ b/server/test/fixtures/auth.stub.ts @@ -1,6 +1,7 @@ import { AuthDto } from 'src/dtos/auth.dto'; import { SessionEntity } from 'src/entities/session.entity'; import { SharedLinkEntity } from 'src/entities/shared-link.entity'; +import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity } from 'src/entities/user.entity'; export const authStub = { @@ -9,6 +10,7 @@ export const authStub = { id: 'admin_id', email: 'admin@test.com', isAdmin: true, + metadata: [] as UserMetadataEntity[], } as UserEntity, }), user1: Object.freeze({ @@ -16,6 +18,7 @@ export const authStub = { id: 'user-id', email: 'immich@test.com', isAdmin: false, + metadata: [] as UserMetadataEntity[], } as UserEntity, session: { id: 'token-id', @@ -26,6 +29,7 @@ export const authStub = { id: 'user-2', email: 'user2@immich.app', isAdmin: false, + metadata: [] as UserMetadataEntity[], } as UserEntity, session: { id: 'token-id', @@ -36,6 +40,7 @@ export const authStub = { id: 'user-id', email: 'immich@test.com', isAdmin: false, + metadata: [] as UserMetadataEntity[], } as UserEntity, session: { id: 'token-id', @@ -46,6 +51,7 @@ export const authStub = { id: 'admin_id', email: 'admin@test.com', isAdmin: true, + metadata: [] as UserMetadataEntity[], } as UserEntity, sharedLink: { id: '123', @@ -60,6 +66,7 @@ export const authStub = { id: 'admin_id', email: 'admin@test.com', isAdmin: true, + metadata: [] as UserMetadataEntity[], } as UserEntity, sharedLink: { id: '123', @@ -74,6 +81,7 @@ export const authStub = { id: 'admin_id', email: 'admin@test.com', isAdmin: true, + metadata: [] as UserMetadataEntity[], } as UserEntity, sharedLink: { id: '123', @@ -87,6 +95,7 @@ export const authStub = { id: 'admin_id', email: 'admin@test.com', isAdmin: true, + metadata: [] as UserMetadataEntity[], } as UserEntity, sharedLink: { id: '123', diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index 98641be8bc..e0b6fa2bb8 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -8,6 +8,7 @@ export const newCryptoRepositoryMock = (): Mocked => { compareBcrypt: vitest.fn().mockReturnValue(true), hashBcrypt: vitest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)), hashSha256: vitest.fn().mockImplementation((input) => `${input} (hashed)`), + verifySha256: vitest.fn().mockImplementation(() => true), hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)), hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`), newPassword: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')), diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index 25efdbb011..e44301fb21 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -10,6 +10,7 @@ export const newSystemMetadataRepositoryMock = (reset = true): Mocked => updateUsage: vitest.fn(), syncUsage: vitest.fn(), upsertMetadata: vitest.fn(), + deleteMetadata: vitest.fn(), }; }; diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 67ac638ea0..c89dccb2bb 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -26,4 +26,9 @@ export const userAdminFactory = Sync.makeFactory({ shouldChangePassword: false, status: UserStatus.Active, storageLabel: null, + license: { + licenseKey: 'IMCL-license-key', + activationKey: 'activation-key', + activatedAt: new Date().toISOString(), + }, });