From 7a4ae7d1424f0e2dd6869b35a72c0d7d2d5bfea2 Mon Sep 17 00:00:00 2001 From: Sam Holton Date: Fri, 8 Mar 2024 17:49:39 -0500 Subject: [PATCH 01/35] feat(server,web): add force delete to immediately remove user (#7681) * feat(server,web): add force delete to immediately remove user * update wording on force delete confirmation * fix force delete css * PR feedback * cleanup user service delete for force * adding user status column * some cleanup and tests * more test fixes * run npm run sql:generate * chore: cleanup and websocket * chore: linting * userRepository.restore * removed bad color class from delete-confirm-dialoge * additional confirmation for user force delete * shorten confirmation message --------- Co-authored-by: Jason Rasmussen --- e2e/src/responses.ts | 1 + mobile/openapi/.openapi-generator/FILES | 6 + mobile/openapi/README.md | 2 + mobile/openapi/doc/DeleteUserDto.md | 15 +++ mobile/openapi/doc/PartnerResponseDto.md | 1 + mobile/openapi/doc/UserApi.md | 8 +- mobile/openapi/doc/UserResponseDto.md | 1 + mobile/openapi/doc/UserStatus.md | 14 +++ mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api/user_api.dart | 14 ++- mobile/openapi/lib/api_client.dart | 4 + mobile/openapi/lib/api_helper.dart | 3 + mobile/openapi/lib/model/delete_user_dto.dart | 107 ++++++++++++++++++ .../lib/model/partner_response_dto.dart | 10 +- .../openapi/lib/model/user_response_dto.dart | 10 +- mobile/openapi/lib/model/user_status.dart | 88 ++++++++++++++ mobile/openapi/test/delete_user_dto_test.dart | 27 +++++ .../test/partner_response_dto_test.dart | 5 + mobile/openapi/test/user_api_test.dart | 2 +- .../openapi/test/user_response_dto_test.dart | 5 + mobile/openapi/test/user_status_test.dart | 21 ++++ open-api/immich-openapi-specs.json | 34 ++++++ open-api/typescript-sdk/src/fetch-client.ts | 20 +++- server/src/domain/job/job.service.ts | 5 + .../repositories/communication.repository.ts | 2 + .../domain/repositories/user.repository.ts | 1 - server/src/domain/user/dto/delete-user.dto.ts | 6 + server/src/domain/user/dto/index.ts | 1 + .../user/response-dto/user-response.dto.ts | 5 +- server/src/domain/user/user.service.spec.ts | 44 ++++--- server/src/domain/user/user.service.ts | 29 +++-- .../src/immich/controllers/user.controller.ts | 9 +- server/src/infra/entities/user.entity.ts | 9 ++ .../migrations/1709870213078-AddUserStatus.ts | 14 +++ .../src/infra/repositories/user.repository.ts | 6 +- server/src/infra/sql/album.repository.sql | 13 +++ server/src/infra/sql/api.key.repository.sql | 1 + server/src/infra/sql/library.repository.sql | 4 + .../src/infra/sql/shared.link.repository.sql | 3 + server/src/infra/sql/user.repository.sql | 4 + .../src/infra/sql/user.token.repository.sql | 1 + .../test/repositories/user.repository.mock.ts | 1 - .../admin-page/delete-confirm-dialoge.svelte | 68 ++++++++++- .../admin-page/restore-dialoge.svelte | 14 ++- web/src/lib/stores/websocket.ts | 1 + .../routes/admin/user-management/+page.svelte | 81 +++++++------ web/src/test-data/factories/user-factory.ts | 3 +- 47 files changed, 628 insertions(+), 97 deletions(-) create mode 100644 mobile/openapi/doc/DeleteUserDto.md create mode 100644 mobile/openapi/doc/UserStatus.md create mode 100644 mobile/openapi/lib/model/delete_user_dto.dart create mode 100644 mobile/openapi/lib/model/user_status.dart create mode 100644 mobile/openapi/test/delete_user_dto_test.dart create mode 100644 mobile/openapi/test/user_status_test.dart create mode 100644 server/src/domain/user/dto/delete-user.dto.ts create mode 100644 server/src/infra/migrations/1709870213078-AddUserStatus.ts diff --git a/e2e/src/responses.ts b/e2e/src/responses.ts index 76e289ade25db..37892be0c86a6 100644 --- a/e2e/src/responses.ts +++ b/e2e/src/responses.ts @@ -76,6 +76,7 @@ export const signupResponseDto = { memoriesEnabled: true, quotaUsageInBytes: 0, quotaSizeInBytes: null, + status: 'active', }, }; diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index bdd8e1d4bcff2..ddebdaf77df08 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -58,6 +58,7 @@ doc/CreateTagDto.md doc/CreateUserDto.md doc/CuratedLocationsResponseDto.md doc/CuratedObjectsResponseDto.md +doc/DeleteUserDto.md doc/DownloadApi.md doc/DownloadArchiveInfo.md doc/DownloadInfoDto.md @@ -184,6 +185,7 @@ doc/UserApi.md doc/UserAvatarColor.md doc/UserDto.md doc/UserResponseDto.md +doc/UserStatus.md doc/ValidateAccessTokenResponseDto.md doc/ValidateLibraryDto.md doc/ValidateLibraryImportPathResponseDto.md @@ -268,6 +270,7 @@ lib/model/create_tag_dto.dart lib/model/create_user_dto.dart lib/model/curated_locations_response_dto.dart lib/model/curated_objects_response_dto.dart +lib/model/delete_user_dto.dart lib/model/download_archive_info.dart lib/model/download_info_dto.dart lib/model/download_response_dto.dart @@ -380,6 +383,7 @@ lib/model/usage_by_user_dto.dart lib/model/user_avatar_color.dart lib/model/user_dto.dart lib/model/user_response_dto.dart +lib/model/user_status.dart lib/model/validate_access_token_response_dto.dart lib/model/validate_library_dto.dart lib/model/validate_library_import_path_response_dto.dart @@ -441,6 +445,7 @@ test/create_tag_dto_test.dart test/create_user_dto_test.dart test/curated_locations_response_dto_test.dart test/curated_objects_response_dto_test.dart +test/delete_user_dto_test.dart test/download_api_test.dart test/download_archive_info_test.dart test/download_info_dto_test.dart @@ -567,6 +572,7 @@ test/user_api_test.dart test/user_avatar_color_test.dart test/user_dto_test.dart test/user_response_dto_test.dart +test/user_status_test.dart test/validate_access_token_response_dto_test.dart test/validate_library_dto_test.dart test/validate_library_import_path_response_dto_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 536c671b8df40..8f060a4a665d0 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -264,6 +264,7 @@ Class | Method | HTTP request | Description - [CreateUserDto](doc//CreateUserDto.md) - [CuratedLocationsResponseDto](doc//CuratedLocationsResponseDto.md) - [CuratedObjectsResponseDto](doc//CuratedObjectsResponseDto.md) + - [DeleteUserDto](doc//DeleteUserDto.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponseDto](doc//DownloadResponseDto.md) @@ -376,6 +377,7 @@ Class | Method | HTTP request | Description - [UserAvatarColor](doc//UserAvatarColor.md) - [UserDto](doc//UserDto.md) - [UserResponseDto](doc//UserResponseDto.md) + - [UserStatus](doc//UserStatus.md) - [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md) - [ValidateLibraryDto](doc//ValidateLibraryDto.md) - [ValidateLibraryImportPathResponseDto](doc//ValidateLibraryImportPathResponseDto.md) diff --git a/mobile/openapi/doc/DeleteUserDto.md b/mobile/openapi/doc/DeleteUserDto.md new file mode 100644 index 0000000000000..50894b61675aa --- /dev/null +++ b/mobile/openapi/doc/DeleteUserDto.md @@ -0,0 +1,15 @@ +# openapi.model.DeleteUserDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**force** | **bool** | | [optional] + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/doc/PartnerResponseDto.md b/mobile/openapi/doc/PartnerResponseDto.md index ce45b32594696..5d0c4ddf37774 100644 --- a/mobile/openapi/doc/PartnerResponseDto.md +++ b/mobile/openapi/doc/PartnerResponseDto.md @@ -22,6 +22,7 @@ Name | Type | Description | Notes **quotaSizeInBytes** | **int** | | **quotaUsageInBytes** | **int** | | **shouldChangePassword** | **bool** | | +**status** | [**UserStatus**](UserStatus.md) | | **storageLabel** | **String** | | **updatedAt** | [**DateTime**](DateTime.md) | | diff --git a/mobile/openapi/doc/UserApi.md b/mobile/openapi/doc/UserApi.md index 62f3148061829..61df5d4de06fc 100644 --- a/mobile/openapi/doc/UserApi.md +++ b/mobile/openapi/doc/UserApi.md @@ -182,7 +182,7 @@ void (empty response body) [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) # **deleteUser** -> UserResponseDto deleteUser(id) +> UserResponseDto deleteUser(id, deleteUserDto) @@ -206,9 +206,10 @@ import 'package:openapi/api.dart'; final api_instance = UserApi(); final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | +final deleteUserDto = DeleteUserDto(); // DeleteUserDto | try { - final result = api_instance.deleteUser(id); + final result = api_instance.deleteUser(id, deleteUserDto); print(result); } catch (e) { print('Exception when calling UserApi->deleteUser: $e\n'); @@ -220,6 +221,7 @@ try { Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- **id** | **String**| | + **deleteUserDto** | [**DeleteUserDto**](DeleteUserDto.md)| | ### Return type @@ -231,7 +233,7 @@ Name | Type | Description | Notes ### HTTP request headers - - **Content-Type**: Not defined + - **Content-Type**: application/json - **Accept**: application/json [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) diff --git a/mobile/openapi/doc/UserResponseDto.md b/mobile/openapi/doc/UserResponseDto.md index 700a5b849efd1..69d85fbbd4b10 100644 --- a/mobile/openapi/doc/UserResponseDto.md +++ b/mobile/openapi/doc/UserResponseDto.md @@ -21,6 +21,7 @@ Name | Type | Description | Notes **quotaSizeInBytes** | **int** | | **quotaUsageInBytes** | **int** | | **shouldChangePassword** | **bool** | | +**status** | [**UserStatus**](UserStatus.md) | | **storageLabel** | **String** | | **updatedAt** | [**DateTime**](DateTime.md) | | diff --git a/mobile/openapi/doc/UserStatus.md b/mobile/openapi/doc/UserStatus.md new file mode 100644 index 0000000000000..02abb4eff9385 --- /dev/null +++ b/mobile/openapi/doc/UserStatus.md @@ -0,0 +1,14 @@ +# openapi.model.UserStatus + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + + diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 0a093e45365b2..5b49d8d67f950 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -99,6 +99,7 @@ part 'model/create_tag_dto.dart'; part 'model/create_user_dto.dart'; part 'model/curated_locations_response_dto.dart'; part 'model/curated_objects_response_dto.dart'; +part 'model/delete_user_dto.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response_dto.dart'; @@ -211,6 +212,7 @@ part 'model/usage_by_user_dto.dart'; part 'model/user_avatar_color.dart'; part 'model/user_dto.dart'; part 'model/user_response_dto.dart'; +part 'model/user_status.dart'; part 'model/validate_access_token_response_dto.dart'; part 'model/validate_library_dto.dart'; part 'model/validate_library_import_path_response_dto.dart'; diff --git a/mobile/openapi/lib/api/user_api.dart b/mobile/openapi/lib/api/user_api.dart index f92b8fe9f5b80..241c1698c346b 100644 --- a/mobile/openapi/lib/api/user_api.dart +++ b/mobile/openapi/lib/api/user_api.dart @@ -157,19 +157,21 @@ class UserApi { /// Parameters: /// /// * [String] id (required): - Future deleteUserWithHttpInfo(String id,) async { + /// + /// * [DeleteUserDto] deleteUserDto (required): + Future deleteUserWithHttpInfo(String id, DeleteUserDto deleteUserDto,) async { // ignore: prefer_const_declarations final path = r'/user/{id}' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody; + Object? postBody = deleteUserDto; final queryParams = []; final headerParams = {}; final formParams = {}; - const contentTypes = []; + const contentTypes = ['application/json']; return apiClient.invokeAPI( @@ -186,8 +188,10 @@ class UserApi { /// Parameters: /// /// * [String] id (required): - Future deleteUser(String id,) async { - final response = await deleteUserWithHttpInfo(id,); + /// + /// * [DeleteUserDto] deleteUserDto (required): + Future deleteUser(String id, DeleteUserDto deleteUserDto,) async { + final response = await deleteUserWithHttpInfo(id, deleteUserDto,); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 5e5f702996d9a..312153788c5a0 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -280,6 +280,8 @@ class ApiClient { return CuratedLocationsResponseDto.fromJson(value); case 'CuratedObjectsResponseDto': return CuratedObjectsResponseDto.fromJson(value); + case 'DeleteUserDto': + return DeleteUserDto.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -504,6 +506,8 @@ class ApiClient { return UserDto.fromJson(value); case 'UserResponseDto': return UserResponseDto.fromJson(value); + case 'UserStatus': + return UserStatusTypeTransformer().decode(value); case 'ValidateAccessTokenResponseDto': return ValidateAccessTokenResponseDto.fromJson(value); case 'ValidateLibraryDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index f37ba588a3128..d186845d94dc3 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -136,6 +136,9 @@ String parameterToString(dynamic value) { if (value is UserAvatarColor) { return UserAvatarColorTypeTransformer().encode(value).toString(); } + if (value is UserStatus) { + return UserStatusTypeTransformer().encode(value).toString(); + } if (value is VideoCodec) { return VideoCodecTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/delete_user_dto.dart b/mobile/openapi/lib/model/delete_user_dto.dart new file mode 100644 index 0000000000000..d62f40b1ee607 --- /dev/null +++ b/mobile/openapi/lib/model/delete_user_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 DeleteUserDto { + /// Returns a new [DeleteUserDto] instance. + DeleteUserDto({ + this.force, + }); + + /// + /// 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. + /// + bool? force; + + @override + bool operator ==(Object other) => identical(this, other) || other is DeleteUserDto && + other.force == force; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (force == null ? 0 : force!.hashCode); + + @override + String toString() => 'DeleteUserDto[force=$force]'; + + Map toJson() { + final json = {}; + if (this.force != null) { + json[r'force'] = this.force; + } else { + // json[r'force'] = null; + } + return json; + } + + /// Returns a new [DeleteUserDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DeleteUserDto? fromJson(dynamic value) { + if (value is Map) { + final json = value.cast(); + + return DeleteUserDto( + force: mapValueOfType(json, r'force'), + ); + } + 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 = DeleteUserDto.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 = DeleteUserDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DeleteUserDto-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] = DeleteUserDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/partner_response_dto.dart b/mobile/openapi/lib/model/partner_response_dto.dart index 008e0c4f2673a..37602d04b72a2 100644 --- a/mobile/openapi/lib/model/partner_response_dto.dart +++ b/mobile/openapi/lib/model/partner_response_dto.dart @@ -27,6 +27,7 @@ class PartnerResponseDto { required this.quotaSizeInBytes, required this.quotaUsageInBytes, required this.shouldChangePassword, + required this.status, required this.storageLabel, required this.updatedAt, }); @@ -71,6 +72,8 @@ class PartnerResponseDto { bool shouldChangePassword; + UserStatus status; + String? storageLabel; DateTime updatedAt; @@ -91,6 +94,7 @@ class PartnerResponseDto { other.quotaSizeInBytes == quotaSizeInBytes && other.quotaUsageInBytes == quotaUsageInBytes && other.shouldChangePassword == shouldChangePassword && + other.status == status && other.storageLabel == storageLabel && other.updatedAt == updatedAt; @@ -111,11 +115,12 @@ class PartnerResponseDto { (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) + (shouldChangePassword.hashCode) + + (status.hashCode) + (storageLabel == null ? 0 : storageLabel!.hashCode) + (updatedAt.hashCode); @override - String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'PartnerResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, inTimeline=$inTimeline, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -153,6 +158,7 @@ class PartnerResponseDto { // json[r'quotaUsageInBytes'] = null; } json[r'shouldChangePassword'] = this.shouldChangePassword; + json[r'status'] = this.status; if (this.storageLabel != null) { json[r'storageLabel'] = this.storageLabel; } else { @@ -184,6 +190,7 @@ class PartnerResponseDto { quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, + status: UserStatus.fromJson(json[r'status'])!, storageLabel: mapValueOfType(json, r'storageLabel'), updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); @@ -245,6 +252,7 @@ class PartnerResponseDto { 'quotaSizeInBytes', 'quotaUsageInBytes', 'shouldChangePassword', + 'status', 'storageLabel', 'updatedAt', }; diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index d4e0bf07dd3b2..df68128e714ba 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -26,6 +26,7 @@ class UserResponseDto { required this.quotaSizeInBytes, required this.quotaUsageInBytes, required this.shouldChangePassword, + required this.status, required this.storageLabel, required this.updatedAt, }); @@ -62,6 +63,8 @@ class UserResponseDto { bool shouldChangePassword; + UserStatus status; + String? storageLabel; DateTime updatedAt; @@ -81,6 +84,7 @@ class UserResponseDto { other.quotaSizeInBytes == quotaSizeInBytes && other.quotaUsageInBytes == quotaUsageInBytes && other.shouldChangePassword == shouldChangePassword && + other.status == status && other.storageLabel == storageLabel && other.updatedAt == updatedAt; @@ -100,11 +104,12 @@ class UserResponseDto { (quotaSizeInBytes == null ? 0 : quotaSizeInBytes!.hashCode) + (quotaUsageInBytes == null ? 0 : quotaUsageInBytes!.hashCode) + (shouldChangePassword.hashCode) + + (status.hashCode) + (storageLabel == null ? 0 : storageLabel!.hashCode) + (updatedAt.hashCode); @override - String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, storageLabel=$storageLabel, updatedAt=$updatedAt]'; + String toString() => 'UserResponseDto[avatarColor=$avatarColor, createdAt=$createdAt, deletedAt=$deletedAt, email=$email, id=$id, isAdmin=$isAdmin, memoriesEnabled=$memoriesEnabled, name=$name, oauthId=$oauthId, profileImagePath=$profileImagePath, quotaSizeInBytes=$quotaSizeInBytes, quotaUsageInBytes=$quotaUsageInBytes, shouldChangePassword=$shouldChangePassword, status=$status, storageLabel=$storageLabel, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -137,6 +142,7 @@ class UserResponseDto { // json[r'quotaUsageInBytes'] = null; } json[r'shouldChangePassword'] = this.shouldChangePassword; + json[r'status'] = this.status; if (this.storageLabel != null) { json[r'storageLabel'] = this.storageLabel; } else { @@ -167,6 +173,7 @@ class UserResponseDto { quotaSizeInBytes: mapValueOfType(json, r'quotaSizeInBytes'), quotaUsageInBytes: mapValueOfType(json, r'quotaUsageInBytes'), shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, + status: UserStatus.fromJson(json[r'status'])!, storageLabel: mapValueOfType(json, r'storageLabel'), updatedAt: mapDateTime(json, r'updatedAt', r'')!, ); @@ -228,6 +235,7 @@ class UserResponseDto { 'quotaSizeInBytes', 'quotaUsageInBytes', 'shouldChangePassword', + 'status', 'storageLabel', 'updatedAt', }; diff --git a/mobile/openapi/lib/model/user_status.dart b/mobile/openapi/lib/model/user_status.dart new file mode 100644 index 0000000000000..cbbe1b56d9840 --- /dev/null +++ b/mobile/openapi/lib/model/user_status.dart @@ -0,0 +1,88 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 UserStatus { + /// Instantiate a new enum with the provided [value]. + const UserStatus._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const active = UserStatus._(r'active'); + static const removing = UserStatus._(r'removing'); + static const deleted = UserStatus._(r'deleted'); + + /// List of all possible values in this [enum][UserStatus]. + static const values = [ + active, + removing, + deleted, + ]; + + static UserStatus? fromJson(dynamic value) => UserStatusTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = UserStatus.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [UserStatus] to String, +/// and [decode] dynamic data back to [UserStatus]. +class UserStatusTypeTransformer { + factory UserStatusTypeTransformer() => _instance ??= const UserStatusTypeTransformer._(); + + const UserStatusTypeTransformer._(); + + String encode(UserStatus data) => data.value; + + /// Decodes a [dynamic value][data] to a UserStatus. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + UserStatus? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'active': return UserStatus.active; + case r'removing': return UserStatus.removing; + case r'deleted': return UserStatus.deleted; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [UserStatusTypeTransformer] instance. + static UserStatusTypeTransformer? _instance; +} + diff --git a/mobile/openapi/test/delete_user_dto_test.dart b/mobile/openapi/test/delete_user_dto_test.dart new file mode 100644 index 0000000000000..475681d420ae1 --- /dev/null +++ b/mobile/openapi/test/delete_user_dto_test.dart @@ -0,0 +1,27 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for DeleteUserDto +void main() { + // final instance = DeleteUserDto(); + + group('test DeleteUserDto', () { + // bool force + test('to test the property `force`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/partner_response_dto_test.dart b/mobile/openapi/test/partner_response_dto_test.dart index 7fce31d5ebd46..2eef7f0c83497 100644 --- a/mobile/openapi/test/partner_response_dto_test.dart +++ b/mobile/openapi/test/partner_response_dto_test.dart @@ -86,6 +86,11 @@ void main() { // TODO }); + // UserStatus status + test('to test the property `status`', () async { + // TODO + }); + // String storageLabel test('to test the property `storageLabel`', () async { // TODO diff --git a/mobile/openapi/test/user_api_test.dart b/mobile/openapi/test/user_api_test.dart index b0a3ba85f1fb6..61df36243dad0 100644 --- a/mobile/openapi/test/user_api_test.dart +++ b/mobile/openapi/test/user_api_test.dart @@ -32,7 +32,7 @@ void main() { // TODO }); - //Future deleteUser(String id) async + //Future deleteUser(String id, DeleteUserDto deleteUserDto) async test('test deleteUser', () async { // TODO }); diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart index d0fdf97e12954..71fa57f488280 100644 --- a/mobile/openapi/test/user_response_dto_test.dart +++ b/mobile/openapi/test/user_response_dto_test.dart @@ -81,6 +81,11 @@ void main() { // TODO }); + // UserStatus status + test('to test the property `status`', () async { + // TODO + }); + // String storageLabel test('to test the property `storageLabel`', () async { // TODO diff --git a/mobile/openapi/test/user_status_test.dart b/mobile/openapi/test/user_status_test.dart new file mode 100644 index 0000000000000..88abba0459225 --- /dev/null +++ b/mobile/openapi/test/user_status_test.dart @@ -0,0 +1,21 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.12 + +// 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 + +import 'package:openapi/api.dart'; +import 'package:test/test.dart'; + +// tests for UserStatus +void main() { + + group('test UserStatus', () { + + }); + +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8819825b9175d..97386426089b3 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6402,6 +6402,16 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeleteUserDto" + } + } + }, + "required": true + }, "responses": { "200": { "content": { @@ -7750,6 +7760,14 @@ ], "type": "object" }, + "DeleteUserDto": { + "properties": { + "force": { + "type": "boolean" + } + }, + "type": "object" + }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -8616,6 +8634,9 @@ "shouldChangePassword": { "type": "boolean" }, + "status": { + "$ref": "#/components/schemas/UserStatus" + }, "storageLabel": { "nullable": true, "type": "string" @@ -8638,6 +8659,7 @@ "quotaSizeInBytes", "quotaUsageInBytes", "shouldChangePassword", + "status", "storageLabel", "updatedAt" ], @@ -10561,6 +10583,9 @@ "shouldChangePassword": { "type": "boolean" }, + "status": { + "$ref": "#/components/schemas/UserStatus" + }, "storageLabel": { "nullable": true, "type": "string" @@ -10583,11 +10608,20 @@ "quotaSizeInBytes", "quotaUsageInBytes", "shouldChangePassword", + "status", "storageLabel", "updatedAt" ], "type": "object" }, + "UserStatus": { + "enum": [ + "active", + "removing", + "deleted" + ], + "type": "string" + }, "ValidateAccessTokenResponseDto": { "properties": { "authStatus": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index e9ce467127c56..68f04b4db5b67 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -75,6 +75,7 @@ export type UserResponseDto = { quotaSizeInBytes: number | null; quotaUsageInBytes: number | null; shouldChangePassword: boolean; + status: UserStatus; storageLabel: string | null; updatedAt: string; }; @@ -518,6 +519,7 @@ export type PartnerResponseDto = { quotaSizeInBytes: number | null; quotaUsageInBytes: number | null; shouldChangePassword: boolean; + status: UserStatus; storageLabel: string | null; updatedAt: string; }; @@ -994,6 +996,9 @@ export type CreateProfileImageResponseDto = { profileImagePath: string; userId: string; }; +export type DeleteUserDto = { + force?: boolean; +}; export function getActivities({ albumId, assetId, level, $type, userId }: { albumId: string; assetId?: string; @@ -2678,16 +2683,18 @@ export function getProfileImage({ id }: { ...opts })); } -export function deleteUser({ id }: { +export function deleteUser({ id, deleteUserDto }: { id: string; + deleteUserDto: DeleteUserDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: UserResponseDto; - }>(`/user/${encodeURIComponent(id)}`, { + }>(`/user/${encodeURIComponent(id)}`, oazapfts.json({ ...opts, - method: "DELETE" - })); + method: "DELETE", + body: deleteUserDto + }))); } export function restoreUser({ id }: { id: string; @@ -2724,6 +2731,11 @@ export enum UserAvatarColor { Gray = "gray", Amber = "amber" } +export enum UserStatus { + Active = "active", + Removing = "removing", + Deleted = "deleted" +} export enum TagTypeEnum { Object = "OBJECT", Face = "FACE", diff --git a/server/src/domain/job/job.service.ts b/server/src/domain/job/job.service.ts index 1010185b2ca54..5d5333f3ab87d 100644 --- a/server/src/domain/job/job.service.ts +++ b/server/src/domain/job/job.service.ts @@ -280,6 +280,11 @@ export class JobService { } break; } + + case JobName.USER_DELETION: { + this.communicationRepository.broadcast(ClientEvent.USER_DELETE, item.data.id); + break; + } } } } diff --git a/server/src/domain/repositories/communication.repository.ts b/server/src/domain/repositories/communication.repository.ts index 4a3bc552c9cf3..65e322702f2f7 100644 --- a/server/src/domain/repositories/communication.repository.ts +++ b/server/src/domain/repositories/communication.repository.ts @@ -4,6 +4,7 @@ export const ICommunicationRepository = 'ICommunicationRepository'; export enum ClientEvent { UPLOAD_SUCCESS = 'on_upload_success', + USER_DELETE = 'on_user_delete', ASSET_DELETE = 'on_asset_delete', ASSET_TRASH = 'on_asset_trash', ASSET_UPDATE = 'on_asset_update', @@ -22,6 +23,7 @@ export enum ServerEvent { export interface ClientEventMap { [ClientEvent.UPLOAD_SUCCESS]: AssetResponseDto; + [ClientEvent.USER_DELETE]: string; [ClientEvent.ASSET_DELETE]: string; [ClientEvent.ASSET_TRASH]: string[]; [ClientEvent.ASSET_UPDATE]: AssetResponseDto; diff --git a/server/src/domain/repositories/user.repository.ts b/server/src/domain/repositories/user.repository.ts index cecdb0b06e2df..efd950318f4fb 100644 --- a/server/src/domain/repositories/user.repository.ts +++ b/server/src/domain/repositories/user.repository.ts @@ -32,7 +32,6 @@ export interface IUserRepository { create(user: Partial): Promise; update(id: string, user: Partial): Promise; delete(user: UserEntity, hard?: boolean): Promise; - restore(user: UserEntity): Promise; updateUsage(id: string, delta: number): Promise; syncUsage(id?: string): Promise; } diff --git a/server/src/domain/user/dto/delete-user.dto.ts b/server/src/domain/user/dto/delete-user.dto.ts new file mode 100644 index 0000000000000..88f55f4af517f --- /dev/null +++ b/server/src/domain/user/dto/delete-user.dto.ts @@ -0,0 +1,6 @@ +import { ValidateBoolean } from '../../domain.util'; + +export class DeleteUserDto { + @ValidateBoolean({ optional: true }) + force?: boolean; +} diff --git a/server/src/domain/user/dto/index.ts b/server/src/domain/user/dto/index.ts index 09d7998e8effc..2d166de3689b2 100644 --- a/server/src/domain/user/dto/index.ts +++ b/server/src/domain/user/dto/index.ts @@ -1,3 +1,4 @@ export * from './create-profile-image.dto'; export * from './create-user.dto'; +export * from './delete-user.dto'; export * from './update-user.dto'; diff --git a/server/src/domain/user/response-dto/user-response.dto.ts b/server/src/domain/user/response-dto/user-response.dto.ts index a82337945e59a..bd437ea3444b3 100644 --- a/server/src/domain/user/response-dto/user-response.dto.ts +++ b/server/src/domain/user/response-dto/user-response.dto.ts @@ -1,4 +1,4 @@ -import { UserAvatarColor, UserEntity } from '@app/infra/entities'; +import { UserAvatarColor, UserEntity, UserStatus } from '@app/infra/entities'; import { ApiProperty } from '@nestjs/swagger'; import { IsEnum } from 'class-validator'; @@ -33,6 +33,8 @@ export class UserResponseDto extends UserDto { quotaSizeInBytes!: number | null; @ApiProperty({ type: 'integer', format: 'int64' }) quotaUsageInBytes!: number | null; + @ApiProperty({ enumName: 'UserStatus', enum: UserStatus }) + status!: string; } export const mapSimpleUser = (entity: UserEntity): UserDto => { @@ -58,5 +60,6 @@ export function mapUser(entity: UserEntity): UserResponseDto { memoriesEnabled: entity.memoriesEnabled, quotaSizeInBytes: entity.quotaSizeInBytes, quotaUsageInBytes: entity.quotaUsageInBytes, + status: entity.status, }; } diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index dba0106fb631e..d0e56e4cd36dd 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -1,4 +1,4 @@ -import { UserEntity } from '@app/infra/entities'; +import { UserEntity, UserStatus } from '@app/infra/entities'; import { BadRequestException, ForbiddenException, @@ -243,16 +243,14 @@ describe(UserService.name, () => { it('should throw error if user could not be found', async () => { when(userMock.get).calledWith(userStub.admin.id, { withDeleted: true }).mockResolvedValue(null); await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); - expect(userMock.restore).not.toHaveBeenCalled(); + expect(userMock.update).not.toHaveBeenCalled(); }); it('should restore an user', async () => { userMock.get.mockResolvedValue(userStub.user1); - userMock.restore.mockResolvedValue(userStub.user1); - + userMock.update.mockResolvedValue(userStub.user1); await expect(sut.restore(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1)); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, { withDeleted: true }); - expect(userMock.restore).toHaveBeenCalledWith(userStub.user1); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { status: UserStatus.ACTIVE, deletedAt: null }); }); }); @@ -260,27 +258,47 @@ describe(UserService.name, () => { it('should throw error if user could not be found', async () => { userMock.get.mockResolvedValue(null); - await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); + await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException); expect(userMock.delete).not.toHaveBeenCalled(); }); it('cannot delete admin user', async () => { - await expect(sut.delete(authStub.admin, userStub.admin.id)).rejects.toBeInstanceOf(ForbiddenException); + await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toBeInstanceOf(ForbiddenException); }); it('should require the auth user be an admin', async () => { - await expect(sut.delete(authStub.user1, authStub.admin.user.id)).rejects.toBeInstanceOf(ForbiddenException); + await expect(sut.delete(authStub.user1, authStub.admin.user.id, {})).rejects.toBeInstanceOf(ForbiddenException); expect(userMock.delete).not.toHaveBeenCalled(); }); it('should delete user', async () => { userMock.get.mockResolvedValue(userStub.user1); - userMock.delete.mockResolvedValue(userStub.user1); + userMock.update.mockResolvedValue(userStub.user1); - await expect(sut.delete(authStub.admin, userStub.user1.id)).resolves.toEqual(mapUser(userStub.user1)); - expect(userMock.get).toHaveBeenCalledWith(userStub.user1.id, {}); - expect(userMock.delete).toHaveBeenCalledWith(userStub.user1); + await expect(sut.delete(authStub.admin, userStub.user1.id, {})).resolves.toEqual(mapUser(userStub.user1)); + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + status: UserStatus.DELETED, + deletedAt: expect.any(Date), + }); + }); + + it('should force delete user', async () => { + userMock.get.mockResolvedValue(userStub.user1); + userMock.update.mockResolvedValue(userStub.user1); + + await expect(sut.delete(authStub.admin, userStub.user1.id, { force: true })).resolves.toEqual( + mapUser(userStub.user1), + ); + + expect(userMock.update).toHaveBeenCalledWith(userStub.user1.id, { + status: UserStatus.REMOVING, + deletedAt: expect.any(Date), + }); + expect(jobMock.queue).toHaveBeenCalledWith({ + name: JobName.USER_DELETION, + data: { id: userStub.user1.id, force: true }, + }); }); }); diff --git a/server/src/domain/user/user.service.ts b/server/src/domain/user/user.service.ts index 9a862199b8dde..564163d77576a 100644 --- a/server/src/domain/user/user.service.ts +++ b/server/src/domain/user/user.service.ts @@ -1,4 +1,4 @@ -import { UserEntity } from '@app/infra/entities'; +import { UserEntity, UserStatus } from '@app/infra/entities'; import { ImmichLogger } from '@app/infra/logger'; import { BadRequestException, ForbiddenException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import { DateTime } from 'luxon'; @@ -18,7 +18,7 @@ import { } from '../repositories'; import { StorageCore, StorageFolder } from '../storage'; import { SystemConfigCore } from '../system-config/system-config.core'; -import { CreateUserDto, UpdateUserDto } from './dto'; +import { CreateUserDto, DeleteUserDto, UpdateUserDto } from './dto'; import { CreateProfileImageResponseDto, UserResponseDto, mapCreateProfileImageResponse, mapUser } from './response-dto'; import { UserCore } from './user.core'; @@ -73,22 +73,29 @@ export class UserService { return this.userCore.updateUser(auth.user, dto.id, dto).then(mapUser); } - async delete(auth: AuthDto, id: string): Promise { - const user = await this.findOrFail(id, {}); - if (user.isAdmin) { + async delete(auth: AuthDto, id: string, dto: DeleteUserDto): Promise { + const { force } = dto; + const { isAdmin } = await this.findOrFail(id, {}); + if (isAdmin) { throw new ForbiddenException('Cannot delete admin user'); } await this.albumRepository.softDeleteAll(id); - return this.userRepository.delete(user).then(mapUser); + const status = force ? UserStatus.REMOVING : UserStatus.DELETED; + const user = await this.userRepository.update(id, { status, deletedAt: new Date() }); + + if (force) { + await this.jobRepository.queue({ name: JobName.USER_DELETION, data: { id: user.id, force } }); + } + + return mapUser(user); } async restore(auth: AuthDto, id: string): Promise { - let user = await this.findOrFail(id, { withDeleted: true }); - user = await this.userRepository.restore(user); + await this.findOrFail(id, { withDeleted: true }); await this.albumRepository.restoreAll(id); - return mapUser(user); + return this.userRepository.update(id, { deletedAt: null, status: UserStatus.ACTIVE }).then(mapUser); } async createProfileImage(auth: AuthDto, fileInfo: Express.Multer.File): Promise { @@ -154,7 +161,7 @@ export class UserService { return true; } - async handleUserDelete({ id }: IEntityJob) { + async handleUserDelete({ id, force }: IEntityJob) { const config = await this.configCore.getConfig(); const user = await this.userRepository.get(id, { withDeleted: true }); if (!user) { @@ -162,7 +169,7 @@ export class UserService { } // just for extra protection here - if (!this.isReadyForDeletion(user, config.user.deleteDelay)) { + if (!force && !this.isReadyForDeletion(user, config.user.deleteDelay)) { this.logger.warn(`Skipped user that was not ready for deletion: id=${id}`); return false; } diff --git a/server/src/immich/controllers/user.controller.ts b/server/src/immich/controllers/user.controller.ts index 2cf2c6f86d16d..7fa7ccd0fd15c 100644 --- a/server/src/immich/controllers/user.controller.ts +++ b/server/src/immich/controllers/user.controller.ts @@ -3,6 +3,7 @@ import { CreateUserDto as CreateDto, CreateProfileImageDto, CreateProfileImageResponseDto, + DeleteUserDto, UpdateUserDto as UpdateDto, UserResponseDto, UserService, @@ -66,8 +67,12 @@ export class UserController { @AdminRoute() @Delete(':id') - deleteUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { - return this.service.delete(auth, id); + deleteUser( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: DeleteUserDto, + ): Promise { + return this.service.delete(auth, id, dto); } @AdminRoute() diff --git a/server/src/infra/entities/user.entity.ts b/server/src/infra/entities/user.entity.ts index c574595ea8c59..20c057d790606 100644 --- a/server/src/infra/entities/user.entity.ts +++ b/server/src/infra/entities/user.entity.ts @@ -23,6 +23,12 @@ export enum UserAvatarColor { AMBER = 'amber', } +export enum UserStatus { + ACTIVE = 'active', + REMOVING = 'removing', + DELETED = 'deleted', +} + @Entity('users') export class UserEntity { @PrimaryGeneratedColumn('uuid') @@ -61,6 +67,9 @@ export class UserEntity { @DeleteDateColumn({ type: 'timestamptz' }) deletedAt!: Date | null; + @Column({ type: 'varchar', default: UserStatus.ACTIVE }) + status!: UserStatus; + @UpdateDateColumn({ type: 'timestamptz' }) updatedAt!: Date; diff --git a/server/src/infra/migrations/1709870213078-AddUserStatus.ts b/server/src/infra/migrations/1709870213078-AddUserStatus.ts new file mode 100644 index 0000000000000..858f51258fde7 --- /dev/null +++ b/server/src/infra/migrations/1709870213078-AddUserStatus.ts @@ -0,0 +1,14 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddUserStatus1709870213078 implements MigrationInterface { + name = 'AddUserStatus1709870213078' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" ADD "status" character varying NOT NULL DEFAULT 'active'`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "status"`); + } + +} diff --git a/server/src/infra/repositories/user.repository.ts b/server/src/infra/repositories/user.repository.ts index 640eda0ee475c..d9f12bb314663 100644 --- a/server/src/infra/repositories/user.repository.ts +++ b/server/src/infra/repositories/user.repository.ts @@ -77,10 +77,6 @@ export class UserRepository implements IUserRepository { return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user); } - async restore(user: UserEntity): Promise { - return this.userRepository.recover(user); - } - @GenerateSql() async getUserStats(): Promise { const stats = await this.userRepository @@ -135,6 +131,6 @@ export class UserRepository implements IUserRepository { private async save(user: Partial) { const { id } = await this.userRepository.save(user); - return this.userRepository.findOneByOrFail({ id }); + return this.userRepository.findOneOrFail({ where: { id }, withDeleted: true }); } } diff --git a/server/src/infra/sql/album.repository.sql b/server/src/infra/sql/album.repository.sql index 3997dd1a22914..d9b2e896e90f8 100644 --- a/server/src/infra/sql/album.repository.sql +++ b/server/src/infra/sql/album.repository.sql @@ -26,6 +26,7 @@ FROM "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -41,6 +42,7 @@ FROM "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -100,6 +102,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -115,6 +118,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -156,6 +160,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -171,6 +176,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -284,6 +290,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -311,6 +318,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -355,6 +363,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -382,6 +391,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -463,6 +473,7 @@ SELECT "AlbumEntity__AlbumEntity_sharedUsers"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_sharedUsers_shouldChangePassword", "AlbumEntity__AlbumEntity_sharedUsers"."createdAt" AS "AlbumEntity__AlbumEntity_sharedUsers_createdAt", "AlbumEntity__AlbumEntity_sharedUsers"."deletedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_deletedAt", + "AlbumEntity__AlbumEntity_sharedUsers"."status" AS "AlbumEntity__AlbumEntity_sharedUsers_status", "AlbumEntity__AlbumEntity_sharedUsers"."updatedAt" AS "AlbumEntity__AlbumEntity_sharedUsers_updatedAt", "AlbumEntity__AlbumEntity_sharedUsers"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_sharedUsers_memoriesEnabled", "AlbumEntity__AlbumEntity_sharedUsers"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_sharedUsers_quotaSizeInBytes", @@ -490,6 +501,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", @@ -552,6 +564,7 @@ SELECT "AlbumEntity__AlbumEntity_owner"."shouldChangePassword" AS "AlbumEntity__AlbumEntity_owner_shouldChangePassword", "AlbumEntity__AlbumEntity_owner"."createdAt" AS "AlbumEntity__AlbumEntity_owner_createdAt", "AlbumEntity__AlbumEntity_owner"."deletedAt" AS "AlbumEntity__AlbumEntity_owner_deletedAt", + "AlbumEntity__AlbumEntity_owner"."status" AS "AlbumEntity__AlbumEntity_owner_status", "AlbumEntity__AlbumEntity_owner"."updatedAt" AS "AlbumEntity__AlbumEntity_owner_updatedAt", "AlbumEntity__AlbumEntity_owner"."memoriesEnabled" AS "AlbumEntity__AlbumEntity_owner_memoriesEnabled", "AlbumEntity__AlbumEntity_owner"."quotaSizeInBytes" AS "AlbumEntity__AlbumEntity_owner_quotaSizeInBytes", diff --git a/server/src/infra/sql/api.key.repository.sql b/server/src/infra/sql/api.key.repository.sql index 3f6b207ce19dd..22b8fd67226c2 100644 --- a/server/src/infra/sql/api.key.repository.sql +++ b/server/src/infra/sql/api.key.repository.sql @@ -20,6 +20,7 @@ FROM "APIKeyEntity__APIKeyEntity_user"."shouldChangePassword" AS "APIKeyEntity__APIKeyEntity_user_shouldChangePassword", "APIKeyEntity__APIKeyEntity_user"."createdAt" AS "APIKeyEntity__APIKeyEntity_user_createdAt", "APIKeyEntity__APIKeyEntity_user"."deletedAt" AS "APIKeyEntity__APIKeyEntity_user_deletedAt", + "APIKeyEntity__APIKeyEntity_user"."status" AS "APIKeyEntity__APIKeyEntity_user_status", "APIKeyEntity__APIKeyEntity_user"."updatedAt" AS "APIKeyEntity__APIKeyEntity_user_updatedAt", "APIKeyEntity__APIKeyEntity_user"."memoriesEnabled" AS "APIKeyEntity__APIKeyEntity_user_memoriesEnabled", "APIKeyEntity__APIKeyEntity_user"."quotaSizeInBytes" AS "APIKeyEntity__APIKeyEntity_user_quotaSizeInBytes", diff --git a/server/src/infra/sql/library.repository.sql b/server/src/infra/sql/library.repository.sql index 433ab6fbac43e..93a6fc97fb107 100644 --- a/server/src/infra/sql/library.repository.sql +++ b/server/src/infra/sql/library.repository.sql @@ -28,6 +28,7 @@ FROM "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", + "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", @@ -143,6 +144,7 @@ SELECT "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", + "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", @@ -188,6 +190,7 @@ SELECT "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", + "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", @@ -227,6 +230,7 @@ SELECT "LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword", "LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt", "LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt", + "LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status", "LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt", "LibraryEntity__LibraryEntity_owner"."memoriesEnabled" AS "LibraryEntity__LibraryEntity_owner_memoriesEnabled", "LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes", diff --git a/server/src/infra/sql/shared.link.repository.sql b/server/src/infra/sql/shared.link.repository.sql index 6cac1c44fc5e2..b5e6894130d64 100644 --- a/server/src/infra/sql/shared.link.repository.sql +++ b/server/src/infra/sql/shared.link.repository.sql @@ -155,6 +155,7 @@ FROM "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt", + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", @@ -258,6 +259,7 @@ SELECT "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."shouldChangePassword" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_shouldChangePassword", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."createdAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_createdAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."deletedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_deletedAt", + "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."status" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_status", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."updatedAt" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_updatedAt", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."memoriesEnabled" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_memoriesEnabled", "6d7fd45329a05fd86b3dbcacde87fe76e33a422d"."quotaSizeInBytes" AS "6d7fd45329a05fd86b3dbcacde87fe76e33a422d_quotaSizeInBytes", @@ -311,6 +313,7 @@ FROM "SharedLinkEntity__SharedLinkEntity_user"."shouldChangePassword" AS "SharedLinkEntity__SharedLinkEntity_user_shouldChangePassword", "SharedLinkEntity__SharedLinkEntity_user"."createdAt" AS "SharedLinkEntity__SharedLinkEntity_user_createdAt", "SharedLinkEntity__SharedLinkEntity_user"."deletedAt" AS "SharedLinkEntity__SharedLinkEntity_user_deletedAt", + "SharedLinkEntity__SharedLinkEntity_user"."status" AS "SharedLinkEntity__SharedLinkEntity_user_status", "SharedLinkEntity__SharedLinkEntity_user"."updatedAt" AS "SharedLinkEntity__SharedLinkEntity_user_updatedAt", "SharedLinkEntity__SharedLinkEntity_user"."memoriesEnabled" AS "SharedLinkEntity__SharedLinkEntity_user_memoriesEnabled", "SharedLinkEntity__SharedLinkEntity_user"."quotaSizeInBytes" AS "SharedLinkEntity__SharedLinkEntity_user_quotaSizeInBytes", diff --git a/server/src/infra/sql/user.repository.sql b/server/src/infra/sql/user.repository.sql index e4c7d3a314b4f..b3741bcf75669 100644 --- a/server/src/infra/sql/user.repository.sql +++ b/server/src/infra/sql/user.repository.sql @@ -13,6 +13,7 @@ SELECT "UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword", "UserEntity"."createdAt" AS "UserEntity_createdAt", "UserEntity"."deletedAt" AS "UserEntity_deletedAt", + "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", @@ -59,6 +60,7 @@ SELECT "user"."shouldChangePassword" AS "user_shouldChangePassword", "user"."createdAt" AS "user_createdAt", "user"."deletedAt" AS "user_deletedAt", + "user"."status" AS "user_status", "user"."updatedAt" AS "user_updatedAt", "user"."memoriesEnabled" AS "user_memoriesEnabled", "user"."quotaSizeInBytes" AS "user_quotaSizeInBytes", @@ -82,6 +84,7 @@ SELECT "UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword", "UserEntity"."createdAt" AS "UserEntity_createdAt", "UserEntity"."deletedAt" AS "UserEntity_deletedAt", + "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", @@ -107,6 +110,7 @@ SELECT "UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword", "UserEntity"."createdAt" AS "UserEntity_createdAt", "UserEntity"."deletedAt" AS "UserEntity_deletedAt", + "UserEntity"."status" AS "UserEntity_status", "UserEntity"."updatedAt" AS "UserEntity_updatedAt", "UserEntity"."memoriesEnabled" AS "UserEntity_memoriesEnabled", "UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", diff --git a/server/src/infra/sql/user.token.repository.sql b/server/src/infra/sql/user.token.repository.sql index b51e53106e7ef..f09238e1379b0 100644 --- a/server/src/infra/sql/user.token.repository.sql +++ b/server/src/infra/sql/user.token.repository.sql @@ -23,6 +23,7 @@ FROM "UserTokenEntity__UserTokenEntity_user"."shouldChangePassword" AS "UserTokenEntity__UserTokenEntity_user_shouldChangePassword", "UserTokenEntity__UserTokenEntity_user"."createdAt" AS "UserTokenEntity__UserTokenEntity_user_createdAt", "UserTokenEntity__UserTokenEntity_user"."deletedAt" AS "UserTokenEntity__UserTokenEntity_user_deletedAt", + "UserTokenEntity__UserTokenEntity_user"."status" AS "UserTokenEntity__UserTokenEntity_user_status", "UserTokenEntity__UserTokenEntity_user"."updatedAt" AS "UserTokenEntity__UserTokenEntity_user_updatedAt", "UserTokenEntity__UserTokenEntity_user"."memoriesEnabled" AS "UserTokenEntity__UserTokenEntity_user_memoriesEnabled", "UserTokenEntity__UserTokenEntity_user"."quotaSizeInBytes" AS "UserTokenEntity__UserTokenEntity_user_quotaSizeInBytes", diff --git a/server/test/repositories/user.repository.mock.ts b/server/test/repositories/user.repository.mock.ts index e365a20bd5b26..402b90eaddab8 100644 --- a/server/test/repositories/user.repository.mock.ts +++ b/server/test/repositories/user.repository.mock.ts @@ -17,7 +17,6 @@ export const newUserRepositoryMock = (reset = true): jest.Mocked { try { - const { deletedAt } = await deleteUser({ id: user.id }); + const { deletedAt } = await deleteUser({ + id: user.id, + deleteUserDto: { force: forceDelete }, + }); + if (deletedAt == undefined) { dispatch('fail'); } else { @@ -26,20 +34,68 @@ dispatch('fail'); } }; + + const handleConfirm = (e: Event) => { + userIdInput = (e.target as HTMLInputElement).value; + deleteButtonDisabled = userIdInput != user.email; + }; dispatch('cancel')} + disabled={deleteButtonDisabled} >
-

- {user.name}'s account and assets will be permanently deleted after {$serverConfig.userDeleteDelay} days. -

-

Are you sure you want to continue?

+ {#if forceDelete} +

+ {user.name}'s account and assets will be queued for permanent deletion immediately. +

+ {:else} +

+ {user.name}'s account and assets will be scheduled for permanent deletion in {$serverConfig.userDeleteDelay} + days. +

+ {/if} + +
+ + + { + deleteButtonDisabled = forceDelete; + }} + /> +
+ + {#if forceDelete} +

+ WARNING: This will immediately remove the user and all assets. This cannot be undone and the files cannot be + recovered. +

+ +

+ To confirm, type "{user.email}" below +

+ + + {/if}
diff --git a/web/src/lib/components/admin-page/restore-dialoge.svelte b/web/src/lib/components/admin-page/restore-dialoge.svelte index d9a8ed3bc01b2..b98932f829e07 100644 --- a/web/src/lib/components/admin-page/restore-dialoge.svelte +++ b/web/src/lib/components/admin-page/restore-dialoge.svelte @@ -1,5 +1,6 @@ @@ -123,8 +132,8 @@ {#if shouldShowDeleteConfirmDialog} (shouldShowDeleteConfirmDialog = false)} /> {/if} @@ -132,8 +141,8 @@ {#if shouldShowRestoreDialog} (shouldShowRestoreDialog = false)} /> {/if} @@ -179,9 +188,7 @@ {#if allUsers} {#each allUsers as immichUser, index} - {#if !isDeleted(immichUser)} + {#if !immichUser.deletedAt} {/if} {/if} - {#if isDeleted(immichUser)} + {#if immichUser.deletedAt && immichUser.status === UserStatus.Deleted} diff --git a/web/src/test-data/factories/user-factory.ts b/web/src/test-data/factories/user-factory.ts index 02273f750f8ec..563844e07d0eb 100644 --- a/web/src/test-data/factories/user-factory.ts +++ b/web/src/test-data/factories/user-factory.ts @@ -1,5 +1,5 @@ import { faker } from '@faker-js/faker'; -import { UserAvatarColor, type UserResponseDto } from '@immich/sdk'; +import { UserAvatarColor, UserStatus, type UserResponseDto } from '@immich/sdk'; import { Sync } from 'factory.ts'; export const userFactory = Sync.makeFactory({ @@ -18,4 +18,5 @@ export const userFactory = Sync.makeFactory({ avatarColor: UserAvatarColor.Primary, quotaUsageInBytes: 0, quotaSizeInBytes: null, + status: UserStatus.Active, }); From e8fb529026c535b86e672f56afdee8f3b7436d23 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 Mar 2024 17:16:32 -0600 Subject: [PATCH 02/35] fix(server): getAllAssets doesn't return all assets (#7752) * fix(server): getAllAssets doesn't return all assets * try reverting * fix: archive and remove unused method * update sql * remove unused code * linting --- .../domain/repositories/asset.repository.ts | 11 +---- .../immich/api-v1/asset/asset-repository.ts | 41 ++++++++++++++-- .../immich/api-v1/asset/asset.service.spec.ts | 1 + .../src/immich/api-v1/asset/asset.service.ts | 15 +----- .../infra/repositories/asset.repository.ts | 24 --------- server/src/infra/sql/asset.repository.sql | 49 ------------------- .../repositories/asset.repository.mock.ts | 1 - 7 files changed, 42 insertions(+), 100 deletions(-) diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index dd5e76577c88f..847c97aae3971 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -1,9 +1,4 @@ -import { - AssetSearchOneToOneRelationOptions, - AssetSearchOptions, - ReverseGeocodeResult, - SearchExploreItem, -} from '@app/domain'; +import { AssetSearchOptions, ReverseGeocodeResult, SearchExploreItem } from '@app/domain'; import { AssetEntity, AssetJobStatusEntity, AssetType, ExifEntity } from '@app/infra/entities'; import { FindOptionsRelations, FindOptionsSelect } from 'typeorm'; import { Paginated, PaginationOptions } from '../domain.util'; @@ -140,10 +135,6 @@ export interface IAssetRepository { updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; - getAllByFileCreationDate( - pagination: PaginationOptions, - options?: AssetSearchOneToOneRelationOptions, - ): Paginated; getAllByDeviceId(userId: string, deviceId: string): Promise; updateAll(ids: string[], options: Partial): Promise; save(asset: Pick & Partial): Promise; diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index 2f54db27d0cf1..18feb65dce312 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -1,13 +1,14 @@ -import { AssetEntity } from '@app/infra/entities'; +import { AssetEntity, ExifEntity } from '@app/infra/entities'; +import { OptionalBetween } from '@app/infra/infra.utils'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In } from 'typeorm/find-options/operator/In.js'; import { Repository } from 'typeorm/repository/Repository.js'; +import { AssetSearchDto } from './dto/asset-search.dto'; import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; import { SearchPropertiesDto } from './dto/search-properties.dto'; import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto'; import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto'; - export interface AssetCheck { id: string; checksum: Buffer; @@ -21,6 +22,7 @@ export interface IAssetRepositoryV1 { get(id: string): Promise; getLocationsByUserId(userId: string): Promise; getDetectedObjectsByUserId(userId: string): Promise; + getAllByUserId(userId: string, dto: AssetSearchDto): Promise; getSearchPropertiesByUserId(userId: string): Promise; getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise; getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise; @@ -31,7 +33,40 @@ export const IAssetRepositoryV1 = 'IAssetRepositoryV1'; @Injectable() export class AssetRepositoryV1 implements IAssetRepositoryV1 { - constructor(@InjectRepository(AssetEntity) private assetRepository: Repository) {} + constructor( + @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(ExifEntity) private exifRepository: Repository, + ) {} + + /** + * Retrieves all assets by user ID. + * + * @param ownerId - The ID of the owner. + * @param dto - The AssetSearchDto object containing search criteria. + * @returns A Promise that resolves to an array of AssetEntity objects. + */ + getAllByUserId(ownerId: string, dto: AssetSearchDto): Promise { + return this.assetRepository.find({ + where: { + ownerId, + isVisible: true, + isFavorite: dto.isFavorite, + isArchived: dto.isArchived, + updatedAt: OptionalBetween(dto.updatedAfter, dto.updatedBefore), + }, + relations: { + exifInfo: true, + tags: true, + stack: { assets: true }, + }, + skip: dto.skip || 0, + take: dto.take, + order: { + fileCreatedAt: 'DESC', + }, + withDeleted: true, + }); + } getSearchPropertiesByUserId(userId: string): Promise { return this.assetRepository diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index 48354d440ec60..9f0aa371e8635 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -77,6 +77,7 @@ describe('AssetService', () => { beforeEach(() => { assetRepositoryMockV1 = { get: jest.fn(), + getAllByUserId: jest.fn(), getDetectedObjectsByUserId: jest.fn(), getLocationsByUserId: jest.fn(), getSearchPropertiesByUserId: jest.fn(), diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 923cb4ebe871e..821a7de82a9f4 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -113,19 +113,8 @@ export class AssetService { public async getAllAssets(auth: AuthDto, dto: AssetSearchDto): Promise { const userId = dto.userId || auth.user.id; await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId); - const assets = await this.assetRepository.getAllByFileCreationDate( - { take: dto.take ?? 1000, skip: dto.skip }, - { - ...dto, - userIds: [userId], - withDeleted: true, - orderDirection: 'DESC', - withExif: true, - isVisible: true, - withStacked: true, - }, - ); - return assets.items.map((asset) => mapAsset(asset, { withStack: true })); + const assets = await this.assetRepositoryV1.getAllByUserId(userId, dto); + return assets.map((asset) => mapAsset(asset, { withStack: true, auth })); } async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise { diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 4813056659efa..15aa11523a4ac 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -2,7 +2,6 @@ import { AssetBuilderOptions, AssetCreate, AssetExploreFieldOptions, - AssetSearchOneToOneRelationOptions, AssetSearchOptions, AssetStats, AssetStatsOptions, @@ -233,29 +232,6 @@ export class AssetRepository implements IAssetRepository { }); } - @GenerateSql({ - params: [ - { skip: 20_000, take: 10_000 }, - { - takenBefore: DummyValue.DATE, - userIds: [DummyValue.UUID], - }, - ], - }) - getAllByFileCreationDate( - pagination: PaginationOptions, - options: AssetSearchOneToOneRelationOptions = {}, - ): Paginated { - let builder = this.repository.createQueryBuilder('asset'); - builder = searchAssetBuilder(builder, options); - builder.orderBy('asset.fileCreatedAt', options.orderDirection ?? 'DESC'); - return paginatedBuilder(builder, { - mode: PaginationMode.LIMIT_OFFSET, - skip: pagination.skip, - take: pagination.take, - }); - } - /** * Get assets by device's Id on the database * @param ownerId diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql index 54992e5f87779..4e5dd2536bcfc 100644 --- a/server/src/infra/sql/asset.repository.sql +++ b/server/src/infra/sql/asset.repository.sql @@ -428,55 +428,6 @@ WHERE AND "isOffline" = $4 ) --- AssetRepository.getAllByFileCreationDate -SELECT - "asset"."id" AS "asset_id", - "asset"."deviceAssetId" AS "asset_deviceAssetId", - "asset"."ownerId" AS "asset_ownerId", - "asset"."libraryId" AS "asset_libraryId", - "asset"."deviceId" AS "asset_deviceId", - "asset"."type" AS "asset_type", - "asset"."originalPath" AS "asset_originalPath", - "asset"."resizePath" AS "asset_resizePath", - "asset"."webpPath" AS "asset_webpPath", - "asset"."thumbhash" AS "asset_thumbhash", - "asset"."encodedVideoPath" AS "asset_encodedVideoPath", - "asset"."createdAt" AS "asset_createdAt", - "asset"."updatedAt" AS "asset_updatedAt", - "asset"."deletedAt" AS "asset_deletedAt", - "asset"."fileCreatedAt" AS "asset_fileCreatedAt", - "asset"."localDateTime" AS "asset_localDateTime", - "asset"."fileModifiedAt" AS "asset_fileModifiedAt", - "asset"."isFavorite" AS "asset_isFavorite", - "asset"."isArchived" AS "asset_isArchived", - "asset"."isExternal" AS "asset_isExternal", - "asset"."isReadOnly" AS "asset_isReadOnly", - "asset"."isOffline" AS "asset_isOffline", - "asset"."checksum" AS "asset_checksum", - "asset"."duration" AS "asset_duration", - "asset"."isVisible" AS "asset_isVisible", - "asset"."livePhotoVideoId" AS "asset_livePhotoVideoId", - "asset"."originalFileName" AS "asset_originalFileName", - "asset"."sidecarPath" AS "asset_sidecarPath", - "asset"."stackId" AS "asset_stackId" -FROM - "assets" "asset" -WHERE - ( - "asset"."fileCreatedAt" <= $1 - AND 1 = 1 - AND "asset"."ownerId" IN ($2) - AND 1 = 1 - AND "asset"."isArchived" = $3 - ) - AND ("asset"."deletedAt" IS NULL) -ORDER BY - "asset"."fileCreatedAt" DESC -LIMIT - 10001 -OFFSET - 20000 - -- AssetRepository.getAllByDeviceId SELECT "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 63f1229a23fec..6143d357c148f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -18,7 +18,6 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getFirstAssetForAlbumId: jest.fn(), getLastUpdatedAssetForAlbumId: jest.fn(), getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }), - getAllByFileCreationDate: jest.fn(), getAllByDeviceId: jest.fn(), updateAll: jest.fn(), getByLibraryId: jest.fn(), From 430561d692c48b8ba5e81e94fdfa595204219f12 Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Fri, 8 Mar 2024 23:44:13 +0000 Subject: [PATCH 03/35] Version v1.98.1 --- cli/package-lock.json | 2 +- e2e/package-lock.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 15 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 9574fa26f6a05..5da869dc3b8c4 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -46,7 +46,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.1", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 338d6d2a14e94..8a92a012d6392 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -79,7 +79,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.1", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index fc881d29ae204..2d1845511c076 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.98.0" +version = "1.98.1" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index a656dbc4cdbff..972986cb72dcb 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 126, - "android.injected.version.name" => "1.98.0", + "android.injected.version.code" => 127, + "android.injected.version.name" => "1.98.1", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 328fe1536ec18..ae5c31e723242 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.98.0" + version_number: "1.98.1" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 8f060a4a665d0..6041bd39d36e8 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.98.0 +- API version: 1.98.1 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index d0ab2a8ac8b00..7904fcd308b4f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.98.0+126 +version: 1.98.1+127 isar_version: &isar_version 3.1.0+1 environment: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 97386426089b3..2f5040f343b43 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6486,7 +6486,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.98.0", + "version": "1.98.1", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index ff333b7f77eee..c95a96efe1f45 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 2bd1cfddd3cd8..4450f1b2d6b98 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.1", "description": "", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 68f04b4db5b67..ee421155bedca 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.98.0 + * 1.98.1 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 54dd09902591e..3279f5272f14f 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.98.0", + "version": "1.98.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.98.0", + "version": "1.98.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@babel/runtime": "^7.22.11", diff --git a/server/package.json b/server/package.json index da9cb0e442344..24f77cb21131a 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.98.0", + "version": "1.98.1", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 5819c0eb7cb38..2119ddfea5dbd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.98.0", + "version": "1.98.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.98.0", + "version": "1.98.1", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,7 +63,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.0", + "version": "1.98.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/web/package.json b/web/package.json index 763f4ebe7d884..396f119a62e32 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.98.0", + "version": "1.98.1", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 4fdb0835c954f6999126e124518c1ee2e7a8870d Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 8 Mar 2024 19:08:33 -0600 Subject: [PATCH 04/35] chore: post release tasks --- mobile/android/fastlane/report.xml | 6 +++--- mobile/ios/Podfile.lock | 2 +- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- mobile/ios/fastlane/report.xml | 12 ++++++------ 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 575edaa4e4902..4a3fba3676d3c 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 6081988b7aaf6..a9ac5b33817e9 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -180,4 +180,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 64c9b5291666c0ca3caabdfe9865c141ac40321d -COCOAPODS: 1.11.3 +COCOAPODS: 1.12.1 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 457160ad355c1..dd714b6d21202 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -379,7 +379,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 141; + CURRENT_PROJECT_VERSION = 143; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -515,7 +515,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 141; + CURRENT_PROJECT_VERSION = 143; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 141; + CURRENT_PROJECT_VERSION = 143; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 6d745e984c30d..75002a3c06be2 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -55,11 +55,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.97.0 + 1.98.1 CFBundleSignature ???? CFBundleVersion - 141 + 143 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index 129dddb341555..ea1fd2e0cb6b6 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + From ba55e867e04c6e4e509bb6f6c00a7603442ffec2 Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Fri, 8 Mar 2024 18:15:38 -0800 Subject: [PATCH 05/35] perf: precompress and cache assets (#7757) * perf: precompress and cache assets * fix cache header * use startswith --------- Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com> --- server/package-lock.json | 60 +++++++++++++++++++++++++++++++++++++++ server/package.json | 1 + server/src/immich/main.ts | 16 ++++++++++- web/svelte.config.js | 7 +---- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 3279f5272f14f..0b8e755e22f68 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -51,6 +51,7 @@ "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", "sharp": "^0.33.0", + "sirv": "^2.0.4", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", "ua-parser-js": "^1.0.35" @@ -2730,6 +2731,11 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@polka/url": { + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -8831,6 +8837,14 @@ "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true }, + "node_modules/mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", + "engines": { + "node": ">=10" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -10821,6 +10835,19 @@ "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" }, + "node_modules/sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -11748,6 +11775,14 @@ "node": ">=0.6" } }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14468,6 +14503,11 @@ "integrity": "sha512-Zwq5OCzuwJC2jwqmpEQt7Ds1DTi6BWSwoGkbb1n9pO3hzb35BoJELx7c0T23iDkBGkh2e7tvOtjF3tr3OaQHDQ==", "dev": true }, + "@polka/url": { + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==" + }, "@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -19156,6 +19196,11 @@ "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", "dev": true }, + "mrmime": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==" + }, "ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -20648,6 +20693,16 @@ } } }, + "sirv": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "requires": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + } + }, "sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", @@ -21386,6 +21441,11 @@ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==" }, + "totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" + }, "tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", diff --git a/server/package.json b/server/package.json index 24f77cb21131a..98ee13c1b1f59 100644 --- a/server/package.json +++ b/server/package.json @@ -76,6 +76,7 @@ "rxjs": "^7.8.1", "sanitize-filename": "^1.6.3", "sharp": "^0.33.0", + "sirv": "^2.0.4", "thumbhash": "^0.1.1", "typeorm": "^0.3.17", "ua-parser-js": "^1.0.35" diff --git a/server/src/immich/main.ts b/server/src/immich/main.ts index 05f3f038117f3..0039019b6266b 100644 --- a/server/src/immich/main.ts +++ b/server/src/immich/main.ts @@ -5,6 +5,7 @@ import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { json } from 'body-parser'; import cookieParser from 'cookie-parser'; +import sirv from 'sirv'; import { AppModule } from './app.module'; import { AppService } from './app.service'; import { useSwagger } from './app.utils'; @@ -28,7 +29,20 @@ export async function bootstrap() { const excludePaths = ['/.well-known/immich', '/custom.css']; app.setGlobalPrefix('api', { exclude: excludePaths }); - app.useStaticAssets('www'); + // copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46 + // provides serving of precompressed assets and caching of immutable assets + app.use( + sirv('www', { + etag: true, + gzip: true, + brotli: true, + setHeaders: (res, pathname) => { + if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) { + res.setHeader('cache-control', 'public,max-age=31536000,immutable'); + } + }, + }), + ); app.use(app.get(AppService).ssr(excludePaths)); const server = await app.listen(port); diff --git a/web/svelte.config.js b/web/svelte.config.js index 3cb982c6b8953..76a9c2e55b27a 100644 --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -6,13 +6,8 @@ const config = { preprocess: vitePreprocess(), kit: { adapter: adapter({ - // default options are shown. On some platforms - // these options are set automatically — see below - pages: 'build', - assets: 'build', fallback: 'index.html', - precompress: false, - strict: true, + precompress: true, }), alias: { $lib: 'src/lib', From 3f1d37e556d56a371df57fb79e8378384aeda770 Mon Sep 17 00:00:00 2001 From: Fynn Petersen-Frey <10599762+fyfrey@users.noreply.github.com> Date: Sat, 9 Mar 2024 03:17:26 +0100 Subject: [PATCH 06/35] feat(server): hardware HDR tonemapping for RKMPP (#7655) * feat(server): hardware HDR tonemapping for RKMPP * review feedback --- docker/hwaccel.transcoding.yml | 8 +++-- docs/docs/features/hardware-transcoding.md | 13 +++++++ server/src/domain/media/media.service.spec.ts | 36 +++++++++++++++++++ server/src/domain/media/media.service.ts | 14 +++++++- server/src/domain/media/media.util.ts | 35 +++++++++++++----- 5 files changed, 94 insertions(+), 12 deletions(-) diff --git a/docker/hwaccel.transcoding.yml b/docker/hwaccel.transcoding.yml index 2f6ae3ebdec9f..ef9c0a5bb1301 100644 --- a/docker/hwaccel.transcoding.yml +++ b/docker/hwaccel.transcoding.yml @@ -1,9 +1,9 @@ version: "3.8" -# Configurations for hardware-accelerated transcoding +# Configurations for hardware-accelerated transcoding # If using Unraid or another platform that doesn't allow multiple Compose files, -# you can inline the config for a backend by copying its contents +# you can inline the config for a backend by copying its contents # into the immich-microservices service in the docker-compose.yml file. # See https://immich.app/docs/features/hardware-transcoding for more info on using hardware transcoding. @@ -38,6 +38,10 @@ services: - /dev/dri:/dev/dri - /dev/dma_heap:/dev/dma_heap - /dev/mpp_service:/dev/mpp_service + #- /dev/mali0:/dev/mali0 # only required to enable OpenCL-accelerated HDR -> SDR tonemapping + volumes: + #- /etc/OpenCL:/etc/OpenCL:ro # only required to enable OpenCL-accelerated HDR -> SDR tonemapping + #- /usr/lib/aarch64-linux-gnu/libmali.so.1:/usr/lib/aarch64-linux-gnu/libmali.so.1:ro # only required to enable OpenCL-accelerated HDR -> SDR tonemapping vaapi: devices: diff --git a/docs/docs/features/hardware-transcoding.md b/docs/docs/features/hardware-transcoding.md index db3d1ba7d6da9..420cd2a43bae3 100644 --- a/docs/docs/features/hardware-transcoding.md +++ b/docs/docs/features/hardware-transcoding.md @@ -42,6 +42,18 @@ You do not need to redo any transcoding jobs after enabling hardware acceleratio - If you have an 11th gen CPU or older, then you may need to follow [these][jellyfin-lp] instructions as Low-Power mode is required - Additionally, if the server specifically has an 11th gen CPU and is running kernel 5.15 (shipped with Ubuntu 22.04 LTS), then you will need to upgrade this kernel (from [Jellyfin docs][jellyfin-kernel-bug]) +#### RKMPP + +For RKMPP to work: + +- You must have a supported Rockchip ARM SoC. +- Only RK3588 supports hardware tonemapping, other SoCs use slower software tonemapping while still using hardware encoding. +- Tonemapping requires `/usr/lib/aarch64-linux-gnu/libmali.so.1` to be present on your host system. Install [`libmali-valhall-g610-g6p0-gbm`][libmali-rockchip] and modify the [`hwaccel.transcoding.yml`][hw-file] file: + - under `rkmpp` uncomment the 3 lines required for OpenCL tonemapping by removing the `#` symbol at the beginning of each line + - `- /dev/mali0:/dev/mali0` + - `- /etc/OpenCL:/etc/OpenCL:ro` + - `- /usr/lib/aarch64-linux-gnu/libmali.so.1:/usr/lib/aarch64-linux-gnu/libmali.so.1:ro` + ## Setup #### Basic Setup @@ -106,3 +118,4 @@ Once this is done, you can continue to step 3 of "Basic Setup". [nvcr]: https://github.com/NVIDIA/nvidia-container-runtime/ [jellyfin-lp]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#configure-and-verify-lp-mode-on-linux [jellyfin-kernel-bug]: https://jellyfin.org/docs/general/administration/hardware-acceleration/intel/#known-issues-and-limitations +[libmali-rockchip]: https://github.com/tsukumijima/libmali-rockchip/releases diff --git a/server/src/domain/media/media.service.spec.ts b/server/src/domain/media/media.service.spec.ts index 244978d099d25..8a6eae4cc13b3 100644 --- a/server/src/domain/media/media.service.spec.ts +++ b/server/src/domain/media/media.service.spec.ts @@ -23,6 +23,7 @@ import { personStub, probeStub, } from '@test'; +import { Stats } from 'node:fs'; import { JobName } from '../job'; import { IAssetRepository, @@ -1853,6 +1854,41 @@ describe(MediaService.name, () => { }, ); }); + + it('should set OpenCL tonemapping options for rkmpp when OpenCL is available', async () => { + storageMock.readdir.mockResolvedValue(['renderD128']); + storageMock.stat.mockResolvedValue({ ...new Stats(), isFile: () => true, isCharacterDevice: () => true }); + mediaMock.probe.mockResolvedValue(probeStub.videoStreamHDR); + configMock.load.mockResolvedValue([ + { key: SystemConfigKey.FFMPEG_ACCEL, value: TranscodeHWAccel.RKMPP }, + { key: SystemConfigKey.FFMPEG_CRF, value: 30 }, + { key: SystemConfigKey.FFMPEG_MAX_BITRATE, value: '0' }, + ]); + assetMock.getByIds.mockResolvedValue([assetStub.video]); + await sut.handleVideoConversion({ id: assetStub.video.id }); + expect(mediaMock.transcode).toHaveBeenCalledWith( + '/original/path.ext', + 'upload/encoded-video/user-id/as/se/asset-id.mp4', + { + inputOptions: ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga'], + outputOptions: [ + `-c:v h264_rkmpp`, + '-c:a copy', + '-movflags faststart', + '-fps_mode passthrough', + '-map 0:0', + '-map 0:1', + '-g 256', + '-v verbose', + '-vf scale_rkrga=-2:720:format=p010:afbc=1,hwmap=derive_device=opencl:mode=read,tonemap_opencl=format=nv12:r=pc:p=bt709:t=bt709:m=bt709:tonemap=hable:desat=0,hwmap=derive_device=rkmpp:mode=write:reverse=1,format=drm_prime', + '-level 51', + '-rc_mode CQP', + '-qp_init 30', + ], + twoPass: false, + }, + ); + }); }); it('should tonemap when policy is required and video is hdr', async () => { diff --git a/server/src/domain/media/media.service.ts b/server/src/domain/media/media.service.ts index 6a5c8ff9d3bb7..5c8e777ad5e4f 100644 --- a/server/src/domain/media/media.service.ts +++ b/server/src/domain/media/media.service.ts @@ -47,6 +47,7 @@ export class MediaService { private logger = new ImmichLogger(MediaService.name); private configCore: SystemConfigCore; private storageCore: StorageCore; + private hasOpenCL?: boolean = undefined; constructor( @Inject(IAssetRepository) private assetRepository: IAssetRepository, @@ -456,8 +457,19 @@ export class MediaService { break; } case TranscodeHWAccel.RKMPP: { + if (this.hasOpenCL === undefined) { + try { + const maliIcdStat = await this.storageRepository.stat('/etc/OpenCL/vendors/mali.icd'); + const maliDeviceStat = await this.storageRepository.stat('/dev/mali0'); + this.hasOpenCL = maliIcdStat.isFile() && maliDeviceStat.isCharacterDevice(); + } catch { + this.logger.warn('OpenCL not available for transcoding, using CPU instead.'); + this.hasOpenCL = false; + } + } + devices = await this.storageRepository.readdir('/dev/dri'); - handler = new RKMPPConfig(config, devices); + handler = new RKMPPConfig(config, devices, this.hasOpenCL); break; } default: { diff --git a/server/src/domain/media/media.util.ts b/server/src/domain/media/media.util.ts index d5f08ab0de475..3acabb4356df6 100644 --- a/server/src/domain/media/media.util.ts +++ b/server/src/domain/media/media.util.ts @@ -608,6 +608,17 @@ export class VAAPIConfig extends BaseHWConfig { } export class RKMPPConfig extends BaseHWConfig { + private hasOpenCL: boolean; + + constructor( + protected config: SystemConfigFFmpegDto, + devices: string[] = [], + hasOpenCL: boolean = false, + ) { + super(config, devices); + this.hasOpenCL = hasOpenCL; + } + eligibleForTwoPass(): boolean { return false; } @@ -616,19 +627,25 @@ export class RKMPPConfig extends BaseHWConfig { if (this.devices.length === 0) { throw new Error('No RKMPP device found'); } - if (this.shouldToneMap(videoStream)) { - // disable hardware decoding - return []; - } - return ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; + return this.shouldToneMap(videoStream) && !this.hasOpenCL + ? [] // disable hardware decoding & filters + : ['-hwaccel rkmpp', '-hwaccel_output_format drm_prime', '-afbc rga']; } getFilterOptions(videoStream: VideoStreamInfo) { if (this.shouldToneMap(videoStream)) { - // use software filter options - return super.getFilterOptions(videoStream); - } - if (this.shouldScale(videoStream)) { + if (!this.hasOpenCL) { + return super.getFilterOptions(videoStream); + } + const colors = this.getColors(); + return [ + `scale_rkrga=${this.getScaling(videoStream)}:format=p010:afbc=1`, + 'hwmap=derive_device=opencl:mode=read', + `tonemap_opencl=format=nv12:r=pc:p=${colors.primaries}:t=${colors.transfer}:m=${colors.matrix}:tonemap=${this.config.tonemap}:desat=0`, + 'hwmap=derive_device=rkmpp:mode=write:reverse=1', + 'format=drm_prime', + ]; + } else if (this.shouldScale(videoStream)) { return [`scale_rkrga=${this.getScaling(videoStream)}:format=nv12:afbc=1`]; } return []; From 8eb9dad9896cabb7b4d29c11def6d534f8ad9dd2 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Sat, 9 Mar 2024 05:16:36 +0100 Subject: [PATCH 07/35] fix: update e2e (#7710) * fix: update e2e * update package.json * fix: version --- e2e/package-lock.json | 4 ++-- e2e/package.json | 2 +- misc/release/pump-version.sh | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/e2e/package-lock.json b/e2e/package-lock.json index 8a92a012d6392..b6a8b9179a88f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.0.0", + "version": "1.98.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.0.0", + "version": "1.98.1", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", diff --git a/e2e/package.json b/e2e/package.json index 14685df51bd5b..1e66fd579cbab 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.0.0", + "version": "1.98.1", "description": "", "main": "index.js", "type": "module", diff --git a/misc/release/pump-version.sh b/misc/release/pump-version.sh index 45d32ebfcbf49..14f0b3a8174c4 100755 --- a/misc/release/pump-version.sh +++ b/misc/release/pump-version.sh @@ -65,6 +65,7 @@ if [ "$CURRENT_SERVER" != "$NEXT_SERVER" ]; then make open-api npm --prefix open-api/typescript-sdk version "$SERVER_PUMP" npm --prefix web version "$SERVER_PUMP" + npm --prefix e2e version "$SERVER_PUMP" npm --prefix web i --package-lock-only npm --prefix cli i --package-lock-only npm --prefix e2e i --package-lock-only From 30b0b2474e9d1d73f11d35ccf6f34414e2153c1b Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Sat, 9 Mar 2024 12:51:58 -0500 Subject: [PATCH 08/35] refactor: asset e2e (#7769) --- .github/workflows/test.yml | 17 - Makefile | 3 - e2e/src/api/specs/asset.e2e-spec.ts | 563 +++++++++++- e2e/src/api/specs/search.e2e-spec.ts | 351 ++++++-- e2e/src/fixtures.ts | 13 + e2e/src/utils.ts | 14 +- server/e2e/api/jest-e2e.json | 23 - server/e2e/api/setup.ts | 29 - server/e2e/api/specs/asset.e2e-spec.ts | 1151 ------------------------ server/e2e/api/utils.ts | 118 --- server/e2e/client/asset-api.ts | 67 -- server/e2e/client/auth-api.ts | 10 +- server/e2e/client/index.ts | 6 - server/e2e/client/library-api.ts | 40 +- server/e2e/client/shared-link-api.ts | 13 - server/e2e/client/trash-api.ts | 13 - server/e2e/client/user-api.ts | 37 - server/package.json | 1 - 18 files changed, 852 insertions(+), 1617 deletions(-) delete mode 100644 server/e2e/api/jest-e2e.json delete mode 100644 server/e2e/api/setup.ts delete mode 100644 server/e2e/api/specs/asset.e2e-spec.ts delete mode 100644 server/e2e/api/utils.ts delete mode 100644 server/e2e/client/shared-link-api.ts delete mode 100644 server/e2e/client/trash-api.ts delete mode 100644 server/e2e/client/user-api.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6b17774ed67e..9adcfe7373333 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,23 +10,6 @@ concurrency: cancel-in-progress: true jobs: - server-e2e-api: - name: Server (e2e-api) - runs-on: ubuntu-latest - defaults: - run: - working-directory: ./server - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Run npm install - run: npm ci - - - name: Run e2e tests - run: npm run e2e:api - server-e2e-jobs: name: Server (e2e-jobs) runs-on: ubuntu-latest diff --git a/Makefile b/Makefile index b455e2656b64e..55875e732b277 100644 --- a/Makefile +++ b/Makefile @@ -19,9 +19,6 @@ pull-stage: server-e2e-jobs: docker compose -f ./server/e2e/docker-compose.server-e2e.yml up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server --remove-orphans --build -server-e2e-api: - npm run e2e:api --prefix server - .PHONY: e2e e2e: docker compose -f ./e2e/docker-compose.yml up --build -V --remove-orphans diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index f1bb355315a6e..65ad094be6216 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -2,20 +2,45 @@ import { AssetFileUploadResponseDto, AssetResponseDto, AssetTypeEnum, + LibraryResponseDto, LoginResponseDto, SharedLinkType, + TimeBucketSize, + getAllLibraries, + getAssetInfo, + updateAssets, } from '@immich/sdk'; import { exiftool } from 'exiftool-vendored'; import { DateTime } from 'luxon'; +import { randomBytes } from 'node:crypto'; import { readFile, writeFile } from 'node:fs/promises'; import { basename, join } from 'node:path'; import { Socket } from 'socket.io-client'; import { createUserDto, uuidDto } from 'src/fixtures'; +import { makeRandomImage } from 'src/generators'; import { errorDto } from 'src/responses'; -import { app, tempDir, testAssetDir, utils } from 'src/utils'; +import { app, asBearerAuth, tempDir, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +const makeUploadDto = (options?: { omit: string }): Record => { + const dto: Record = { + deviceAssetId: 'example-image', + deviceId: 'TEST', + fileCreatedAt: new Date().toISOString(), + fileModifiedAt: new Date().toISOString(), + isFavorite: 'testing', + duration: '0:00:00.000000', + }; + + const omit = options?.omit; + if (omit) { + delete dto[omit]; + } + + return dto; +}; + const TEN_TIMES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; const locationAssetFilepath = `${testAssetDir}/metadata/gps-position/thompson-springs.jpg`; @@ -35,34 +60,43 @@ const yesterday = today.minus({ days: 1 }); describe('/asset', () => { let admin: LoginResponseDto; + let websocket: Socket; + let user1: LoginResponseDto; let user2: LoginResponseDto; - let userStats: LoginResponseDto; + let timeBucketUser: LoginResponseDto; + let quotaUser: LoginResponseDto; + let statsUser: LoginResponseDto; + let stackUser: LoginResponseDto; + let user1Assets: AssetFileUploadResponseDto[]; let user2Assets: AssetFileUploadResponseDto[]; - let assetLocation: AssetFileUploadResponseDto; - let ws: Socket; + let stackAssets: AssetFileUploadResponseDto[]; + let locationAsset: AssetFileUploadResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - [ws, user1, user2, userStats] = await Promise.all([ + [websocket, user1, user2, statsUser, quotaUser, timeBucketUser, stackUser] = await Promise.all([ utils.connectWebsocket(admin.accessToken), - utils.userSetup(admin.accessToken, createUserDto.user1), - utils.userSetup(admin.accessToken, createUserDto.user2), - utils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.create('1')), + utils.userSetup(admin.accessToken, createUserDto.create('2')), + utils.userSetup(admin.accessToken, createUserDto.create('stats')), + utils.userSetup(admin.accessToken, createUserDto.userQuota), + utils.userSetup(admin.accessToken, createUserDto.create('time-bucket')), + utils.userSetup(admin.accessToken, createUserDto.create('stack')), ]); // asset location - assetLocation = await utils.createAsset(admin.accessToken, { + locationAsset = await utils.createAsset(admin.accessToken, { assetData: { filename: 'thompson-springs.jpg', bytes: await readFile(locationAssetFilepath), }, }); - await utils.waitForWebsocketEvent({ event: 'upload', assetId: assetLocation.id }); + await utils.waitForWebsocketEvent({ event: 'upload', assetId: locationAsset.id }); user1Assets = await Promise.all([ utils.createAsset(user1.accessToken), @@ -80,22 +114,43 @@ describe('/asset', () => { user2Assets = await Promise.all([utils.createAsset(user2.accessToken)]); + await Promise.all([ + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-01-01').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-10').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + utils.createAsset(timeBucketUser.accessToken, { fileCreatedAt: new Date('1970-02-11').toISOString() }), + ]); + for (const asset of [...user1Assets, ...user2Assets]) { expect(asset.duplicate).toBe(false); } await Promise.all([ // stats - utils.createAsset(userStats.accessToken), - utils.createAsset(userStats.accessToken, { isFavorite: true }), - utils.createAsset(userStats.accessToken, { isArchived: true }), - utils.createAsset(userStats.accessToken, { + utils.createAsset(statsUser.accessToken), + utils.createAsset(statsUser.accessToken, { isFavorite: true }), + utils.createAsset(statsUser.accessToken, { isArchived: true }), + utils.createAsset(statsUser.accessToken, { isArchived: true, isFavorite: true, assetData: { filename: 'example.mp4' }, }), ]); + // stacks + stackAssets = await Promise.all([ + utils.createAsset(stackUser.accessToken), + utils.createAsset(stackUser.accessToken), + utils.createAsset(stackUser.accessToken), + utils.createAsset(stackUser.accessToken), + utils.createAsset(stackUser.accessToken), + ]); + + await updateAssets( + { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, + { headers: asBearerAuth(stackUser.accessToken) }, + ); + const person1 = await utils.createPerson(user1.accessToken, { name: 'Test Person', }); @@ -106,7 +161,7 @@ describe('/asset', () => { }, 30_000); afterAll(() => { - utils.disconnectWebsocket(ws); + utils.disconnectWebsocket(websocket); }); describe('GET /asset/:id', () => { @@ -193,7 +248,7 @@ describe('/asset', () => { it('should return stats of all assets', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`); + .set('Authorization', `Bearer ${statsUser.accessToken}`); expect(body).toEqual({ images: 3, videos: 1, total: 4 }); expect(status).toBe(200); @@ -202,7 +257,7 @@ describe('/asset', () => { it('should return stats of all favored assets', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`) + .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isFavorite: true }); expect(status).toBe(200); @@ -212,7 +267,7 @@ describe('/asset', () => { it('should return stats of all archived assets', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`) + .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isArchived: true }); expect(status).toBe(200); @@ -222,7 +277,7 @@ describe('/asset', () => { it('should return stats of all favored and archived assets', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`) + .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isFavorite: true, isArchived: true }); expect(status).toBe(200); @@ -232,7 +287,7 @@ describe('/asset', () => { it('should return stats of all assets neither favored nor archived', async () => { const { status, body } = await request(app) .get('/asset/statistics') - .set('Authorization', `Bearer ${userStats.accessToken}`) + .set('Authorization', `Bearer ${statsUser.accessToken}`) .query({ isFavorite: false, isArchived: false }); expect(status).toBe(200); @@ -488,6 +543,35 @@ describe('/asset', () => { }); describe('POST /asset/upload', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).post(`/asset/upload`); + expect(body).toEqual(errorDto.unauthorized); + expect(status).toBe(401); + }); + + const invalid = [ + { should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } }, + { should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } }, + { should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } }, + { should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } }, + { should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } }, + { should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } }, + { should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } }, + { should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } }, + ]; + + for (const { should, dto } of invalid) { + it(`should ${should}`, async () => { + const { status, body } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${user1.accessToken}`) + .attach('assetData', makeRandomImage(), 'example.png') + .field(dto); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + } + const tests = [ { input: 'formats/jpg/el_torcal_rocks.jpg', @@ -601,7 +685,7 @@ describe('/asset', () => { ]; for (const { input, expected } of tests) { - it(`should generate a thumbnail for ${input}`, async () => { + it(`should upload and generate a thumbnail for ${input}`, async () => { const filepath = join(testAssetDir, input); const { id, duplicate } = await utils.createAsset(admin.accessToken, { assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, @@ -631,6 +715,57 @@ describe('/asset', () => { expect(duplicate).toBe(true); }); + it("should not upload to another user's library", async () => { + const libraries = await getAllLibraries({}, { headers: asBearerAuth(admin.accessToken) }); + const library = libraries.find((library) => library.ownerId === user1.userId) as LibraryResponseDto; + + const { body, status } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${admin.accessToken}`) + .field('libraryId', library.id) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'e2e') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .field('duration', '0:00:00.000000') + .attach('assetData', makeRandomImage(), 'example.png'); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Not found or no asset.upload access')); + }); + + it('should update the used quota', async () => { + const { body, status } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${quotaUser.accessToken}`) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'e2e') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .attach('assetData', makeRandomImage(), 'example.jpg'); + + expect(body).toEqual({ id: expect.any(String), duplicate: false }); + expect(status).toBe(201); + + const { body: user } = await request(app).get('/user/me').set('Authorization', `Bearer ${quotaUser.accessToken}`); + + expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 70 })); + }); + + it('should not upload an asset if it would exceed the quota', async () => { + const { body, status } = await request(app) + .post('/asset/upload') + .set('Authorization', `Bearer ${quotaUser.accessToken}`) + .field('deviceAssetId', 'example-image') + .field('deviceId', 'e2e') + .field('fileCreatedAt', new Date().toISOString()) + .field('fileModifiedAt', new Date().toISOString()) + .attach('assetData', randomBytes(2014), 'example.jpg'); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest('Quota has been exceeded!')); + }); + // These hashes were created by copying the image files to a Samsung phone, // exporting the video from Samsung's stock Gallery app, and hashing them locally. // This ensures that immich+exiftool are extracting the videos the same way Samsung does. @@ -675,7 +810,7 @@ describe('/asset', () => { describe('GET /asset/thumbnail/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`); + const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -683,12 +818,12 @@ describe('/asset', () => { it('should not include gps data for webp thumbnails', async () => { const { status, body, type } = await request(app) - .get(`/asset/thumbnail/${assetLocation.id}?format=WEBP`) + .get(`/asset/thumbnail/${locationAsset.id}?format=WEBP`) .set('Authorization', `Bearer ${admin.accessToken}`); await utils.waitForWebsocketEvent({ event: 'upload', - assetId: assetLocation.id, + assetId: locationAsset.id, }); expect(status).toBe(200); @@ -702,7 +837,7 @@ describe('/asset', () => { it('should not include gps data for jpeg thumbnails', async () => { const { status, body, type } = await request(app) - .get(`/asset/thumbnail/${assetLocation.id}?format=JPEG`) + .get(`/asset/thumbnail/${locationAsset.id}?format=JPEG`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); @@ -717,7 +852,7 @@ describe('/asset', () => { describe('GET /asset/file/:id', () => { it('should require authentication', async () => { - const { status, body } = await request(app).get(`/asset/thumbnail/${assetLocation.id}`); + const { status, body } = await request(app).get(`/asset/thumbnail/${locationAsset.id}`); expect(status).toBe(401); expect(body).toEqual(errorDto.unauthorized); @@ -725,14 +860,14 @@ describe('/asset', () => { it('should download the original', async () => { const { status, body, type } = await request(app) - .get(`/asset/file/${assetLocation.id}`) + .get(`/asset/file/${locationAsset.id}`) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); expect(body).toBeDefined(); expect(type).toBe('image/jpeg'); - const asset = await utils.getAssetInfo(admin.accessToken, assetLocation.id); + const asset = await utils.getAssetInfo(admin.accessToken, locationAsset.id); const original = await readFile(locationAssetFilepath); const originalChecksum = utils.sha1(original); @@ -742,4 +877,376 @@ describe('/asset', () => { expect(downloadChecksum).toBe(asset.checksum); }); }); + + describe('GET /asset/map-marker', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/asset/map-marker'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + // TODO archive one of these assets + it('should get map markers for all non-archived assets', async () => { + const { status, body } = await request(app) + .get('/asset/map-marker') + .query({ isArchived: false }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(2); + expect(body).toEqual([ + { + city: 'Palisade', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(39.115), + lon: expect.closeTo(-108.400_968), + state: 'Mesa County, Colorado', + }, + { + city: 'Ralston', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(41.2203), + lon: expect.closeTo(-96.071_625), + state: 'Douglas County, Nebraska', + }, + ]); + }); + + // TODO archive one of these assets + it('should get all map markers', async () => { + const { status, body } = await request(app) + .get('/asset/map-marker') + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([ + { + city: 'Palisade', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(39.115), + lon: expect.closeTo(-108.400_968), + state: 'Mesa County, Colorado', + }, + { + city: 'Ralston', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(41.2203), + lon: expect.closeTo(-96.071_625), + state: 'Douglas County, Nebraska', + }, + ]); + }); + }); + + describe('GET /asset/time-buckets', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/asset/time-buckets').query({ size: TimeBucketSize.Month }); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should get time buckets by month', async () => { + const { status, body } = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month }); + + expect(status).toBe(200); + expect(body).toEqual( + expect.arrayContaining([ + { count: 3, timeBucket: '1970-02-01T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]), + ); + }); + + it('should not allow access for unrelated shared links', async () => { + const sharedLink = await utils.createSharedLink(user1.accessToken, { + type: SharedLinkType.Individual, + assetIds: user1Assets.map(({ id }) => id), + }); + + const { status, body } = await request(app) + .get('/asset/time-buckets') + .query({ key: sharedLink.key, size: TimeBucketSize.Month }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should get time buckets by day', async () => { + const { status, body } = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Day }); + + expect(status).toBe(200); + expect(body).toEqual([ + { count: 2, timeBucket: '1970-02-11T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-02-10T00:00:00.000Z' }, + { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, + ]); + }); + }); + + describe('GET /asset/time-bucket', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/asset/time-bucket').query({ + size: TimeBucketSize.Month, + timeBucket: '1900-01-01T00:00:00.000Z', + }); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should handle 5 digit years', async () => { + const { status, body } = await request(app) + .get('/asset/time-bucket') + .query({ size: TimeBucketSize.Month, timeBucket: '+012345-01-01T00:00:00.000Z' }) + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + + // TODO enable date string validation while still accepting 5 digit years + // it('should fail if time bucket is invalid', async () => { + // const { status, body } = await request(app) + // .get('/asset/time-bucket') + // .set('Authorization', `Bearer ${user1.accessToken}`) + // .query({ size: TimeBucketSize.Month, timeBucket: 'foo' }); + + // expect(status).toBe(400); + // expect(body).toEqual(errorDto.badRequest); + // }); + + it('should return time bucket', async () => { + const { status, body } = await request(app) + .get('/asset/time-bucket') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, timeBucket: '1970-02-10T00:00:00.000Z' }); + + expect(status).toBe(200); + expect(body).toEqual([]); + }); + + it('should return error if time bucket is requested with partners asset and archived', async () => { + const req1 = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${user1.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isArchived: undefined }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and favorite', async () => { + const req1 = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: true }); + + expect(req1.status).toBe(400); + expect(req1.body).toEqual(errorDto.badRequest()); + + const req2 = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${timeBucketUser.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isFavorite: false }); + + expect(req2.status).toBe(400); + expect(req2.body).toEqual(errorDto.badRequest()); + }); + + it('should return error if time bucket is requested with partners asset and trash', async () => { + const req = await request(app) + .get('/asset/time-buckets') + .set('Authorization', `Bearer ${user1.accessToken}`) + .query({ size: TimeBucketSize.Month, withPartners: true, isTrashed: true }); + + expect(req.status).toBe(400); + expect(req.body).toEqual(errorDto.badRequest()); + }); + }); + + describe('GET /asset', () => { + it('should return stack data', async () => { + const { status, body } = await request(app).get('/asset').set('Authorization', `Bearer ${stackUser.accessToken}`); + + const stack = body.find((asset: AssetResponseDto) => asset.id === stackAssets[0].id); + + expect(status).toBe(200); + expect(stack).toEqual( + expect.objectContaining({ + stackCount: 3, + stack: + // Response includes children at the root level + expect.arrayContaining([ + expect.objectContaining({ id: stackAssets[1].id }), + expect.objectContaining({ id: stackAssets[2].id }), + ]), + }), + ); + }); + }); + + describe('PUT /asset', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put('/asset'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid parent id', async () => { + const { status, body } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: uuidDto.invalid, ids: [stackAssets[0].id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['stackParentId must be a UUID'])); + }); + + it('should require access to the parent', async () => { + const { status, body } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ stackParentId: stackAssets[3].id, ids: [user1Assets[0].id] }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should add stack children', async () => { + const { status } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ stackParentId: stackAssets[0].id, ids: [stackAssets[3].id] }); + + expect(status).toBe(204); + + const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: stackAssets[3].id })])); + }); + + it('should remove stack children', async () => { + const { status } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ removeParent: true, ids: [stackAssets[1].id] }); + + expect(status).toBe(204); + + const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: stackAssets[2].id }), + expect.objectContaining({ id: stackAssets[3].id }), + ]), + ); + }); + + it('should remove all stack children', async () => { + const { status } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ removeParent: true, ids: [stackAssets[2].id, stackAssets[3].id] }); + + expect(status).toBe(204); + + const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + expect(asset.stack).toBeUndefined(); + }); + + it('should merge stack children', async () => { + // create stack after previous test removed stack children + await updateAssets( + { assetBulkUpdateDto: { stackParentId: stackAssets[0].id, ids: [stackAssets[1].id, stackAssets[2].id] } }, + { headers: asBearerAuth(stackUser.accessToken) }, + ); + + const { status } = await request(app) + .put('/asset') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ stackParentId: stackAssets[3].id, ids: [stackAssets[0].id] }); + + expect(status).toBe(204); + + const asset = await getAssetInfo({ id: stackAssets[3].id }, { headers: asBearerAuth(stackUser.accessToken) }); + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: stackAssets[0].id }), + expect.objectContaining({ id: stackAssets[1].id }), + expect.objectContaining({ id: stackAssets[2].id }), + ]), + ); + }); + }); + + describe('PUT /asset/stack/parent', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).put('/asset/stack/parent'); + + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should require a valid id', async () => { + const { status, body } = await request(app) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: uuidDto.invalid, newParentId: uuidDto.invalid }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest()); + }); + + it('should require access', async () => { + const { status, body } = await request(app) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${user1.accessToken}`) + .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); + + expect(status).toBe(400); + expect(body).toEqual(errorDto.noPermission); + }); + + it('should make old parent child of new parent', async () => { + const { status } = await request(app) + .put('/asset/stack/parent') + .set('Authorization', `Bearer ${stackUser.accessToken}`) + .send({ oldParentId: stackAssets[3].id, newParentId: stackAssets[0].id }); + + expect(status).toBe(200); + + const asset = await getAssetInfo({ id: stackAssets[0].id }, { headers: asBearerAuth(stackUser.accessToken) }); + + // new parent + expect(asset.stack).not.toBeUndefined(); + expect(asset.stack).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: stackAssets[1].id }), + expect.objectContaining({ id: stackAssets[2].id }), + expect.objectContaining({ id: stackAssets[3].id }), + ]), + ); + }); + }); }); diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index de7d9ef4c5a22..19b1b6807308d 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -1,52 +1,76 @@ -import { AssetFileUploadResponseDto, LoginResponseDto } from '@immich/sdk'; +import { AssetFileUploadResponseDto, LoginResponseDto, deleteAssets } from '@immich/sdk'; +import { DateTime } from 'luxon'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { Socket } from 'socket.io-client'; import { errorDto } from 'src/responses'; -import { app, testAssetDir, utils } from 'src/utils'; +import { app, asBearerAuth, testAssetDir, utils } from 'src/utils'; import request from 'supertest'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -const albums = { total: 0, count: 0, items: [], facets: [] }; +const today = DateTime.now(); describe('/search', () => { let admin: LoginResponseDto; + let websocket: Socket; + let assetFalcon: AssetFileUploadResponseDto; let assetDenali: AssetFileUploadResponseDto; - let websocket: Socket; + let assetCyclamen: AssetFileUploadResponseDto; + let assetNotocactus: AssetFileUploadResponseDto; + let assetSilver: AssetFileUploadResponseDto; + // let assetDensity: AssetFileUploadResponseDto; + // let assetPhiladelphia: AssetFileUploadResponseDto; + // let assetOrychophragmus: AssetFileUploadResponseDto; + // let assetRidge: AssetFileUploadResponseDto; + // let assetPolemonium: AssetFileUploadResponseDto; + // let assetWood: AssetFileUploadResponseDto; + let assetHeic: AssetFileUploadResponseDto; + let assetRocks: AssetFileUploadResponseDto; + let assetOneJpg6: AssetFileUploadResponseDto; + let assetOneHeic6: AssetFileUploadResponseDto; + let assetOneJpg5: AssetFileUploadResponseDto; + let assetGlarus: AssetFileUploadResponseDto; + let assetSprings: AssetFileUploadResponseDto; + let assetLast: AssetFileUploadResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); websocket = await utils.connectWebsocket(admin.accessToken); - const files: string[] = [ - '/albums/nature/prairie_falcon.jpg', - '/formats/webp/denali.webp', - '/formats/raw/Nikon/D700/philadelphia.nef', - '/albums/nature/orychophragmus_violaceus.jpg', - '/albums/nature/notocactus_minimus.jpg', - '/albums/nature/silver_fir.jpg', - '/albums/nature/tanners_ridge.jpg', - '/albums/nature/cyclamen_persicum.jpg', - '/albums/nature/polemonium_reptans.jpg', - '/albums/nature/wood_anemones.jpg', - '/formats/heic/IMG_2682.heic', - '/formats/jpg/el_torcal_rocks.jpg', - '/formats/png/density_plot.png', - '/formats/motionphoto/Samsung One UI 6.jpg', - '/formats/motionphoto/Samsung One UI 6.heic', - '/formats/motionphoto/Samsung One UI 5.jpg', - '/formats/raw/Nikon/D80/glarus.nef', - '/metadata/gps-position/thompson-springs.jpg', + const files = [ + { filename: '/albums/nature/prairie_falcon.jpg' }, + { filename: '/formats/webp/denali.webp' }, + { filename: '/albums/nature/cyclamen_persicum.jpg', dto: { isFavorite: true } }, + { filename: '/albums/nature/notocactus_minimus.jpg' }, + { filename: '/albums/nature/silver_fir.jpg' }, + { filename: '/formats/heic/IMG_2682.heic' }, + { filename: '/formats/jpg/el_torcal_rocks.jpg' }, + { filename: '/formats/motionphoto/Samsung One UI 6.jpg' }, + { filename: '/formats/motionphoto/Samsung One UI 6.heic' }, + { filename: '/formats/motionphoto/Samsung One UI 5.jpg' }, + { filename: '/formats/raw/Nikon/D80/glarus.nef', dto: { isReadOnly: true } }, + { filename: '/metadata/gps-position/thompson-springs.jpg', dto: { isArchived: true } }, + + // used for search suggestions + { filename: '/formats/png/density_plot.png' }, + { filename: '/formats/raw/Nikon/D700/philadelphia.nef' }, + { filename: '/albums/nature/orychophragmus_violaceus.jpg' }, + { filename: '/albums/nature/tanners_ridge.jpg' }, + { filename: '/albums/nature/polemonium_reptans.jpg' }, + + // last asset + { filename: '/albums/nature/wood_anemones.jpg' }, ]; const assets: AssetFileUploadResponseDto[] = []; - for (const filename of files) { + for (const { filename, dto } of files) { const bytes = await readFile(join(testAssetDir, filename)); assets.push( await utils.createAsset(admin.accessToken, { deviceAssetId: `test-${filename}`, assetData: { bytes, filename }, + ...dto, }), ); } @@ -55,7 +79,30 @@ describe('/search', () => { await utils.waitForWebsocketEvent({ event: 'upload', assetId: asset.id }); } - [assetFalcon, assetDenali] = assets; + [ + assetFalcon, + assetDenali, + assetCyclamen, + assetNotocactus, + assetSilver, + assetHeic, + assetRocks, + assetOneJpg6, + assetOneHeic6, + assetOneJpg5, + assetGlarus, + assetSprings, + // assetDensity, + // assetPhiladelphia, + // assetOrychophragmus, + // assetRidge, + // assetPolemonium, + // assetWood, + ] = assets; + + assetLast = assets.at(-1) as AssetFileUploadResponseDto; + + await deleteAssets({ assetBulkDeleteDto: { ids: [assetSilver.id] } }, { headers: asBearerAuth(admin.accessToken) }); }); afterAll(async () => { @@ -69,44 +116,226 @@ describe('/search', () => { expect(body).toEqual(errorDto.unauthorized); }); - it('should search by camera make', async () => { - const { status, body } = await request(app) - .post('/search/metadata') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ make: 'Canon' }); - expect(status).toBe(200); - expect(body).toEqual({ - albums, - assets: { - count: 2, - items: expect.arrayContaining([ - expect.objectContaining({ id: assetDenali.id }), - expect.objectContaining({ id: assetFalcon.id }), - ]), - facets: [], - nextPage: null, - total: 2, - }, - }); - }); + const badTests = [ + { + should: 'should reject page as a string', + dto: { page: 'abc' }, + expected: ['page must not be less than 1', 'page must be an integer number'], + }, + { + should: 'should reject page as a decimal', + dto: { page: 1.5 }, + expected: ['page must be an integer number'], + }, + { + should: 'should reject page as a negative number', + dto: { page: -10 }, + expected: ['page must not be less than 1'], + }, + { + should: 'should reject page as 0', + dto: { page: 0 }, + expected: ['page must not be less than 1'], + }, + { + should: 'should reject size as a string', + dto: { size: 'abc' }, + expected: [ + 'size must not be greater than 1000', + 'size must not be less than 1', + 'size must be an integer number', + ], + }, + { + should: 'should reject an invalid size', + dto: { size: -1.5 }, + expected: ['size must not be less than 1', 'size must be an integer number'], + }, + ...[ + 'isArchived', + 'isFavorite', + 'isReadOnly', + 'isExternal', + 'isEncoded', + 'isMotion', + 'isOffline', + 'isVisible', + ].map((value) => ({ + should: `should reject ${value} not a boolean`, + dto: { [value]: 'immich' }, + expected: [`${value} must be a boolean value`], + })), + ]; - it('should search by camera model', async () => { - const { status, body } = await request(app) - .post('/search/metadata') - .set('Authorization', `Bearer ${admin.accessToken}`) - .send({ model: 'Canon EOS 7D' }); - expect(status).toBe(200); - expect(body).toEqual({ - albums, - assets: { - count: 1, - items: [expect.objectContaining({ id: assetDenali.id })], - facets: [], - nextPage: null, - total: 1, - }, + for (const { should, dto, expected } of badTests) { + it(should, async () => { + const { status, body } = await request(app) + .post('/search/metadata') + .set('Authorization', `Bearer ${admin.accessToken}`) + .send(dto); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(expected)); }); - }); + } + + const searchTests = [ + { + should: 'should get my assets', + deferred: () => ({ dto: { size: 1 }, assets: [assetLast] }), + }, + { + should: 'should sort my assets in reverse', + deferred: () => ({ dto: { order: 'asc', size: 2 }, assets: [assetCyclamen, assetNotocactus] }), + }, + { + should: 'should support pagination', + deferred: () => ({ dto: { order: 'asc', size: 1, page: 2 }, assets: [assetNotocactus] }), + }, + { + should: 'should search by checksum (base64)', + deferred: () => ({ dto: { checksum: '9IXBDMjj9OrQb+1YMHprZJgZ/UQ=' }, assets: [assetCyclamen] }), + }, + { + should: 'should search by checksum (hex)', + deferred: () => ({ dto: { checksum: 'f485c10cc8e3f4ead06fed58307a6b649819fd44' }, assets: [assetCyclamen] }), + }, + { should: 'should search by id', deferred: () => ({ dto: { id: assetCyclamen.id }, assets: [assetCyclamen] }) }, + { + should: 'should search by isFavorite (true)', + deferred: () => ({ dto: { isFavorite: true }, assets: [assetCyclamen] }), + }, + { + should: 'should search by isFavorite (false)', + deferred: () => ({ dto: { size: 1, isFavorite: false }, assets: [assetLast] }), + }, + { + should: 'should search by isArchived (true)', + deferred: () => ({ dto: { isArchived: true }, assets: [assetSprings] }), + }, + { + should: 'should search by isArchived (false)', + deferred: () => ({ dto: { size: 1, isArchived: false }, assets: [assetLast] }), + }, + { + should: 'should search by isReadOnly (true)', + deferred: () => ({ dto: { isReadOnly: true }, assets: [assetGlarus] }), + }, + { + should: 'should search by isReadOnly (false)', + deferred: () => ({ dto: { size: 1, isReadOnly: false }, assets: [assetLast] }), + }, + { + should: 'should search by type (image)', + deferred: () => ({ dto: { size: 1, type: 'IMAGE' }, assets: [assetLast] }), + }, + { + should: 'should search by type (video)', + deferred: () => ({ + dto: { type: 'VIDEO' }, + assets: [ + // the three live motion photos + { id: expect.any(String) }, + { id: expect.any(String) }, + { id: expect.any(String) }, + ], + }), + }, + { + should: 'should search by trashedBefore', + deferred: () => ({ dto: { trashedBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }), + }, + { + should: 'should search by trashedBefore (no results)', + deferred: () => ({ dto: { trashedBefore: today.minus({ days: 1 }).toJSDate() }, assets: [] }), + }, + { + should: 'should search by trashedAfter', + deferred: () => ({ dto: { trashedAfter: today.minus({ hour: 1 }).toJSDate() }, assets: [assetSilver] }), + }, + { + should: 'should search by trashedAfter (no results)', + deferred: () => ({ dto: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }), + }, + { + should: 'should search by takenBefore', + deferred: () => ({ dto: { size: 1, takenBefore: today.plus({ hour: 1 }).toJSDate() }, assets: [assetLast] }), + }, + { + should: 'should search by takenBefore (no results)', + deferred: () => ({ dto: { takenBefore: DateTime.fromObject({ year: 1234 }).toJSDate() }, assets: [] }), + }, + { + should: 'should search by takenAfter', + deferred: () => ({ + dto: { size: 1, takenAfter: DateTime.fromObject({ year: 1234 }).toJSDate() }, + assets: [assetLast], + }), + }, + { + should: 'should search by takenAfter (no results)', + deferred: () => ({ dto: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, assets: [] }), + }, + // { + // should: 'should search by originalPath', + // deferred: () => ({ + // dto: { originalPath: asset1.originalPath }, + // assets: [asset1], + // }), + // }, + { + should: 'should search by originalFilename', + deferred: () => ({ + dto: { originalFileName: 'rocks' }, + assets: [assetRocks], + }), + }, + { + should: 'should search by originalFilename with spaces', + deferred: () => ({ + dto: { originalFileName: 'Samsung One', type: 'IMAGE' }, + assets: [assetOneJpg5, assetOneJpg6, assetOneHeic6], + }), + }, + { + should: 'should search by city', + deferred: () => ({ dto: { city: 'Ralston' }, assets: [assetHeic] }), + }, + { + should: 'should search by state', + deferred: () => ({ dto: { state: 'Douglas County, Nebraska' }, assets: [assetHeic] }), + }, + { + should: 'should search by country', + deferred: () => ({ dto: { country: 'United States of America' }, assets: [assetHeic] }), + }, + { + should: 'should search by make', + deferred: () => ({ dto: { make: 'Canon' }, assets: [assetFalcon, assetDenali] }), + }, + { + should: 'should search by model', + deferred: () => ({ dto: { model: 'Canon EOS 7D' }, assets: [assetDenali] }), + }, + ]; + + for (const { should, deferred } of searchTests) { + it(should, async () => { + const { assets, dto } = deferred(); + const { status, body } = await request(app) + .post('/search/metadata') + .send(dto) + .set('Authorization', `Bearer ${admin.accessToken}`); + console.dir({ status, body }, { depth: 10 }); + expect(status).toBe(200); + expect(body.assets).toBeDefined(); + expect(Array.isArray(body.assets.items)).toBe(true); + console.log({ assets: body.assets.items }); + for (const [i, asset] of assets.entries()) { + expect(body.assets.items[i]).toEqual(expect.objectContaining({ id: asset.id })); + } + expect(body.assets.items).toHaveLength(assets.length); + }); + } }); describe('POST /search/smart', () => { diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 6a1a1b39681d1..56070e6e343bb 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -21,6 +21,13 @@ export const signupDto = { }; export const createUserDto = { + create(key: string) { + return { + email: `${key}@immich.cloud`, + name: `User ${key}`, + password: `password-${key}`, + }; + }, user1: { email: 'user1@immich.cloud', name: 'User 1', @@ -36,6 +43,12 @@ export const createUserDto = { name: 'User 3', password: 'password123', }, + userQuota: { + email: 'user-quota@immich.cloud', + name: 'User Quota', + password: 'password-quota', + quotaSizeInBytes: 512, + }, }; export const userDto = { diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index d62497b8e4e70..af86a608db22b 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -104,6 +104,8 @@ export const utils = { } tables = tables || [ + // TODO e2e test for deleting a stack, since it is quite complex + 'asset_stack', 'libraries', 'shared_links', 'person', @@ -117,9 +119,17 @@ export const utils = { 'system_metadata', ]; - for (const table of tables) { - await client.query(`DELETE FROM ${table} CASCADE;`); + const sql: string[] = []; + + if (tables.includes('asset_stack')) { + sql.push('UPDATE "assets" SET "stackId" = NULL;'); } + + for (const table of tables) { + sql.push(`DELETE FROM ${table} CASCADE;`); + } + + await client.query(sql.join('\n')); } catch (error) { console.error('Failed to reset database', error); throw error; diff --git a/server/e2e/api/jest-e2e.json b/server/e2e/api/jest-e2e.json deleted file mode 100644 index 9fd67774f3e40..0000000000000 --- a/server/e2e/api/jest-e2e.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "moduleFileExtensions": ["js", "json", "ts"], - "modulePaths": [""], - "rootDir": "../..", - "globalSetup": "/e2e/api/setup.ts", - "testEnvironment": "node", - "testMatch": ["**/e2e/api/specs/*.e2e-spec.[tj]s"], - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "/src/**/*.(t|j)s", - "!/src/**/*.spec.(t|s)s", - "!/src/infra/migrations/**" - ], - "coverageDirectory": "./coverage", - "moduleNameMapper": { - "^@test(|/.*)$": "/test/$1", - "^@app/immich(|/.*)$": "/src/immich/$1", - "^@app/infra(|/.*)$": "/src/infra/$1", - "^@app/domain(|/.*)$": "/src/domain/$1" - } -} diff --git a/server/e2e/api/setup.ts b/server/e2e/api/setup.ts deleted file mode 100644 index 88f2f598bdbb0..0000000000000 --- a/server/e2e/api/setup.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { PostgreSqlContainer } from '@testcontainers/postgresql'; -import path from 'node:path'; - -export default async () => { - let IMMICH_TEST_ASSET_PATH: string = ''; - - if (process.env.IMMICH_TEST_ASSET_PATH === undefined) { - IMMICH_TEST_ASSET_PATH = path.normalize(`${__dirname}/../../test/assets/`); - process.env.IMMICH_TEST_ASSET_PATH = IMMICH_TEST_ASSET_PATH; - } else { - IMMICH_TEST_ASSET_PATH = process.env.IMMICH_TEST_ASSET_PATH; - } - - const pg = await new PostgreSqlContainer('tensorchord/pgvecto-rs:pg14-v0.2.0') - .withDatabase('immich') - .withUsername('postgres') - .withPassword('postgres') - .withReuse() - .withCommand(['-c', 'fsync=off', '-c', 'shared_preload_libraries=vectors.so']) - .start(); - - process.env.DB_URL = pg.getConnectionUri(); - process.env.NODE_ENV = 'development'; - process.env.TZ = 'Z'; - - if (process.env.LOG_LEVEL === undefined) { - process.env.LOG_LEVEL = 'fatal'; - } -}; diff --git a/server/e2e/api/specs/asset.e2e-spec.ts b/server/e2e/api/specs/asset.e2e-spec.ts deleted file mode 100644 index 6badd4c67460e..0000000000000 --- a/server/e2e/api/specs/asset.e2e-spec.ts +++ /dev/null @@ -1,1151 +0,0 @@ -import { - AssetResponseDto, - IAssetRepository, - IPersonRepository, - LibraryResponseDto, - LoginResponseDto, - TimeBucketSize, - WithoutProperty, - mapAsset, - usePagination, -} from '@app/domain'; -import { AssetController } from '@app/immich'; -import { AssetEntity, AssetStackEntity, AssetType, SharedLinkType } from '@app/infra/entities'; -import { AssetRepository } from '@app/infra/repositories'; -import { INestApplication } from '@nestjs/common'; -import { errorStub, userDto, uuidStub } from '@test/fixtures'; -import { assetApi } from 'e2e/client/asset-api'; -import { randomBytes } from 'node:crypto'; -import request from 'supertest'; -import { api } from '../../client'; -import { generateAsset, testApp, today, yesterday } from '../utils'; - -const makeUploadDto = (options?: { omit: string }): Record => { - const dto: Record = { - deviceAssetId: 'example-image', - deviceId: 'TEST', - fileCreatedAt: new Date().toISOString(), - fileModifiedAt: new Date().toISOString(), - isFavorite: 'testing', - duration: '0:00:00.000000', - }; - - const omit = options?.omit; - if (omit) { - delete dto[omit]; - } - - return dto; -}; - -describe(`${AssetController.name} (e2e)`, () => { - let app: INestApplication; - let server: any; - let assetRepository: IAssetRepository; - let admin: LoginResponseDto; - let user1: LoginResponseDto; - let user2: LoginResponseDto; - let userWithQuota: LoginResponseDto; - let libraries: LibraryResponseDto[]; - let asset1: AssetResponseDto; - let asset2: AssetResponseDto; - let asset3: AssetResponseDto; - let asset4: AssetResponseDto; - let asset5: AssetResponseDto; - let asset6: AssetResponseDto; - - const createAsset = async ( - loginResponse: LoginResponseDto, - fileCreatedAt: Date, - other: Partial = {}, - ) => { - const asset = await assetRepository.create( - generateAsset(loginResponse.userId, libraries, { fileCreatedAt, ...other }), - ); - - return mapAsset(asset); - }; - - beforeAll(async () => { - app = await testApp.create(); - server = app.getHttpServer(); - assetRepository = app.get(IAssetRepository); - - await testApp.reset(); - - await api.authApi.adminSignUp(server); - admin = await api.authApi.adminLogin(server); - - await Promise.all([ - api.userApi.create(server, admin.accessToken, userDto.user1), - api.userApi.create(server, admin.accessToken, userDto.user2), - api.userApi.create(server, admin.accessToken, userDto.userWithQuota), - ]); - - [user1, user2, userWithQuota] = await Promise.all([ - api.authApi.login(server, userDto.user1), - api.authApi.login(server, userDto.user2), - api.authApi.login(server, userDto.userWithQuota), - ]); - - libraries = await api.libraryApi.getAll(server, admin.accessToken); - }); - - beforeEach(async () => { - await testApp.reset({ entities: [AssetEntity, AssetStackEntity] }); - - [asset1, asset2, asset3, asset4, asset5, asset6] = await Promise.all([ - createAsset(user1, new Date('1970-01-01')), - createAsset(user1, new Date('1970-02-10')), - createAsset(user1, new Date('1970-02-11'), { - isFavorite: true, - isExternal: true, - isReadOnly: true, - type: AssetType.VIDEO, - fileCreatedAt: yesterday.toJSDate(), - fileModifiedAt: yesterday.toJSDate(), - createdAt: yesterday.toJSDate(), - updatedAt: yesterday.toJSDate(), - localDateTime: yesterday.toJSDate(), - encodedVideoPath: '/path/to/encoded-video.mp4', - webpPath: '/path/to/thumb.webp', - resizePath: '/path/to/thumb.jpg', - }), - createAsset(user2, new Date('1970-01-01')), - createAsset(user1, new Date('1970-01-01'), { - deletedAt: yesterday.toJSDate(), - }), - createAsset(user1, new Date('1970-02-11'), { - isArchived: true, - }), - ]); - - await assetRepository.upsertExif({ - assetId: asset3.id, - latitude: 90, - longitude: 90, - city: 'Immich', - state: 'Nebraska', - country: 'United States', - make: 'Cannon', - model: 'EOS Rebel T7', - lensModel: 'Fancy lens', - }); - }); - - afterAll(async () => { - await testApp.teardown(); - }); - - describe('GET /assets', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get('/assets'); - expect(body).toEqual(errorStub.unauthorized); - expect(status).toBe(401); - }); - - const badTests = [ - // - { - should: 'should reject page as a string', - query: { page: 'abc' }, - expected: ['page must not be less than 1', 'page must be an integer number'], - }, - { - should: 'should reject page as a decimal', - query: { page: 1.5 }, - expected: ['page must be an integer number'], - }, - { - should: 'should reject page as a negative number', - query: { page: -10 }, - expected: ['page must not be less than 1'], - }, - { - should: 'should reject page as 0', - query: { page: 0 }, - expected: ['page must not be less than 1'], - }, - { - should: 'should reject size as a string', - query: { size: 'abc' }, - expected: [ - 'size must not be greater than 1000', - 'size must not be less than 1', - 'size must be an integer number', - ], - }, - { - should: 'should reject an invalid size', - query: { size: -1.5 }, - expected: ['size must not be less than 1', 'size must be an integer number'], - }, - ...[ - 'isArchived', - 'isFavorite', - 'isReadOnly', - 'isExternal', - 'isEncoded', - 'isMotion', - 'isOffline', - 'isVisible', - ].map((value) => ({ - should: `should reject ${value} not a boolean`, - query: { [value]: 'immich' }, - expected: [`${value} must be a boolean value`], - })), - ]; - - for (const { should, query, expected } of badTests) { - it(should, async () => { - const { status, body } = await request(server) - .get('/assets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query(query); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(expected)); - }); - } - - const searchTests = [ - { - should: 'should only return my own assets', - deferred: () => ({ - query: {}, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should sort my assets in reverse', - deferred: () => ({ - query: { order: 'asc' }, - assets: [asset1, asset2, asset3], - }), - }, - { - should: 'should support custom page sizes', - deferred: () => ({ - query: { size: 1 }, - assets: [asset3], - }), - }, - { - should: 'should support pagination', - deferred: () => ({ - query: { size: 1, page: 2 }, - assets: [asset2], - }), - }, - { - should: 'should search by checksum (base64)', - deferred: () => ({ - query: { checksum: asset1.checksum }, - assets: [asset1], - }), - }, - { - should: 'should search by checksum (hex)', - deferred: () => ({ - query: { checksum: Buffer.from(asset1.checksum, 'base64').toString('hex') }, - assets: [asset1], - }), - }, - { - should: 'should search by id', - deferred: () => ({ - query: { id: asset1.id }, - assets: [asset1], - }), - }, - { - should: 'should search by isFavorite (true)', - deferred: () => ({ - query: { isFavorite: true }, - assets: [asset3], - }), - }, - { - should: 'should search by isFavorite (false)', - deferred: () => ({ - query: { isFavorite: false }, - assets: [asset2, asset1], - }), - }, - { - should: 'should search by isArchived (true)', - deferred: () => ({ - query: { isArchived: true }, - assets: [asset6], - }), - }, - { - should: 'should search by isArchived (false)', - deferred: () => ({ - query: { isArchived: false }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by isReadOnly (true)', - deferred: () => ({ - query: { isReadOnly: true }, - assets: [asset3], - }), - }, - { - should: 'should search by isReadOnly (false)', - deferred: () => ({ - query: { isReadOnly: false }, - assets: [asset2, asset1], - }), - }, - { - should: 'should search by type (image)', - deferred: () => ({ - query: { type: 'IMAGE' }, - assets: [asset2, asset1], - }), - }, - { - should: 'should search by type (video)', - deferred: () => ({ - query: { type: 'VIDEO' }, - assets: [asset3], - }), - }, - { - should: 'should search by withArchived (true)', - deferred: () => ({ - query: { withArchived: true }, - assets: [asset3, asset6, asset2, asset1], - }), - }, - { - should: 'should search by withArchived (false)', - deferred: () => ({ - query: { withArchived: false }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by createdBefore', - deferred: () => ({ - query: { createdBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset3], - }), - }, - { - should: 'should search by createdBefore (no results)', - deferred: () => ({ - query: { createdBefore: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by createdAfter', - deferred: () => ({ - query: { createdAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by createdAfter (no results)', - deferred: () => ({ - query: { createdAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by updatedBefore', - deferred: () => ({ - query: { updatedBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset3], - }), - }, - { - should: 'should search by updatedBefore (no results)', - deferred: () => ({ - query: { updatedBefore: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by updatedAfter', - deferred: () => ({ - query: { updatedAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by updatedAfter (no results)', - deferred: () => ({ - query: { updatedAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by trashedBefore', - deferred: () => ({ - query: { trashedBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset5], - }), - }, - { - should: 'should search by trashedBefore (no results)', - deferred: () => ({ - query: { trashedBefore: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by trashedAfter', - deferred: () => ({ - query: { trashedAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset5], - }), - }, - { - should: 'should search by trashedAfter (no results)', - deferred: () => ({ - query: { trashedAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by takenBefore', - deferred: () => ({ - query: { takenBefore: yesterday.plus({ hour: 1 }).toJSDate() }, - assets: [asset3, asset2, asset1], - }), - }, - { - should: 'should search by takenBefore (no results)', - deferred: () => ({ - query: { takenBefore: yesterday.minus({ years: 100 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by takenAfter', - deferred: () => ({ - query: { takenAfter: yesterday.minus({ hour: 1 }).toJSDate() }, - assets: [asset3], - }), - }, - { - should: 'should search by takenAfter (no results)', - deferred: () => ({ - query: { takenAfter: today.plus({ hour: 1 }).toJSDate() }, - assets: [], - }), - }, - { - should: 'should search by originalPath', - deferred: () => ({ - query: { originalPath: asset1.originalPath }, - assets: [asset1], - }), - }, - { - should: 'should search by originalFilename', - deferred: () => ({ - query: { originalFileName: asset1.originalFileName }, - assets: [asset1], - }), - }, - { - should: 'should search by encodedVideoPath', - deferred: () => ({ - query: { encodedVideoPath: '/path/to/encoded-video.mp4' }, - assets: [asset3], - }), - }, - { - should: 'should search by resizePath', - deferred: () => ({ - query: { resizePath: '/path/to/thumb.jpg' }, - assets: [asset3], - }), - }, - { - should: 'should search by webpPath', - deferred: () => ({ - query: { webpPath: '/path/to/thumb.webp' }, - assets: [asset3], - }), - }, - { - should: 'should search by city', - deferred: () => ({ - query: { city: 'Immich' }, - assets: [asset3], - }), - }, - { - should: 'should search by state', - deferred: () => ({ - query: { state: 'Nebraska' }, - assets: [asset3], - }), - }, - { - should: 'should search by country', - deferred: () => ({ - query: { country: 'United States' }, - assets: [asset3], - }), - }, - { - should: 'should search by make', - deferred: () => ({ - query: { make: 'Cannon' }, - assets: [asset3], - }), - }, - { - should: 'should search by country', - deferred: () => ({ - query: { model: 'EOS Rebel T7' }, - assets: [asset3], - }), - }, - { - should: 'should search by lensModel', - deferred: () => ({ - query: { lensModel: 'Fancy lens' }, - assets: [asset3], - }), - }, - ]; - - for (const { should, deferred } of searchTests) { - it(should, async () => { - const { assets, query } = deferred(); - const { status, body } = await request(server) - .get('/assets') - .query(query) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body.length).toBe(assets.length); - for (const [i, asset] of assets.entries()) { - expect(body[i]).toEqual(expect.objectContaining({ id: asset.id })); - } - }); - } - - it('should return stack data', async () => { - const parentId = asset1.id; - const childIds = [asset2.id, asset3.id]; - await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: parentId, ids: childIds }); - - const body = await assetApi.getAllAssets(server, user1.accessToken); - // Response includes parent with stack children count - const parentDto = body.find((a) => a.id == parentId); - expect(parentDto?.stackCount).toEqual(3); - - // Response includes children at the root level - expect.arrayContaining([expect.objectContaining({ id: asset1.id }), expect.objectContaining({ id: asset2.id })]); - }); - }); - - describe('POST /asset/upload', () => { - it('should require authentication', async () => { - const { status, body } = await request(server) - .post(`/asset/upload`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', false) - .field('duration', '0:00:00.000000') - .attach('assetData', randomBytes(32), 'example.jpg'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - const invalid = [ - { should: 'require `deviceAssetId`', dto: { ...makeUploadDto({ omit: 'deviceAssetId' }) } }, - { should: 'require `deviceId`', dto: { ...makeUploadDto({ omit: 'deviceId' }) } }, - { should: 'require `fileCreatedAt`', dto: { ...makeUploadDto({ omit: 'fileCreatedAt' }) } }, - { should: 'require `fileModifiedAt`', dto: { ...makeUploadDto({ omit: 'fileModifiedAt' }) } }, - { should: 'require `duration`', dto: { ...makeUploadDto({ omit: 'duration' }) } }, - { should: 'throw if `isFavorite` is not a boolean', dto: { ...makeUploadDto(), isFavorite: 'not-a-boolean' } }, - { should: 'throw if `isVisible` is not a boolean', dto: { ...makeUploadDto(), isVisible: 'not-a-boolean' } }, - { should: 'throw if `isArchived` is not a boolean', dto: { ...makeUploadDto(), isArchived: 'not-a-boolean' } }, - ]; - - for (const { should, dto } of invalid) { - it(`should ${should}`, async () => { - const { status, body } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .attach('assetData', randomBytes(32), 'example.jpg') - .field(dto); - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest()); - }); - } - - it('should upload a new asset', async () => { - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', randomBytes(32), 'example.jpg'); - expect(status).toBe(201); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - - const asset = await api.assetApi.get(server, user1.accessToken, body.id); - expect(asset).toMatchObject({ id: body.id, isFavorite: true }); - }); - - it('should have correct original file name and extension (simple)', async () => { - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', randomBytes(32), 'example.jpg'); - expect(status).toBe(201); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - - const asset = await api.assetApi.get(server, user1.accessToken, body.id); - expect(asset).toMatchObject({ id: body.id, originalFileName: 'example.jpg' }); - }); - - it('should have correct original file name and extension (complex)', async () => { - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', randomBytes(32), 'example.complex.ext.jpg'); - expect(status).toBe(201); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - - const asset = await api.assetApi.get(server, user1.accessToken, body.id); - expect(asset).toMatchObject({ id: body.id, originalFileName: 'example.complex.ext.jpg' }); - }); - - it('should not upload the same asset twice', async () => { - const content = randomBytes(32); - await api.assetApi.upload(server, user1.accessToken, 'example-image', { content }); - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', false) - .field('duration', '0:00:00.000000') - .attach('assetData', content, 'example.jpg'); - - expect(status).toBe(200); - expect(body.duplicate).toBe(true); - }); - - it("should not upload to another user's library", async () => { - const content = randomBytes(32); - const [library] = await api.libraryApi.getAll(server, admin.accessToken); - await api.assetApi.upload(server, user1.accessToken, 'example-image', { content }); - - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${user1.accessToken}`) - .field('libraryId', library.id) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', false) - .field('duration', '0:00:00.000000') - .attach('assetData', content, 'example.jpg'); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('Not found or no asset.upload access')); - }); - - it('should update the used quota', async () => { - const content = randomBytes(32); - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${userWithQuota.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', content, 'example.jpg'); - - expect(status).toBe(201); - expect(body).toEqual({ id: expect.any(String), duplicate: false }); - - const { body: user } = await request(server) - .get('/user/me') - .set('Authorization', `Bearer ${userWithQuota.accessToken}`); - - expect(user).toEqual(expect.objectContaining({ quotaUsageInBytes: 32 })); - }); - - it('should not upload an asset if it would exceed the quota', async () => { - const content = randomBytes(420); - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${userWithQuota.accessToken}`) - .field('deviceAssetId', 'example-image') - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', 'true') - .field('duration', '0:00:00.000000') - .attach('assetData', content, 'example.jpg'); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest('Quota has been exceeded!')); - }); - }); - - describe('GET /asset/time-buckets', () => { - it('should require authentication', async () => { - const { status, body } = await request(server).get('/asset/time-buckets').query({ size: TimeBucketSize.MONTH }); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should get time buckets by month', async () => { - const { status, body } = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - { count: 1, timeBucket: '2023-11-01T00:00:00.000Z' }, - { count: 1, timeBucket: '1970-01-01T00:00:00.000Z' }, - { count: 2, timeBucket: '1970-02-01T00:00:00.000Z' }, - ]), - ); - }); - - it('should not allow access for unrelated shared links', async () => { - const sharedLink = await api.sharedLinkApi.create(server, user1.accessToken, { - type: SharedLinkType.INDIVIDUAL, - assetIds: [asset1.id, asset2.id], - }); - - const { status, body } = await request(server) - .get('/asset/time-buckets') - .query({ key: sharedLink.key, size: TimeBucketSize.MONTH }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should get time buckets by day', async () => { - const { status, body } = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.DAY }); - - expect(status).toBe(200); - expect(body).toEqual( - expect.arrayContaining([ - { count: 1, timeBucket: asset1.fileCreatedAt.toISOString() }, - { count: 1, timeBucket: asset2.fileCreatedAt.toISOString() }, - { count: 1, timeBucket: asset3.fileCreatedAt.toISOString() }, - ]), - ); - }); - }); - - describe('GET /asset/time-bucket', () => { - let timeBucket: string; - beforeEach(async () => { - const { body, status } = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH }); - - expect(status).toBe(200); - timeBucket = body[1].timeBucket; - }); - - it('should require authentication', async () => { - const { status, body } = await request(server) - .get('/asset/time-bucket') - .query({ size: TimeBucketSize.MONTH, timeBucket }); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should handle 5 digit years', async () => { - const { status, body } = await request(server) - .get('/asset/time-bucket') - .query({ size: TimeBucketSize.MONTH, timeBucket: '+012345-01-01T00:00:00.000Z' }) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([]); - }); - - // it('should fail if time bucket is invalid', async () => { - // const { status, body } = await request(server) - // .get('/asset/time-bucket') - // .set('Authorization', `Bearer ${user1.accessToken}`) - // .query({ size: TimeBucketSize.MONTH, timeBucket: 'foo' }); - - // expect(status).toBe(400); - // expect(body).toEqual(errorStub.badRequest); - // }); - - it('should return time bucket', async () => { - const { status, body } = await request(server) - .get('/asset/time-bucket') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, timeBucket }); - - expect(status).toBe(200); - expect(body).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset2.id })])); - }); - - it('should return error if time bucket is requested with partners asset and archived', async () => { - const req1 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isArchived: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorStub.badRequest()); - - const req2 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isArchived: undefined }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorStub.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and favorite', async () => { - const req1 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isFavorite: true }); - - expect(req1.status).toBe(400); - expect(req1.body).toEqual(errorStub.badRequest()); - - const req2 = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isFavorite: false }); - - expect(req2.status).toBe(400); - expect(req2.body).toEqual(errorStub.badRequest()); - }); - - it('should return error if time bucket is requested with partners asset and trash', async () => { - const req = await request(server) - .get('/asset/time-buckets') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ size: TimeBucketSize.MONTH, withPartners: true, isTrashed: true }); - - expect(req.status).toBe(400); - expect(req.body).toEqual(errorStub.badRequest()); - }); - }); - - describe('GET /asset/map-marker', () => { - beforeEach(async () => { - await Promise.all([ - assetRepository.save({ id: asset1.id, isArchived: true }), - assetRepository.upsertExif({ assetId: asset1.id, latitude: 0, longitude: 0 }), - assetRepository.upsertExif({ assetId: asset2.id, latitude: 0, longitude: 0 }), - ]); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).get('/asset/map-marker'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should get map markers for all non-archived assets', async () => { - const { status, body } = await request(server) - .get('/asset/map-marker') - .query({ isArchived: false }) - .set('Authorization', `Bearer ${user1.accessToken}`); - - expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset2.id }), - expect.objectContaining({ id: asset3.id }), - ]), - ); - }); - - it('should get all map markers', async () => { - const { status, body } = await request(server) - .get('/asset/map-marker') - .set('Authorization', `Bearer ${user1.accessToken}`) - .query({ isArchived: false }); - - expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset2.id }), - expect.objectContaining({ id: asset3.id }), - ]), - ); - }); - }); - - describe('PUT /asset', () => { - beforeEach(async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); - - expect(status).toBe(204); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).put('/asset'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should require a valid parent id', async () => { - const { status, body } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: uuidStub.invalid, ids: [asset1.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest(['stackParentId must be a UUID'])); - }); - - it('should require access to the parent', async () => { - const { status, body } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: asset4.id, ids: [asset1.id] }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should add stack children', async () => { - const [parent, child] = await Promise.all([ - createAsset(user1, new Date('1970-01-01')), - createAsset(user1, new Date('1970-01-01')), - ]); - - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: parent.id, ids: [child.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, parent.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: child.id })])); - }); - - it('should remove stack children', async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ removeParent: true, ids: [asset2.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); - }); - - it('should remove all stack children', async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ removeParent: true, ids: [asset2.id, asset3.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, asset1.id); - expect(asset.stack).toBeUndefined(); - }); - - it('should merge stack children', async () => { - const newParent = await createAsset(user1, new Date('1970-01-01')); - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: newParent.id, ids: [asset1.id] }); - - expect(status).toBe(204); - - const asset = await api.assetApi.get(server, user1.accessToken, newParent.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: asset1.id }), - expect.objectContaining({ id: asset2.id }), - expect.objectContaining({ id: asset3.id }), - ]), - ); - }); - }); - - describe('PUT /asset/stack/parent', () => { - beforeEach(async () => { - const { status } = await request(server) - .put('/asset') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ stackParentId: asset1.id, ids: [asset2.id, asset3.id] }); - - expect(status).toBe(204); - }); - - it('should require authentication', async () => { - const { status, body } = await request(server).put('/asset/stack/parent'); - - expect(status).toBe(401); - expect(body).toEqual(errorStub.unauthorized); - }); - - it('should require a valid id', async () => { - const { status, body } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: uuidStub.invalid, newParentId: uuidStub.invalid }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.badRequest()); - }); - - it('should require access', async () => { - const { status, body } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: asset4.id, newParentId: asset1.id }); - - expect(status).toBe(400); - expect(body).toEqual(errorStub.noPermission); - }); - - it('should make old parent child of new parent', async () => { - const { status } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: asset1.id, newParentId: asset2.id }); - - expect(status).toBe(200); - - const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset1.id })])); - }); - - it('should make all childrens of old parent, a child of new parent', async () => { - const { status } = await request(server) - .put('/asset/stack/parent') - .set('Authorization', `Bearer ${user1.accessToken}`) - .send({ oldParentId: asset1.id, newParentId: asset2.id }); - - expect(status).toBe(200); - - const asset = await api.assetApi.get(server, user1.accessToken, asset2.id); - expect(asset.stack).not.toBeUndefined(); - expect(asset.stack).toEqual(expect.arrayContaining([expect.objectContaining({ id: asset3.id })])); - }); - }); - - const getAssetIdsWithoutFaces = async () => { - const assetPagination = usePagination(10, (pagination) => - assetRepository.getWithout(pagination, WithoutProperty.FACES), - ); - let assets: AssetEntity[] = []; - for await (const assetsPage of assetPagination) { - assets = [...assets, ...assetsPage]; - } - return assets.map((a) => a.id); - }; - - describe(AssetRepository.name, () => { - describe('getWithout', () => { - describe('WithoutProperty.FACES', () => { - beforeEach(async () => { - await assetRepository.save({ id: asset1.id, resizePath: '/path/to/resize' }); - expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); - }); - - describe('with recognized faces', () => { - beforeEach(async () => { - const personRepository = app.get(IPersonRepository); - const person = await personRepository.create({ ownerId: asset1.ownerId, name: 'Test Person' }); - await personRepository.createFaces([ - { - assetId: asset1.id, - personId: person.id, - embedding: Array.from({ length: 512 }, Math.random), - }, - ]); - }); - - it('should not return asset with facesRecognizedAt unset', async () => { - expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); - }); - - it('should not return asset with facesRecognizedAt set', async () => { - await assetRepository.upsertJobStatus({ assetId: asset1.id, facesRecognizedAt: new Date() }); - expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); - }); - }); - - describe('without recognized faces', () => { - it('should return asset with facesRecognizedAt unset', async () => { - expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); - }); - - it('should not return asset with facesRecognizedAt set', async () => { - expect(await getAssetIdsWithoutFaces()).toContain(asset1.id); - await assetRepository.upsertJobStatus({ assetId: asset1.id, facesRecognizedAt: new Date() }); - expect(await getAssetIdsWithoutFaces()).not.toContain(asset1.id); - }); - }); - }); - }); - }); -}); diff --git a/server/e2e/api/utils.ts b/server/e2e/api/utils.ts deleted file mode 100644 index c03c4ada5525b..0000000000000 --- a/server/e2e/api/utils.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { AssetCreate, IJobRepository, IMetadataRepository, LibraryResponseDto } from '@app/domain'; -import { AppModule } from '@app/immich'; -import { InfraModule, InfraTestModule, dataSource } from '@app/infra'; -import { AssetEntity, AssetType, LibraryType } from '@app/infra/entities'; -import { INestApplication } from '@nestjs/common'; -import { Test } from '@nestjs/testing'; -import { DateTime } from 'luxon'; -import { randomBytes } from 'node:crypto'; -import { EntityTarget, ObjectLiteral } from 'typeorm'; -import { AppService } from '../../src/microservices/app.service'; -import { newJobRepositoryMock, newMetadataRepositoryMock } from '../../test'; - -export const today = DateTime.fromObject({ year: 2023, month: 11, day: 3 }); -export const yesterday = today.minus({ days: 1 }); - -export interface ResetOptions { - entities?: EntityTarget[]; -} -export const db = { - reset: async (options?: ResetOptions) => { - if (!dataSource.isInitialized) { - await dataSource.initialize(); - } - await dataSource.transaction(async (em) => { - const entities = options?.entities || []; - const tableNames = - entities.length > 0 - ? entities.map((entity) => em.getRepository(entity).metadata.tableName) - : dataSource.entityMetadatas - .map((entity) => entity.tableName) - .filter((tableName) => !tableName.startsWith('geodata')); - - if (tableNames.includes('asset_stack')) { - await em.query(`DELETE FROM "asset_stack" CASCADE;`); - } - let deleteUsers = false; - for (const tableName of tableNames) { - if (tableName === 'users') { - deleteUsers = true; - continue; - } - await em.query(`DELETE FROM ${tableName} CASCADE;`); - } - if (deleteUsers) { - await em.query(`DELETE FROM "users" CASCADE;`); - } - }); - }, - disconnect: async () => { - if (dataSource.isInitialized) { - await dataSource.destroy(); - } - }, -}; - -let app: INestApplication; - -export const testApp = { - create: async (): Promise => { - const moduleFixture = await Test.createTestingModule({ imports: [AppModule], providers: [AppService] }) - .overrideModule(InfraModule) - .useModule(InfraTestModule) - .overrideProvider(IJobRepository) - .useValue(newJobRepositoryMock()) - .overrideProvider(IMetadataRepository) - .useValue(newMetadataRepositoryMock()) - .compile(); - - app = await moduleFixture.createNestApplication().init(); - await app.get(AppService).init(); - - return app; - }, - reset: async (options?: ResetOptions) => { - await db.reset(options); - }, - teardown: async () => { - if (app) { - await app.get(AppService).teardown(); - await app.close(); - } - await db.disconnect(); - }, -}; - -function randomDate(start: Date, end: Date): Date { - return new Date(start.getTime() + Math.random() * (end.getTime() - start.getTime())); -} - -let assetCount = 0; -export function generateAsset( - userId: string, - libraries: LibraryResponseDto[], - other: Partial = {}, -): AssetCreate { - const id = assetCount++; - const { fileCreatedAt = randomDate(new Date(1970, 1, 1), new Date(2023, 1, 1)) } = other; - - return { - createdAt: today.toJSDate(), - updatedAt: today.toJSDate(), - ownerId: userId, - checksum: randomBytes(20), - originalPath: `/tests/test_${id}`, - deviceAssetId: `test_${id}`, - deviceId: 'e2e-test', - libraryId: ( - libraries.find(({ ownerId, type }) => ownerId === userId && type === LibraryType.UPLOAD) as LibraryResponseDto - ).id, - isVisible: true, - fileCreatedAt, - fileModifiedAt: new Date(), - localDateTime: fileCreatedAt, - type: AssetType.IMAGE, - originalFileName: `test_${id}`, - ...other, - }; -} diff --git a/server/e2e/client/asset-api.ts b/server/e2e/client/asset-api.ts index 8d2a1b79bcc67..63d4395866470 100644 --- a/server/e2e/client/asset-api.ts +++ b/server/e2e/client/asset-api.ts @@ -1,77 +1,10 @@ import { AssetResponseDto } from '@app/domain'; -import { CreateAssetDto } from '@app/immich/api-v1/asset/dto/create-asset.dto'; -import { AssetFileUploadResponseDto } from '@app/immich/api-v1/asset/response-dto/asset-file-upload-response.dto'; -import { randomBytes } from 'node:crypto'; import request from 'supertest'; -type UploadDto = Partial & { content?: Buffer; filename?: string }; - -const asset = { - deviceAssetId: 'test-1', - deviceId: 'test', - fileCreatedAt: new Date(), - fileModifiedAt: new Date(), -}; - export const assetApi = { - create: async ( - server: any, - accessToken: string, - dto?: Omit, - ): Promise => { - dto = dto || asset; - const { status, body } = await request(server) - .post(`/asset/upload`) - .field('deviceAssetId', dto.deviceAssetId) - .field('deviceId', dto.deviceId) - .field('fileCreatedAt', dto.fileCreatedAt.toISOString()) - .field('fileModifiedAt', dto.fileModifiedAt.toISOString()) - .attach('assetData', randomBytes(32), 'example.jpg') - .set('Authorization', `Bearer ${accessToken}`); - - expect([200, 201].includes(status)).toBe(true); - - return body as AssetResponseDto; - }, - get: async (server: any, accessToken: string, id: string): Promise => { - const { body, status } = await request(server).get(`/asset/${id}`).set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body as AssetResponseDto; - }, getAllAssets: async (server: any, accessToken: string) => { const { body, status } = await request(server).get(`/asset/`).set('Authorization', `Bearer ${accessToken}`); expect(status).toBe(200); return body as AssetResponseDto[]; }, - upload: async (server: any, accessToken: string, deviceAssetId: string, dto: UploadDto = {}) => { - const { content, filename, isFavorite = false, isArchived = false } = dto; - const { body, status } = await request(server) - .post('/asset/upload') - .set('Authorization', `Bearer ${accessToken}`) - .field('deviceAssetId', deviceAssetId) - .field('deviceId', 'TEST') - .field('fileCreatedAt', new Date().toISOString()) - .field('fileModifiedAt', new Date().toISOString()) - .field('isFavorite', isFavorite) - .field('isArchived', isArchived) - .field('duration', '0:00:00.000000') - .attach('assetData', content || randomBytes(32), filename || 'example.jpg'); - - expect(status).toBe(201); - return body as AssetFileUploadResponseDto; - }, - getWebpThumbnail: async (server: any, accessToken: string, assetId: string) => { - const { body, status } = await request(server) - .get(`/asset/thumbnail/${assetId}`) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body; - }, - getJpegThumbnail: async (server: any, accessToken: string, assetId: string) => { - const { body, status } = await request(server) - .get(`/asset/thumbnail/${assetId}?format=JPEG`) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body; - }, }; diff --git a/server/e2e/client/auth-api.ts b/server/e2e/client/auth-api.ts index f0206d3376671..e89e6d057609f 100644 --- a/server/e2e/client/auth-api.ts +++ b/server/e2e/client/auth-api.ts @@ -1,4 +1,4 @@ -import { LoginCredentialDto, LoginResponseDto, UserResponseDto } from '@app/domain'; +import { LoginResponseDto, UserResponseDto } from '@app/domain'; import { adminSignupStub, loginResponseStub, loginStub } from '@test'; import request from 'supertest'; @@ -17,14 +17,6 @@ export const authApi = { expect(body).toMatchObject({ accessToken: expect.any(String) }); expect(status).toBe(201); - return body as LoginResponseDto; - }, - login: async (server: any, dto: LoginCredentialDto) => { - const { status, body } = await request(server).post('/auth/login').send(dto); - - expect(status).toEqual(201); - expect(body).toMatchObject({ accessToken: expect.any(String) }); - return body as LoginResponseDto; }, }; diff --git a/server/e2e/client/index.ts b/server/e2e/client/index.ts index b0464a34d8e94..b4aa2a141bfbc 100644 --- a/server/e2e/client/index.ts +++ b/server/e2e/client/index.ts @@ -1,15 +1,9 @@ import { assetApi } from './asset-api'; import { authApi } from './auth-api'; import { libraryApi } from './library-api'; -import { sharedLinkApi } from './shared-link-api'; -import { trashApi } from './trash-api'; -import { userApi } from './user-api'; export const api = { authApi, assetApi, libraryApi, - sharedLinkApi, - trashApi, - userApi, }; diff --git a/server/e2e/client/library-api.ts b/server/e2e/client/library-api.ts index e0b1331267703..070683eb01a07 100644 --- a/server/e2e/client/library-api.ts +++ b/server/e2e/client/library-api.ts @@ -1,12 +1,4 @@ -import { - CreateLibraryDto, - LibraryResponseDto, - LibraryStatsResponseDto, - ScanLibraryDto, - UpdateLibraryDto, - ValidateLibraryDto, - ValidateLibraryResponseDto, -} from '@app/domain'; +import { CreateLibraryDto, LibraryResponseDto, ScanLibraryDto } from '@app/domain'; import request from 'supertest'; export const libraryApi = { @@ -38,34 +30,4 @@ export const libraryApi = { .send(dto); expect(status).toBe(204); }, - removeOfflineFiles: async (server: any, accessToken: string, id: string) => { - const { status } = await request(server) - .post(`/library/${id}/removeOffline`) - .set('Authorization', `Bearer ${accessToken}`) - .send(); - expect(status).toBe(204); - }, - getLibraryStatistics: async (server: any, accessToken: string, id: string): Promise => { - const { body, status } = await request(server) - .get(`/library/${id}/statistics`) - .set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(200); - return body; - }, - update: async (server: any, accessToken: string, id: string, data: UpdateLibraryDto) => { - const { body, status } = await request(server) - .put(`/library/${id}`) - .set('Authorization', `Bearer ${accessToken}`) - .send(data); - expect(status).toBe(200); - return body as LibraryResponseDto; - }, - validate: async (server: any, accessToken: string, id: string, data: ValidateLibraryDto) => { - const { body, status } = await request(server) - .post(`/library/${id}/validate`) - .set('Authorization', `Bearer ${accessToken}`) - .send(data); - expect(status).toBe(200); - return body as ValidateLibraryResponseDto; - }, }; diff --git a/server/e2e/client/shared-link-api.ts b/server/e2e/client/shared-link-api.ts deleted file mode 100644 index c34093b0ac769..0000000000000 --- a/server/e2e/client/shared-link-api.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { SharedLinkCreateDto, SharedLinkResponseDto } from '@app/domain'; -import request from 'supertest'; - -export const sharedLinkApi = { - create: async (server: any, accessToken: string, dto: SharedLinkCreateDto) => { - const { status, body } = await request(server) - .post('/shared-link') - .set('Authorization', `Bearer ${accessToken}`) - .send(dto); - expect(status).toBe(201); - return body as SharedLinkResponseDto; - }, -}; diff --git a/server/e2e/client/trash-api.ts b/server/e2e/client/trash-api.ts deleted file mode 100644 index a381253f50be2..0000000000000 --- a/server/e2e/client/trash-api.ts +++ /dev/null @@ -1,13 +0,0 @@ -import request from 'supertest'; -import type { App } from 'supertest/types'; - -export const trashApi = { - async empty(server: App, accessToken: string) { - const { status } = await request(server).post('/trash/empty').set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(204); - }, - async restore(server: App, accessToken: string) { - const { status } = await request(server).post('/trash/restore').set('Authorization', `Bearer ${accessToken}`); - expect(status).toBe(204); - }, -}; diff --git a/server/e2e/client/user-api.ts b/server/e2e/client/user-api.ts deleted file mode 100644 index c538db3a8fae7..0000000000000 --- a/server/e2e/client/user-api.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { CreateUserDto, UpdateUserDto, UserResponseDto } from '@app/domain'; -import request from 'supertest'; - -export const userApi = { - create: async (server: any, accessToken: string, dto: CreateUserDto) => { - const { status, body } = await request(server) - .post('/user') - .set('Authorization', `Bearer ${accessToken}`) - .send(dto); - - expect(status).toBe(201); - expect(body).toMatchObject({ - id: expect.any(String), - createdAt: expect.any(String), - updatedAt: expect.any(String), - email: dto.email, - }); - - return body as UserResponseDto; - }, - update: async (server: any, accessToken: string, dto: UpdateUserDto) => { - const { status, body } = await request(server).put('/user').set('Authorization', `Bearer ${accessToken}`).send(dto); - - expect(status).toBe(200); - expect(body).toMatchObject({ id: dto.id }); - - return body as UserResponseDto; - }, - delete: async (server: any, accessToken: string, id: string) => { - const { status, body } = await request(server).delete(`/user/${id}`).set('Authorization', `Bearer ${accessToken}`); - - expect(status).toBe(200); - expect(body).toMatchObject({ id, deletedAt: expect.any(String) }); - - return body as UserResponseDto; - }, -}; diff --git a/server/package.json b/server/package.json index 98ee13c1b1f59..1e3d31c7d81e0 100644 --- a/server/package.json +++ b/server/package.json @@ -23,7 +23,6 @@ "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "e2e:jobs": "jest --config e2e/jobs/jest-e2e.json --runInBand", - "e2e:api": "jest --config e2e/api/jest-e2e.json --runInBand", "typeorm": "typeorm", "typeorm:migrations:create": "typeorm migration:create", "typeorm:migrations:generate": "typeorm migration:generate -d ./dist/infra/database.config.js", From 3bdd2612cecbcca02cd82942e93e788e8e47c27a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 9 Mar 2024 13:33:11 -0500 Subject: [PATCH 09/35] chore(deps): update typescript-eslint monorepo to v7.1.1 (#7790) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- e2e/package-lock.json | 84 +++++++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/e2e/package-lock.json b/e2e/package-lock.json index b6a8b9179a88f..ac11d99cb5f08 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1274,16 +1274,16 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.0.tgz", - "integrity": "sha512-j6vT/kCulhG5wBmGtstKeiVr1rdXE4nk+DT1k6trYkwlrvW9eOF5ZbgKnd/YR6PcM4uTEXa0h6Fcvf6X7Dxl0w==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.1.1.tgz", + "integrity": "sha512-zioDz623d0RHNhvx0eesUmGfIjzrk18nSBC8xewepKXbBvN/7c1qImV7Hg8TI1URTxKax7/zxfxj3Uph8Chcuw==", "dev": true, "dependencies": { "@eslint-community/regexpp": "^4.5.1", - "@typescript-eslint/scope-manager": "7.1.0", - "@typescript-eslint/type-utils": "7.1.0", - "@typescript-eslint/utils": "7.1.0", - "@typescript-eslint/visitor-keys": "7.1.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/type-utils": "7.1.1", + "@typescript-eslint/utils": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1", "debug": "^4.3.4", "graphemer": "^1.4.0", "ignore": "^5.2.4", @@ -1309,15 +1309,15 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.0.tgz", - "integrity": "sha512-V1EknKUubZ1gWFjiOZhDSNToOjs63/9O0puCgGS8aDOgpZY326fzFu15QAUjwaXzRZjf/qdsdBrckYdv9YxB8w==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.1.1.tgz", + "integrity": "sha512-ZWUFyL0z04R1nAEgr9e79YtV5LbafdOtN7yapNbn1ansMyaegl2D4bL7vHoJ4HPSc4CaLwuCVas8CVuneKzplQ==", "dev": true, "dependencies": { - "@typescript-eslint/scope-manager": "7.1.0", - "@typescript-eslint/types": "7.1.0", - "@typescript-eslint/typescript-estree": "7.1.0", - "@typescript-eslint/visitor-keys": "7.1.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1", "debug": "^4.3.4" }, "engines": { @@ -1337,13 +1337,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.0.tgz", - "integrity": "sha512-6TmN4OJiohHfoOdGZ3huuLhpiUgOGTpgXNUPJgeZOZR3DnIpdSgtt83RS35OYNNXxM4TScVlpVKC9jyQSETR1A==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz", + "integrity": "sha512-cirZpA8bJMRb4WZ+rO6+mnOJrGFDd38WoXCEI57+CYBqta8Yc8aJym2i7vyqLL1vVYljgw0X27axkUXz32T8TA==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.0", - "@typescript-eslint/visitor-keys": "7.1.0" + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1" }, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1354,13 +1354,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.0.tgz", - "integrity": "sha512-UZIhv8G+5b5skkcuhgvxYWHjk7FW7/JP5lPASMEUoliAPwIH/rxoUSQPia2cuOj9AmDZmwUl1usKm85t5VUMew==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.1.1.tgz", + "integrity": "sha512-5r4RKze6XHEEhlZnJtR3GYeCh1IueUHdbrukV2KSlLXaTjuSfeVF8mZUVPLovidCuZfbVjfhi4c0DNSa/Rdg5g==", "dev": true, "dependencies": { - "@typescript-eslint/typescript-estree": "7.1.0", - "@typescript-eslint/utils": "7.1.0", + "@typescript-eslint/typescript-estree": "7.1.1", + "@typescript-eslint/utils": "7.1.1", "debug": "^4.3.4", "ts-api-utils": "^1.0.1" }, @@ -1381,9 +1381,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.0.tgz", - "integrity": "sha512-qTWjWieJ1tRJkxgZYXx6WUYtWlBc48YRxgY2JN1aGeVpkhmnopq+SUC8UEVGNXIvWH7XyuTjwALfG6bFEgCkQA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.1.tgz", + "integrity": "sha512-KhewzrlRMrgeKm1U9bh2z5aoL4s7K3tK5DwHDn8MHv0yQfWFz/0ZR6trrIHHa5CsF83j/GgHqzdbzCXJ3crx0Q==", "dev": true, "engines": { "node": "^16.0.0 || >=18.0.0" @@ -1394,13 +1394,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.0.tgz", - "integrity": "sha512-k7MyrbD6E463CBbSpcOnwa8oXRdHzH1WiVzOipK3L5KSML92ZKgUBrTlehdi7PEIMT8k0bQixHUGXggPAlKnOQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.1.1.tgz", + "integrity": "sha512-9ZOncVSfr+sMXVxxca2OJOPagRwT0u/UHikM2Rd6L/aB+kL/QAuTnsv6MeXtjzCJYb8PzrXarypSGIPx3Jemxw==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.0", - "@typescript-eslint/visitor-keys": "7.1.0", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/visitor-keys": "7.1.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -1446,17 +1446,17 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.0.tgz", - "integrity": "sha512-WUFba6PZC5OCGEmbweGpnNJytJiLG7ZvDBJJoUcX4qZYf1mGZ97mO2Mps6O2efxJcJdRNpqweCistDbZMwIVHw==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz", + "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==", "dev": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@types/json-schema": "^7.0.12", "@types/semver": "^7.5.0", - "@typescript-eslint/scope-manager": "7.1.0", - "@typescript-eslint/types": "7.1.0", - "@typescript-eslint/typescript-estree": "7.1.0", + "@typescript-eslint/scope-manager": "7.1.1", + "@typescript-eslint/types": "7.1.1", + "@typescript-eslint/typescript-estree": "7.1.1", "semver": "^7.5.4" }, "engines": { @@ -1471,12 +1471,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.0.tgz", - "integrity": "sha512-FhUqNWluiGNzlvnDZiXad4mZRhtghdoKW6e98GoEOYSu5cND+E39rG5KwJMUzeENwm1ztYBRqof8wMLP+wNPIA==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.1.1.tgz", + "integrity": "sha512-yTdHDQxY7cSoCcAtiBzVzxleJhkGB9NncSIyMYe2+OGON1ZsP9zOPws/Pqgopa65jvknOjlk/w7ulPlZ78PiLQ==", "dev": true, "dependencies": { - "@typescript-eslint/types": "7.1.0", + "@typescript-eslint/types": "7.1.1", "eslint-visitor-keys": "^3.4.1" }, "engines": { From a6cd4b8427e4df7b52deb9f11bcdae448ace1068 Mon Sep 17 00:00:00 2001 From: Alex Date: Sat, 9 Mar 2024 14:01:52 -0600 Subject: [PATCH 10/35] chore(server): openapi (#7794) * chore(server): openapi * openapi --- open-api/immich-openapi-specs.json | 7 +++++-- open-api/typescript-sdk/src/fetch-client.ts | 4 ++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2f5040f343b43..44fb43e101ece 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2358,6 +2358,7 @@ "required": false, "in": "query", "schema": { + "format": "uuid", "type": "array", "items": { "type": "string" @@ -4630,7 +4631,7 @@ "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { @@ -4768,7 +4769,7 @@ "required": true }, "responses": { - "201": { + "200": { "content": { "application/json": { "schema": { @@ -8481,6 +8482,7 @@ }, "personIds": { "items": { + "format": "uuid", "type": "string" }, "type": "array" @@ -9611,6 +9613,7 @@ }, "personIds": { "items": { + "format": "uuid", "type": "string" }, "type": "array" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ee421155bedca..6d4c7cd539054 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -2210,7 +2210,7 @@ export function searchMetadata({ metadataSearchDto }: { metadataSearchDto: MetadataSearchDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; + status: 200; data: SearchResponseDto; }>("/search/metadata", oazapfts.json({ ...opts, @@ -2248,7 +2248,7 @@ export function searchSmart({ smartSearchDto }: { smartSearchDto: SmartSearchDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ - status: 201; + status: 200; data: SearchResponseDto; }>("/search/smart", oazapfts.json({ ...opts, From ec8fb0be83c34cff054bdb0d8274f8dfbe2e0a04 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sat, 9 Mar 2024 22:06:31 +0100 Subject: [PATCH 11/35] chore(server): remove unused storage repository variable from microservices app service (#7797) remove unused storage repository from microservices app service --- server/src/microservices/app.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/src/microservices/app.service.ts b/server/src/microservices/app.service.ts index 623538e594d07..14a6eb6913e02 100644 --- a/server/src/microservices/app.service.ts +++ b/server/src/microservices/app.service.ts @@ -3,7 +3,6 @@ import { AuditService, DatabaseService, IDeleteFilesJob, - IStorageRepository, JobName, JobService, LibraryService, @@ -16,7 +15,7 @@ import { SystemConfigService, UserService, } from '@app/domain'; -import { Inject, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; @Injectable() export class AppService { @@ -34,7 +33,6 @@ export class AppService { private storageService: StorageService, private userService: UserService, private databaseService: DatabaseService, - @Inject(IStorageRepository) private storageRepository: IStorageRepository, ) {} async init() { From 11e7533a4d6f46b37984a1f79d8c8bdc94ae69a7 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sun, 10 Mar 2024 00:10:24 +0100 Subject: [PATCH 12/35] chore(server): user e2e: wait for user delete event (#7799) * wait for user delete event * fix update event names * add test for hard deletion of user --- e2e/src/api/specs/asset.e2e-spec.ts | 10 +++--- e2e/src/api/specs/search.e2e-spec.ts | 4 +-- e2e/src/api/specs/trash.e2e-spec.ts | 2 +- e2e/src/api/specs/user.e2e-spec.ts | 47 ++++++++++++++++++++++------ e2e/src/fixtures.ts | 7 ++++- e2e/src/utils.ts | 30 +++++++++--------- 6 files changed, 67 insertions(+), 33 deletions(-) diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index 65ad094be6216..a13bb58eb174c 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -96,7 +96,7 @@ describe('/asset', () => { }, }); - await utils.waitForWebsocketEvent({ event: 'upload', assetId: locationAsset.id }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: locationAsset.id }); user1Assets = await Promise.all([ utils.createAsset(user1.accessToken), @@ -693,7 +693,7 @@ describe('/asset', () => { expect(duplicate).toBe(false); - await utils.waitForWebsocketEvent({ event: 'upload', assetId: id }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: id }); const asset = await utils.getAssetInfo(admin.accessToken, id); @@ -795,7 +795,7 @@ describe('/asset', () => { }, }); - await utils.waitForWebsocketEvent({ event: 'upload', assetId: response.id }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: response.id }); expect(response.duplicate).toBe(false); @@ -822,8 +822,8 @@ describe('/asset', () => { .set('Authorization', `Bearer ${admin.accessToken}`); await utils.waitForWebsocketEvent({ - event: 'upload', - assetId: locationAsset.id, + event: 'assetUpload', + id: locationAsset.id, }); expect(status).toBe(200); diff --git a/e2e/src/api/specs/search.e2e-spec.ts b/e2e/src/api/specs/search.e2e-spec.ts index 19b1b6807308d..9c554abc56b48 100644 --- a/e2e/src/api/specs/search.e2e-spec.ts +++ b/e2e/src/api/specs/search.e2e-spec.ts @@ -76,7 +76,7 @@ describe('/search', () => { } for (const asset of assets) { - await utils.waitForWebsocketEvent({ event: 'upload', assetId: asset.id }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id: asset.id }); } [ @@ -325,11 +325,9 @@ describe('/search', () => { .post('/search/metadata') .send(dto) .set('Authorization', `Bearer ${admin.accessToken}`); - console.dir({ status, body }, { depth: 10 }); expect(status).toBe(200); expect(body.assets).toBeDefined(); expect(Array.isArray(body.assets.items)).toBe(true); - console.log({ assets: body.assets.items }); for (const [i, asset] of assets.entries()) { expect(body.assets.items[i]).toEqual(expect.objectContaining({ id: asset.id })); } diff --git a/e2e/src/api/specs/trash.e2e-spec.ts b/e2e/src/api/specs/trash.e2e-spec.ts index 3e6c2f1fc6ef6..dc2cadc498f96 100644 --- a/e2e/src/api/specs/trash.e2e-spec.ts +++ b/e2e/src/api/specs/trash.e2e-spec.ts @@ -38,7 +38,7 @@ describe('/trash', () => { const { status } = await request(app).post('/trash/empty').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(204); - await utils.waitForWebsocketEvent({ event: 'delete', assetId }); + await utils.waitForWebsocketEvent({ event: 'assetDelete', id: assetId }); const after = await getAllAssets({}, { headers: asBearerAuth(admin.accessToken) }); expect(after.length).toBe(0); diff --git a/e2e/src/api/specs/user.e2e-spec.ts b/e2e/src/api/specs/user.e2e-spec.ts index d448a605cdcde..911f25381a8df 100644 --- a/e2e/src/api/specs/user.e2e-spec.ts +++ b/e2e/src/api/specs/user.e2e-spec.ts @@ -1,27 +1,37 @@ import { LoginResponseDto, deleteUser, getUserById } from '@immich/sdk'; +import { Socket } from 'socket.io-client'; import { createUserDto, userDto } from 'src/fixtures'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; -import { beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('/user', () => { + let websocket: Socket; -describe('/server-info', () => { let admin: LoginResponseDto; let deletedUser: LoginResponseDto; let userToDelete: LoginResponseDto; + let userToHardDelete: LoginResponseDto; let nonAdmin: LoginResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup({ onboarding: false }); - [deletedUser, nonAdmin, userToDelete] = await Promise.all([ + [websocket, deletedUser, nonAdmin, userToDelete, userToHardDelete] = await Promise.all([ + utils.connectWebsocket(admin.accessToken), utils.userSetup(admin.accessToken, createUserDto.user1), utils.userSetup(admin.accessToken, createUserDto.user2), utils.userSetup(admin.accessToken, createUserDto.user3), + utils.userSetup(admin.accessToken, createUserDto.user4), ]); - await deleteUser({ id: deletedUser.userId }, { headers: asBearerAuth(admin.accessToken) }); + await deleteUser({ id: deletedUser.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); + }); + + afterAll(() => { + utils.disconnectWebsocket(websocket); }); describe('GET /user', () => { @@ -34,13 +44,14 @@ describe('/server-info', () => { it('should get users', async () => { const { status, body } = await request(app).get('/user').set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toEqual(200); - expect(body).toHaveLength(4); + expect(body).toHaveLength(5); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ email: 'admin@immich.cloud' }), expect.objectContaining({ email: 'user1@immich.cloud' }), expect.objectContaining({ email: 'user2@immich.cloud' }), expect.objectContaining({ email: 'user3@immich.cloud' }), + expect.objectContaining({ email: 'user4@immich.cloud' }), ]), ); }); @@ -51,12 +62,13 @@ describe('/server-info', () => { .query({ isAll: true }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(3); + expect(body).toHaveLength(4); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ email: 'admin@immich.cloud' }), expect.objectContaining({ email: 'user2@immich.cloud' }), expect.objectContaining({ email: 'user3@immich.cloud' }), + expect.objectContaining({ email: 'user4@immich.cloud' }), ]), ); }); @@ -68,13 +80,14 @@ describe('/server-info', () => { .set('Authorization', `Bearer ${admin.accessToken}`); expect(status).toBe(200); - expect(body).toHaveLength(4); + expect(body).toHaveLength(5); expect(body).toEqual( expect.arrayContaining([ expect.objectContaining({ email: 'admin@immich.cloud' }), expect.objectContaining({ email: 'user1@immich.cloud' }), expect.objectContaining({ email: 'user2@immich.cloud' }), expect.objectContaining({ email: 'user3@immich.cloud' }), + expect.objectContaining({ email: 'user4@immich.cloud' }), ]), ); }); @@ -138,13 +151,13 @@ describe('/server-info', () => { .post(`/user`) .send({ isAdmin: true, - email: 'user4@immich.cloud', + email: 'user5@immich.cloud', password: 'password123', name: 'Immich', }) .set('Authorization', `Bearer ${admin.accessToken}`); expect(body).toMatchObject({ - email: 'user4@immich.cloud', + email: 'user5@immich.cloud', isAdmin: false, shouldChangePassword: true, }); @@ -188,6 +201,22 @@ describe('/server-info', () => { deletedAt: expect.any(String), }); }); + + it('should hard delete user', async () => { + const { status, body } = await request(app) + .delete(`/user/${userToHardDelete.userId}`) + .send({ force: true }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toMatchObject({ + id: userToHardDelete.userId, + updatedAt: expect.any(String), + deletedAt: expect.any(String), + }); + + await utils.waitForWebsocketEvent({ event: 'userDelete', id: userToHardDelete.userId, timeout: 5000 }); + }); }); describe('PUT /user', () => { diff --git a/e2e/src/fixtures.ts b/e2e/src/fixtures.ts index 56070e6e343bb..031985c5fb257 100644 --- a/e2e/src/fixtures.ts +++ b/e2e/src/fixtures.ts @@ -24,7 +24,7 @@ export const createUserDto = { create(key: string) { return { email: `${key}@immich.cloud`, - name: `User ${key}`, + name: `Generated User ${key}`, password: `password-${key}`, }; }, @@ -43,6 +43,11 @@ export const createUserDto = { name: 'User 3', password: 'password123', }, + user4: { + email: 'user4@immich.cloud', + name: 'User 4', + password: 'password123', + }, userQuota: { email: 'user-quota@immich.cloud', name: 'User Quota', diff --git a/e2e/src/utils.ts b/e2e/src/utils.ts index af86a608db22b..dde9ed22ce809 100644 --- a/e2e/src/utils.ts +++ b/e2e/src/utils.ts @@ -36,8 +36,8 @@ import { makeRandomImage } from 'src/generators'; import request from 'supertest'; type CliResponse = { stdout: string; stderr: string; exitCode: number | null }; -type EventType = 'upload' | 'delete'; -type WaitOptions = { event: EventType; assetId: string; timeout?: number }; +type EventType = 'assetUpload' | 'assetDelete' | 'userDelete'; +type WaitOptions = { event: EventType; id: string; timeout?: number }; type AdminSetupOptions = { onboarding?: boolean }; type AssetData = { bytes?: Buffer; filename: string }; @@ -78,20 +78,21 @@ export const immichCli = async (args: string[]) => { let client: pg.Client | null = null; const events: Record> = { - upload: new Set(), - delete: new Set(), + assetUpload: new Set(), + assetDelete: new Set(), + userDelete: new Set(), }; const callbacks: Record void> = {}; const execPromise = promisify(exec); -const onEvent = ({ event, assetId }: { event: EventType; assetId: string }) => { - events[event].add(assetId); - const callback = callbacks[assetId]; +const onEvent = ({ event, id }: { event: EventType; id: string }) => { + events[event].add(id); + const callback = callbacks[id]; if (callback) { callback(); - delete callbacks[assetId]; + delete callbacks[id]; } }; @@ -166,8 +167,9 @@ export const utils = { return new Promise((resolve) => { websocket .on('connect', () => resolve(websocket)) - .on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'upload', assetId: data.id })) - .on('on_asset_delete', (assetId: string) => onEvent({ event: 'delete', assetId })) + .on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id })) + .on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId })) + .on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId })) .connect(); }); }, @@ -182,17 +184,17 @@ export const utils = { } }, - waitForWebsocketEvent: async ({ event, assetId, timeout: ms }: WaitOptions): Promise => { - console.log(`Waiting for ${event} [${assetId}]`); + waitForWebsocketEvent: async ({ event, id, timeout: ms }: WaitOptions): Promise => { + console.log(`Waiting for ${event} [${id}]`); const set = events[event]; - if (set.has(assetId)) { + if (set.has(id)) { return; } return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error(`Timed out waiting for ${event} event`)), ms || 10_000); - callbacks[assetId] = () => { + callbacks[id] = () => { clearTimeout(timeout); resolve(); }; From 60c521101a0db1c37115343756a2c6325df961e5 Mon Sep 17 00:00:00 2001 From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com> Date: Sun, 10 Mar 2024 00:18:25 +0100 Subject: [PATCH 13/35] chore(server): type checks for e2e (#7800) type checks for e2e --- .github/workflows/test.yml | 4 ++++ e2e/package.json | 3 ++- e2e/src/api/specs/album.e2e-spec.ts | 2 +- e2e/src/api/specs/shared-link.e2e-spec.ts | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9adcfe7373333..d704aa629527d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -196,6 +196,10 @@ jobs: run: npm run format if: ${{ !cancelled() }} + - name: Run tsc + run: npm run check + if: ${{ !cancelled() }} + - name: Install Playwright Browsers run: npx playwright install --with-deps chromium if: ${{ !cancelled() }} diff --git a/e2e/package.json b/e2e/package.json index 1e66fd579cbab..42862d67fb490 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -12,7 +12,8 @@ "format": "prettier --check .", "format:fix": "prettier --write .", "lint": "eslint \"src/**/*.ts\" --max-warnings 0", - "lint:fix": "npm run lint -- --fix" + "lint:fix": "npm run lint -- --fix", + "check": "tsc --noEmit" }, "keywords": [], "author": "", diff --git a/e2e/src/api/specs/album.e2e-spec.ts b/e2e/src/api/specs/album.e2e-spec.ts index 4faa5eac3d8e0..2310b4718cfc3 100644 --- a/e2e/src/api/specs/album.e2e-spec.ts +++ b/e2e/src/api/specs/album.e2e-spec.ts @@ -92,7 +92,7 @@ describe('/album', () => { }), ]); - await deleteUser({ id: user3.userId }, { headers: asBearerAuth(admin.accessToken) }); + await deleteUser({ id: user3.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); }); describe('GET /album', () => { diff --git a/e2e/src/api/specs/shared-link.e2e-spec.ts b/e2e/src/api/specs/shared-link.e2e-spec.ts index 8b854eda00f36..3c3fd79269059 100644 --- a/e2e/src/api/specs/shared-link.e2e-spec.ts +++ b/e2e/src/api/specs/shared-link.e2e-spec.ts @@ -86,7 +86,7 @@ describe('/shared-link', () => { }), ]); - await deleteUser({ id: user2.userId }, { headers: asBearerAuth(admin.accessToken) }); + await deleteUser({ id: user2.userId, deleteUserDto: {} }, { headers: asBearerAuth(admin.accessToken) }); }); describe('GET /shared-link', () => { From d257cdcbbfcc7b1b94cfff87bda31caa4193861e Mon Sep 17 00:00:00 2001 From: Sam Holton Date: Sun, 10 Mar 2024 15:32:05 -0400 Subject: [PATCH 14/35] feat(web): add sticky date headers for asset-date-group (#7824) * feat(web): add sticky date headers for asset-date-group * use existing classes --- .../lib/components/photos-page/asset-date-group.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/web/src/lib/components/photos-page/asset-date-group.svelte b/web/src/lib/components/photos-page/asset-date-group.svelte index 0c40c871080f4..6f7af44efa126 100644 --- a/web/src/lib/components/photos-page/asset-date-group.svelte +++ b/web/src/lib/components/photos-page/asset-date-group.svelte @@ -127,7 +127,7 @@
{ isMouseOverGroup = true; assetMouseEventHandler(groupTitle, null); @@ -138,8 +138,8 @@ }} > -

{#if !singleSelect && ((hoveredDateGroup == groupTitle && isMouseOverGroup) || $selectedGroup.has(groupTitle))} @@ -160,7 +160,7 @@ {groupTitle} -

+
Date: Sun, 10 Mar 2024 22:32:27 +0000 Subject: [PATCH 15/35] =?UTF-8?q?Fix:=20Disable=20'As=20profile=20picture'?= =?UTF-8?q?=20option=20for=20videos=20in=20context=20menu=20a=E2=80=A6=20(?= =?UTF-8?q?#7830)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: Disable 'As profile picture' option for videos in context menu asset-viewer-nav-bar.svelte This commit modifies the context menu behavior to disable the "As profile picture" option when interacting with video assets. Previously, the option was available for all asset types, including videos, which could lead to confusion when this displayed an error. With this change, the "As profile picture" option is conditionally rendered based on the asset type. If the asset is a video, the option is not displayed in the context menu. This adjustment enhances the web experience by preventing users from attempting to set a video as their profile picture, which is not supported by the system. Fixes: #7724 * Switched to check if photo instead of video --- .../lib/components/asset-viewer/asset-viewer-nav-bar.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 227fb139425f8..4d09e170505ab 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -186,7 +186,9 @@ on:click={() => dispatch('toggleArchive')} text={asset.isArchived ? 'Unarchive' : 'Archive'} /> - onMenuClick('asProfileImage')} text="As profile picture" /> + {#if asset.type === AssetTypeEnum.Image} + onMenuClick('asProfileImage')} text="As profile picture" /> + {/if} {#if hasStackChildren} onMenuClick('unstack')} text="Un-Stack" /> From 49d9051879a8b0b5d240e086aca8bd3382639047 Mon Sep 17 00:00:00 2001 From: Ghazi Tounsi Date: Mon, 11 Mar 2024 03:15:34 +0100 Subject: [PATCH 16/35] fix(mobile): archive desc sorting (#7822) desc sort --- .../lib/modules/archive/providers/archive_asset_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/lib/modules/archive/providers/archive_asset_provider.dart b/mobile/lib/modules/archive/providers/archive_asset_provider.dart index 579ace7c50951..5441293f7798f 100644 --- a/mobile/lib/modules/archive/providers/archive_asset_provider.dart +++ b/mobile/lib/modules/archive/providers/archive_asset_provider.dart @@ -17,6 +17,6 @@ final archiveProvider = StreamProvider((ref) { .filter() .isArchivedEqualTo(true) .isTrashedEqualTo(false) - .sortByFileCreatedAt(); + .sortByFileCreatedAtDesc(); return renderListGenerator(query, ref); }); From 5bd597f14bf920d83e6193fae47ab841d49c2680 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Sun, 10 Mar 2024 22:30:57 -0400 Subject: [PATCH 17/35] fix(server): external library sync not working for large libraries (#7759) --- server/package-lock.json | 33 +++--------- server/package.json | 1 + .../domain/library/library.service.spec.ts | 49 +++++++++++++++-- server/src/domain/library/library.service.ts | 53 ++++++++++++++----- .../domain/repositories/asset.repository.ts | 6 +-- .../infra/repositories/asset.repository.ts | 9 ++-- .../infra/repositories/filesystem.provider.ts | 8 +-- server/src/infra/sql/asset.repository.sql | 47 ---------------- .../repositories/asset.repository.mock.ts | 4 +- 9 files changed, 106 insertions(+), 104 deletions(-) diff --git a/server/package-lock.json b/server/package-lock.json index 0b8e755e22f68..94e5518b7eb47 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -33,6 +33,7 @@ "cookie-parser": "^1.4.6", "exiftool-vendored": "~24.5.0", "exiftool-vendored.pl": "12.76", + "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", "glob": "^10.3.3", @@ -2657,7 +2658,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -2670,7 +2670,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, "engines": { "node": ">= 8" } @@ -2679,7 +2678,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -6345,7 +6343,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -6378,7 +6375,6 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, "dependencies": { "reusify": "^1.0.4" } @@ -8665,7 +8661,6 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, "engines": { "node": ">= 8" } @@ -8682,7 +8677,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "dependencies": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -8695,7 +8689,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -9965,7 +9958,6 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -10405,7 +10397,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" @@ -10441,7 +10432,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "funding": [ { "type": "github", @@ -14454,7 +14444,6 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, "requires": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" @@ -14463,14 +14452,12 @@ "@nodelib/fs.stat": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==" }, "@nodelib/fs.walk": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, "requires": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" @@ -17321,7 +17308,6 @@ "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", - "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -17351,7 +17337,6 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, "requires": { "reusify": "^1.0.4" } @@ -19073,8 +19058,7 @@ "merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==" }, "methods": { "version": "1.1.2", @@ -19085,7 +19069,6 @@ "version": "4.0.5", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, "requires": { "braces": "^3.0.2", "picomatch": "^2.3.1" @@ -19094,8 +19077,7 @@ "picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==" } } }, @@ -20035,8 +20017,7 @@ "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==" }, "queue-tick": { "version": "1.0.1", @@ -20367,8 +20348,7 @@ "reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==" }, "rimraf": { "version": "5.0.5", @@ -20388,7 +20368,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, "requires": { "queue-microtask": "^1.2.2" } diff --git a/server/package.json b/server/package.json index 1e3d31c7d81e0..18190646dab49 100644 --- a/server/package.json +++ b/server/package.json @@ -57,6 +57,7 @@ "cookie-parser": "^1.4.6", "exiftool-vendored": "~24.5.0", "exiftool-vendored.pl": "12.76", + "fast-glob": "^3.3.2", "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", "glob": "^10.3.3", diff --git a/server/src/domain/library/library.service.spec.ts b/server/src/domain/library/library.service.spec.ts index a44624c43abb7..03042cf55a245 100644 --- a/server/src/domain/library/library.service.spec.ts +++ b/server/src/domain/library/library.service.spec.ts @@ -156,8 +156,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); - assetMock.getPathsNotInLibrary.mockResolvedValue(['/data/user1/photo.jpg']); - assetMock.getByLibraryId.mockResolvedValue([]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -183,7 +182,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); storageMock.crawl.mockResolvedValue(['/data/user1/photo.jpg']); - assetMock.getByLibraryId.mockResolvedValue([]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -233,7 +232,7 @@ describe(LibraryService.name, () => { libraryMock.get.mockResolvedValue(libraryStub.externalLibraryWithImportPaths1); storageMock.crawl.mockResolvedValue([]); - assetMock.getByLibraryId.mockResolvedValue([]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ items: [], hasNextPage: false }); await sut.handleQueueAssetRefresh(mockLibraryJob); @@ -242,6 +241,48 @@ describe(LibraryService.name, () => { exclusionPatterns: [], }); }); + + it('should set missing assets offline', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.crawl.mockResolvedValue([]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ + items: [assetStub.image], + hasNextPage: false, + }); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.image.id], { isOffline: true }); + expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: false }); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + }); + + it('should set crawled assets that were previously offline back online', async () => { + const mockLibraryJob: ILibraryRefreshJob = { + id: libraryStub.externalLibrary1.id, + refreshModifiedFiles: false, + refreshAllFiles: false, + }; + + libraryMock.get.mockResolvedValue(libraryStub.externalLibrary1); + storageMock.crawl.mockResolvedValue([assetStub.offline.originalPath]); + assetMock.getLibraryAssetPaths.mockResolvedValue({ + items: [assetStub.offline], + hasNextPage: false, + }); + + await sut.handleQueueAssetRefresh(mockLibraryJob); + + expect(assetMock.updateAll).toHaveBeenCalledWith([assetStub.offline.id], { isOffline: false }); + expect(assetMock.updateAll).not.toHaveBeenCalledWith(expect.anything(), { isOffline: true }); + expect(jobMock.queueAll).not.toHaveBeenCalled(); + }); }); describe('handleAssetRefresh', () => { diff --git a/server/src/domain/library/library.service.ts b/server/src/domain/library/library.service.ts index c74e97ea36db6..25894c9b5a963 100644 --- a/server/src/domain/library/library.service.ts +++ b/server/src/domain/library/library.service.ts @@ -640,27 +640,56 @@ export class LibraryService extends EventEmitter { .filter((validation) => validation.isValid) .map((validation) => validation.importPath); - const rawPaths = await this.storageRepository.crawl({ + let rawPaths = await this.storageRepository.crawl({ pathsToCrawl: validImportPaths, exclusionPatterns: library.exclusionPatterns, }); - const crawledAssetPaths = rawPaths.map((filePath) => path.normalize(filePath)); + const crawledAssetPaths = new Set(rawPaths); - this.logger.debug(`Found ${crawledAssetPaths.length} asset(s) when crawling import paths ${library.importPaths}`); + const shouldScanAll = job.refreshAllFiles || job.refreshModifiedFiles; + let pathsToScan: string[] = shouldScanAll ? rawPaths : []; + rawPaths = []; - await this.assetRepository.updateOfflineLibraryAssets(library.id, crawledAssetPaths); + this.logger.debug(`Found ${crawledAssetPaths.size} asset(s) when crawling import paths ${library.importPaths}`); - if (crawledAssetPaths.length > 0) { - let filteredPaths: string[] = []; - if (job.refreshAllFiles || job.refreshModifiedFiles) { - filteredPaths = crawledAssetPaths; - } else { - filteredPaths = await this.assetRepository.getPathsNotInLibrary(library.id, crawledAssetPaths); + const assetIdsToMarkOffline = []; + const assetIdsToMarkOnline = []; + const pagination = usePagination(5000, (pagination) => + this.assetRepository.getLibraryAssetPaths(pagination, library.id), + ); - this.logger.debug(`Will import ${filteredPaths.length} new asset(s)`); + for await (const page of pagination) { + for (const asset of page) { + const isOffline = !crawledAssetPaths.has(asset.originalPath); + if (isOffline && !asset.isOffline) { + assetIdsToMarkOffline.push(asset.id); + } + + if (!isOffline && asset.isOffline) { + assetIdsToMarkOnline.push(asset.id); + } + + crawledAssetPaths.delete(asset.originalPath); } + } - await this.scanAssets(job.id, filteredPaths, library.ownerId, job.refreshAllFiles ?? false); + if (assetIdsToMarkOffline.length > 0) { + this.logger.debug(`Found ${assetIdsToMarkOffline.length} offline asset(s) previously marked as online`); + await this.assetRepository.updateAll(assetIdsToMarkOffline, { isOffline: true }); + } + + if (assetIdsToMarkOnline.length > 0) { + this.logger.debug(`Found ${assetIdsToMarkOnline.length} online asset(s) previously marked as offline`); + await this.assetRepository.updateAll(assetIdsToMarkOnline, { isOffline: false }); + } + + if (!shouldScanAll) { + pathsToScan = [...crawledAssetPaths]; + this.logger.debug(`Will import ${pathsToScan.length} new asset(s)`); + } + + if (pathsToScan.length > 0) { + await this.scanAssets(job.id, pathsToScan, library.ownerId, job.refreshAllFiles ?? false); } await this.repository.update({ id: job.id, refreshedAt: new Date() }); diff --git a/server/src/domain/repositories/asset.repository.ts b/server/src/domain/repositories/asset.repository.ts index 847c97aae3971..b779c8b8c3a22 100644 --- a/server/src/domain/repositories/asset.repository.ts +++ b/server/src/domain/repositories/asset.repository.ts @@ -109,6 +109,8 @@ export interface MetadataSearchOptions { numResults: number; } +export type AssetPathEntity = Pick; + export const IAssetRepository = 'IAssetRepository'; export interface IAssetRepository { @@ -129,10 +131,8 @@ export interface IAssetRepository { getRandom(userId: string, count: number): Promise; getFirstAssetForAlbumId(albumId: string): Promise; getLastUpdatedAssetForAlbumId(albumId: string): Promise; - getByLibraryId(libraryIds: string[]): Promise; + getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated; getByLibraryIdAndOriginalPath(libraryId: string, originalPath: string): Promise; - getPathsNotInLibrary(libraryId: string, originalPaths: string[]): Promise; - updateOfflineLibraryAssets(libraryId: string, originalPaths: string[]): Promise; deleteAll(ownerId: string): Promise; getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated; getAllByDeviceId(userId: string, deviceId: string): Promise; diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 15aa11523a4ac..ff60be9fe0f00 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -2,6 +2,7 @@ import { AssetBuilderOptions, AssetCreate, AssetExploreFieldOptions, + AssetPathEntity, AssetSearchOptions, AssetStats, AssetStatsOptions, @@ -184,10 +185,10 @@ export class AssetRepository implements IAssetRepository { } @GenerateSql({ params: [[DummyValue.UUID]] }) - @ChunkedArray() - getByLibraryId(libraryIds: string[]): Promise { - return this.repository.find({ - where: { library: { id: In(libraryIds) } }, + getLibraryAssetPaths(pagination: PaginationOptions, libraryId: string): Paginated { + return paginate(this.repository, pagination, { + select: { id: true, originalPath: true, isOffline: true }, + where: { library: { id: libraryId } }, }); } diff --git a/server/src/infra/repositories/filesystem.provider.ts b/server/src/infra/repositories/filesystem.provider.ts index fef184992d830..32880ae181984 100644 --- a/server/src/infra/repositories/filesystem.provider.ts +++ b/server/src/infra/repositories/filesystem.provider.ts @@ -11,7 +11,7 @@ import { import { ImmichLogger } from '@app/infra/logger'; import archiver from 'archiver'; import chokidar, { WatchOptions } from 'chokidar'; -import { glob } from 'glob'; +import { glob } from 'fast-glob'; import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; import fs, { copyFile, readdir, rename, stat, utimes, writeFile } from 'node:fs/promises'; import path from 'node:path'; @@ -123,7 +123,7 @@ export class FilesystemProvider implements IStorageRepository { crawl(crawlOptions: CrawlOptionsDto): Promise { const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions; - if (!pathsToCrawl) { + if (pathsToCrawl.length === 0) { return Promise.resolve([]); } @@ -132,8 +132,8 @@ export class FilesystemProvider implements IStorageRepository { return glob(`${base}/**/${extensions}`, { absolute: true, - nocase: true, - nodir: true, + caseSensitiveMatch: false, + onlyFiles: true, dot: includeHidden, ignore: exclusionPatterns, }); diff --git a/server/src/infra/sql/asset.repository.sql b/server/src/infra/sql/asset.repository.sql index 4e5dd2536bcfc..75b5291b66914 100644 --- a/server/src/infra/sql/asset.repository.sql +++ b/server/src/infra/sql/asset.repository.sql @@ -293,53 +293,6 @@ DELETE FROM "assets" WHERE "ownerId" = $1 --- AssetRepository.getByLibraryId -SELECT - "AssetEntity"."id" AS "AssetEntity_id", - "AssetEntity"."deviceAssetId" AS "AssetEntity_deviceAssetId", - "AssetEntity"."ownerId" AS "AssetEntity_ownerId", - "AssetEntity"."libraryId" AS "AssetEntity_libraryId", - "AssetEntity"."deviceId" AS "AssetEntity_deviceId", - "AssetEntity"."type" AS "AssetEntity_type", - "AssetEntity"."originalPath" AS "AssetEntity_originalPath", - "AssetEntity"."resizePath" AS "AssetEntity_resizePath", - "AssetEntity"."webpPath" AS "AssetEntity_webpPath", - "AssetEntity"."thumbhash" AS "AssetEntity_thumbhash", - "AssetEntity"."encodedVideoPath" AS "AssetEntity_encodedVideoPath", - "AssetEntity"."createdAt" AS "AssetEntity_createdAt", - "AssetEntity"."updatedAt" AS "AssetEntity_updatedAt", - "AssetEntity"."deletedAt" AS "AssetEntity_deletedAt", - "AssetEntity"."fileCreatedAt" AS "AssetEntity_fileCreatedAt", - "AssetEntity"."localDateTime" AS "AssetEntity_localDateTime", - "AssetEntity"."fileModifiedAt" AS "AssetEntity_fileModifiedAt", - "AssetEntity"."isFavorite" AS "AssetEntity_isFavorite", - "AssetEntity"."isArchived" AS "AssetEntity_isArchived", - "AssetEntity"."isExternal" AS "AssetEntity_isExternal", - "AssetEntity"."isReadOnly" AS "AssetEntity_isReadOnly", - "AssetEntity"."isOffline" AS "AssetEntity_isOffline", - "AssetEntity"."checksum" AS "AssetEntity_checksum", - "AssetEntity"."duration" AS "AssetEntity_duration", - "AssetEntity"."isVisible" AS "AssetEntity_isVisible", - "AssetEntity"."livePhotoVideoId" AS "AssetEntity_livePhotoVideoId", - "AssetEntity"."originalFileName" AS "AssetEntity_originalFileName", - "AssetEntity"."sidecarPath" AS "AssetEntity_sidecarPath", - "AssetEntity"."stackId" AS "AssetEntity_stackId" -FROM - "assets" "AssetEntity" - LEFT JOIN "libraries" "AssetEntity__AssetEntity_library" ON "AssetEntity__AssetEntity_library"."id" = "AssetEntity"."libraryId" - AND ( - "AssetEntity__AssetEntity_library"."deletedAt" IS NULL - ) -WHERE - ( - ( - ( - (("AssetEntity__AssetEntity_library"."id" IN ($1))) - ) - ) - ) - AND ("AssetEntity"."deletedAt" IS NULL) - -- AssetRepository.getByLibraryIdAndOriginalPath SELECT DISTINCT "distinctAlias"."AssetEntity_id" AS "ids_AssetEntity_id" diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index 6143d357c148f..e1a5fed83097f 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -20,10 +20,8 @@ export const newAssetRepositoryMock = (): jest.Mocked => { getAll: jest.fn().mockResolvedValue({ items: [], hasNextPage: false }), getAllByDeviceId: jest.fn(), updateAll: jest.fn(), - getByLibraryId: jest.fn(), + getLibraryAssetPaths: jest.fn(), getByLibraryIdAndOriginalPath: jest.fn(), - updateOfflineLibraryAssets: jest.fn(), - getPathsNotInLibrary: jest.fn(), deleteAll: jest.fn(), save: jest.fn(), remove: jest.fn(), From 4027cba4ebb4ebe95c7568814df862cbbdc16048 Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Mon, 11 Mar 2024 14:17:12 +0000 Subject: [PATCH 18/35] Version v1.98.2 --- cli/package-lock.json | 2 +- e2e/package-lock.json | 6 +++--- e2e/package.json | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- open-api/immich-openapi-specs.json | 2 +- open-api/typescript-sdk/package-lock.json | 4 ++-- open-api/typescript-sdk/package.json | 2 +- open-api/typescript-sdk/src/fetch-client.ts | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/package-lock.json | 6 +++--- web/package.json | 2 +- 16 files changed, 23 insertions(+), 23 deletions(-) diff --git a/cli/package-lock.json b/cli/package-lock.json index 5da869dc3b8c4..38fe3c6f17d8d 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -46,7 +46,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.1", + "version": "1.98.2", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/e2e/package-lock.json b/e2e/package-lock.json index ac11d99cb5f08..f3d06eacc671f 100644 --- a/e2e/package-lock.json +++ b/e2e/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-e2e", - "version": "1.98.1", + "version": "1.98.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-e2e", - "version": "1.98.1", + "version": "1.98.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@immich/cli": "file:../cli", @@ -79,7 +79,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.1", + "version": "1.98.2", "dev": true, "license": "GNU Affero General Public License version 3", "devDependencies": { diff --git a/e2e/package.json b/e2e/package.json index 42862d67fb490..99d3a91cdb648 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -1,6 +1,6 @@ { "name": "immich-e2e", - "version": "1.98.1", + "version": "1.98.2", "description": "", "main": "index.js", "type": "module", diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 2d1845511c076..327f4fd35e561 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.98.1" +version = "1.98.2" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index 972986cb72dcb..a11d602676b2c 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -35,8 +35,8 @@ platform :android do task: 'bundle', build_type: 'Release', properties: { - "android.injected.version.code" => 127, - "android.injected.version.name" => "1.98.1", + "android.injected.version.code" => 128, + "android.injected.version.name" => "1.98.2", } ) upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index ae5c31e723242..00cbeb8ac0e87 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -19,7 +19,7 @@ platform :ios do desc "iOS Beta" lane :beta do increment_version_number( - version_number: "1.98.1" + version_number: "1.98.2" ) increment_build_number( build_number: latest_testflight_build_number + 1, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 6041bd39d36e8..d8ff4d30fed86 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -3,7 +3,7 @@ Immich API This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project: -- API version: 1.98.1 +- API version: 1.98.2 - Build package: org.openapitools.codegen.languages.DartClientCodegen ## Requirements diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 7904fcd308b4f..85f240ae9510e 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.98.1+127 +version: 1.98.2+128 isar_version: &isar_version 3.1.0+1 environment: diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 44fb43e101ece..2540baf7755e5 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -6487,7 +6487,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.98.1", + "version": "1.98.2", "contact": {} }, "tags": [], diff --git a/open-api/typescript-sdk/package-lock.json b/open-api/typescript-sdk/package-lock.json index c95a96efe1f45..0c223083533e3 100644 --- a/open-api/typescript-sdk/package-lock.json +++ b/open-api/typescript-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@immich/sdk", - "version": "1.98.1", + "version": "1.98.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@immich/sdk", - "version": "1.98.1", + "version": "1.98.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/open-api/typescript-sdk/package.json b/open-api/typescript-sdk/package.json index 4450f1b2d6b98..ec5e78facb636 100644 --- a/open-api/typescript-sdk/package.json +++ b/open-api/typescript-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@immich/sdk", - "version": "1.98.1", + "version": "1.98.2", "description": "", "type": "module", "main": "./build/index.js", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 6d4c7cd539054..acf540aff1df5 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1,6 +1,6 @@ /** * Immich - * 1.98.1 + * 1.98.2 * DO NOT MODIFY - This file has been generated using oazapfts. * See https://www.npmjs.com/package/oazapfts */ diff --git a/server/package-lock.json b/server/package-lock.json index 94e5518b7eb47..2468c0c97d7c0 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.98.1", + "version": "1.98.2", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.98.1", + "version": "1.98.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@babel/runtime": "^7.22.11", diff --git a/server/package.json b/server/package.json index 18190646dab49..70d647989c535 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.98.1", + "version": "1.98.2", "description": "", "author": "", "private": true, diff --git a/web/package-lock.json b/web/package-lock.json index 2119ddfea5dbd..5d8212d3ee91a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich-web", - "version": "1.98.1", + "version": "1.98.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "immich-web", - "version": "1.98.1", + "version": "1.98.2", "license": "GNU Affero General Public License version 3", "dependencies": { "@immich/sdk": "file:../open-api/typescript-sdk", @@ -63,7 +63,7 @@ }, "../open-api/typescript-sdk": { "name": "@immich/sdk", - "version": "1.98.1", + "version": "1.98.2", "license": "GNU Affero General Public License version 3", "devDependencies": { "@oazapfts/runtime": "^1.0.0", diff --git a/web/package.json b/web/package.json index 396f119a62e32..4714b09b0f501 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "immich-web", - "version": "1.98.1", + "version": "1.98.2", "license": "GNU Affero General Public License version 3", "scripts": { "dev": "vite dev --host 0.0.0.0 --port 3000", From 8dc62bd29a6675a2fcde077069be929100be0863 Mon Sep 17 00:00:00 2001 From: Mert <101130780+mertalev@users.noreply.github.com> Date: Mon, 11 Mar 2024 10:19:20 -0400 Subject: [PATCH 19/35] fix(server): face search results not always sorted (#7839) * order by * update sql --- server/src/infra/repositories/search.repository.ts | 1 + server/src/infra/sql/search.repository.sql | 2 ++ 2 files changed, 3 insertions(+) diff --git a/server/src/infra/repositories/search.repository.ts b/server/src/infra/repositories/search.repository.ts index 0ff26a4f5f848..823193138552e 100644 --- a/server/src/infra/repositories/search.repository.ts +++ b/server/src/infra/repositories/search.repository.ts @@ -188,6 +188,7 @@ export class SearchRepository implements ISearchRepository { .addCommonTableExpression(cte, 'cte') .from('cte', 'res') .where('res.distance <= :maxDistance', { maxDistance }) + .orderBy('res.distance') .getRawMany(); }); return results.map((row) => ({ diff --git a/server/src/infra/sql/search.repository.sql b/server/src/infra/sql/search.repository.sql index 48a7fc8e5bce1..a11f8805a0487 100644 --- a/server/src/infra/sql/search.repository.sql +++ b/server/src/infra/sql/search.repository.sql @@ -229,6 +229,8 @@ FROM "cte" "res" WHERE res.distance <= $3 +ORDER BY + res.distance ASC COMMIT -- SearchRepository.searchPlaces From ae34e4f59fa28eb0109e9d595c69a5544d29e418 Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 11 Mar 2024 09:48:25 -0500 Subject: [PATCH 20/35] chore: post release tasks --- mobile/android/fastlane/report.xml | 6 +++--- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- mobile/ios/fastlane/report.xml | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 4a3fba3676d3c..0dcf80996487d 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index dd714b6d21202..dcfe3dc85ed1f 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -379,7 +379,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 143; + CURRENT_PROJECT_VERSION = 144; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -515,7 +515,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 143; + CURRENT_PROJECT_VERSION = 144; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -543,7 +543,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 143; + CURRENT_PROJECT_VERSION = 144; DEVELOPMENT_TEAM = 2F67MQ8R79; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 75002a3c06be2..310133e0bbbbb 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -55,11 +55,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.98.1 + 1.98.2 CFBundleSignature ???? CFBundleVersion - 143 + 144 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index ea1fd2e0cb6b6..f1e83434b4e18 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + From a41ffb5131bf9c7ee37a2a727f3dc561027fb940 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Mon, 11 Mar 2024 11:29:21 -0400 Subject: [PATCH 21/35] feat(web): empty external library placeholder (#7848) --- .../admin/library-management/+page.svelte | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/web/src/routes/admin/library-management/+page.svelte b/web/src/routes/admin/library-management/+page.svelte index 9f809409f4426..ea681acc06636 100644 --- a/web/src/routes/admin/library-management/+page.svelte +++ b/web/src/routes/admin/library-management/+page.svelte @@ -1,9 +1,10 @@ - -
-
-
-
-
- -
-
-
-
-
- -
diff --git a/web/src/lib/components/user-settings-page/user-settings-list.svelte b/web/src/lib/components/user-settings-page/user-settings-list.svelte index b46768c3e7744..a0eaa8cb47687 100644 --- a/web/src/lib/components/user-settings-page/user-settings-list.svelte +++ b/web/src/lib/components/user-settings-page/user-settings-list.svelte @@ -6,13 +6,12 @@ import { oauth } from '$lib/utils'; import { type ApiKeyResponseDto, type AuthDeviceResponseDto } from '@immich/sdk'; import SettingAccordion from '../shared-components/settings/setting-accordion.svelte'; - import AppearanceSettings from './appearance-settings.svelte'; + import AppSettings from './app-settings.svelte'; import ChangePasswordSettings from './change-password-settings.svelte'; import DeviceList from './device-list.svelte'; import MemoriesSettings from './memories-settings.svelte'; import OAuthSettings from './oauth-settings.svelte'; import PartnerSettings from './partner-settings.svelte'; - import TrashSettings from './trash-settings.svelte'; import UserAPIKeyList from './user-api-key-list.svelte'; import UserProfileSettings from './user-profile-settings.svelte'; import SettingAccordionState from '../shared-components/settings/setting-accordion-state.svelte'; @@ -26,8 +25,8 @@ - - + + @@ -42,7 +41,7 @@ - + @@ -59,8 +58,4 @@ - - - - diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 49b836b8a88ee..ff158722e495e 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -96,3 +96,5 @@ export const albumViewSettings = persisted('album-view-settin export const showDeleteModal = persisted('delete-confirm-dialog', true, {}); export const alwaysLoadOriginalFile = persisted('always-load-original-file', false, {}); + +export const playVideoThumbnailOnHover = persisted('play-video-thumbnail-on-hover', true, {}); From 8c3ff654027b9f2586580188e7ac89a30fe32bd8 Mon Sep 17 00:00:00 2001 From: markeeisner Date: Mon, 11 Mar 2024 20:55:21 +0400 Subject: [PATCH 33/35] docs: Add guide for album sync python script (#7736) * Add guide for album sync python script * Add newline for formatting test * Run prettier * fix hardcoded url and extension * brevity Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> * more brevity with copy and paste facepalm Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> * grammar fix Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> --------- Co-authored-by: Mert <101130780+mertalev@users.noreply.github.com> --- docs/docs/guides/api-album-sync.md | 130 +++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/docs/guides/api-album-sync.md diff --git a/docs/docs/guides/api-album-sync.md b/docs/docs/guides/api-album-sync.md new file mode 100644 index 0000000000000..c03915e2cce24 --- /dev/null +++ b/docs/docs/guides/api-album-sync.md @@ -0,0 +1,130 @@ +# API Album Sync (Python Script) + +This is an example of a python script for syncing an album to a local folder. This was used for a digital photoframe so the displayed photos could be managed from the immich web or app UI. + +The script is copied below in it's current form. A repository is hosted [here](https://git.orenit.solutions/open/immichalbumpull). + +:::danger +This guide uses a generated API key. This key gives the same access to your immich instance as the user it is attached to, so be careful how the config file is stored and transferred. +::: + +### Prerequisites + +- Python 3.7+ +- [requests library](https://pypi.org/project/requests/) + +### Installing + +Copy the contents of 'pull.py' (shown below) to your chosen location or clone the repository: + +```bash +git clone https://git.orenit.solutions/open/immichalbumpull +``` + +Edit or create the 'config.ini' file in the same directory as the script with the necessary details: + +```ini title='config.ini' +[immich] +# URL of target immich instance +url = https://photo.example.com +# API key from Account Settings -> API Keys +apikey = xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +# Full local path to target directory +destination = /home/photo/photos +# immich album name +album = Photoframe +``` + +### Usage + +Run the script directly: + +```bash +./pull.py +``` + +Or from cron (every 5 minutes): + +```bash +*/5 * * * * /usr/bin/python /home/user/immichalbumpull/pull.py +``` + +### Python Script + +```python title='pull.py' +#!/usr/bin/env python + +import requests +import configparser +import os +import shutil + +# Read config file +config = configparser.ConfigParser() +config.read('config.ini') + +url = config['immich']['url'] +apikey = config['immich']['apikey'] +photodir = config['immich']['destination'] +albumname = config['immich']['album'] + +headers = { + 'Accept': 'application/json', + 'x-api-key': apikey +} + +# Set up the directory for the downloaded images +os.makedirs(photodir, exist_ok=True) + +# Get the list of albums from the API +response = requests.get(url + "/api/album", headers=headers) + +# Parse the JSON response +data = response.json() + +# Find the chosen album id +for item in data: + if item['albumName'] == albumname: + albumid = item['id'] + +# Get the list of photos from the API using the albumid +response = requests.get(url + "/api/album/" + albumid, headers=headers) + +# Parse the JSON response and extract the URLs of the images +data = response.json() +image_urls = data['assets'] + +# Download each image from the URL and save it to the directory +headers = { + 'Accept': 'application/octet-stream', + 'x-api-key': apikey +} + +photolist = [] + +for id in image_urls: + # Query asset info endpoint for correct extension + assetinfourl = url + "/api/asset/" + str(id['id']) + response = requests.get(assetinfourl, headers=headers) + assetinfo = response.json() + ext = os.path.splitext(assetinfo['originalFileName']) + + asseturl = url + "/api/download/asset/" + str(id['id']) + response = requests.post(asseturl, headers=headers, stream=True) + + # Build current photo list for deletions below + photo = os.path.basename(asseturl) + ext[1] + photolist.append(photo) + + photofullpath = photodir + '/' + os.path.basename(asseturl) + ext[1] + # Only download file if it doesn't already exist + if not os.path.exists(photofullpath): + with open(photofullpath, 'wb') as f: + for chunk in response.iter_content(1024): + f.write(chunk) + +# Delete old photos removed from album +for filename in os.listdir(photodir): + if filename not in photolist: + os.unlink(os.path.join(photodir, filename)) +``` From 6c8fad4cac2821516459e5a870c8e3e45151f16a Mon Sep 17 00:00:00 2001 From: Tejpal Sahota Date: Mon, 11 Mar 2024 13:02:55 -0400 Subject: [PATCH 34/35] docs: Update external-library.md (#7850) Although it is written clearly directly below, showing an example of what to change for both containers makes it very clear for people who just skim the code sections. --- docs/docs/guides/external-library.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/docs/guides/external-library.md b/docs/docs/guides/external-library.md index b1d4b67b2e046..ba60e8b118cb3 100644 --- a/docs/docs/guides/external-library.md +++ b/docs/docs/guides/external-library.md @@ -14,6 +14,12 @@ Edit `docker-compose.yml` to add two new mount points under `volumes:` - ${EXTERNAL_PATH}:/usr/src/app/external ``` +``` + immich-microservices: + volumes: + - ${EXTERNAL_PATH}:/usr/src/app/external +``` + Be sure to add exactly the same line to both `immich-server:` and `immich-microservices:`. Edit `.env` to define `EXTERNAL_PATH`, substituting in the correct path for your computer: From 078da36f2055b2cb8f0b38588b15c37a542d9941 Mon Sep 17 00:00:00 2001 From: Ben McCann <322311+benmccann@users.noreply.github.com> Date: Mon, 11 Mar 2024 10:06:04 -0700 Subject: [PATCH 35/35] fix(server): serve static directory only if it exists (#7857) * fix(server): serve static directory only if it exists * update * refactor: web root --------- Co-authored-by: Jason Rasmussen --- server/src/domain/domain.constant.ts | 3 +-- server/src/immich/app.service.ts | 5 +++-- server/src/immich/main.ts | 33 +++++++++++++++------------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index 0dc9c54140bac..b723474dd3d94 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -82,8 +82,7 @@ const { version } = JSON.parse(readFileSync('./package.json', 'utf8')); export const serverVersion = Version.fromString(version); export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; - -export const WEB_ROOT_PATH = join(process.env.IMMICH_WEB_ROOT || '/usr/src/app/www', 'index.html'); +export const WEB_ROOT = process.env.IMMICH_WEB_ROOT || '/usr/src/app/www'; const GEODATA_ROOT_PATH = process.env.IMMICH_REVERSE_GEOCODING_ROOT || '/usr/src/resources'; diff --git a/server/src/immich/app.service.ts b/server/src/immich/app.service.ts index f3369b12103fb..adfb9d8780238 100644 --- a/server/src/immich/app.service.ts +++ b/server/src/immich/app.service.ts @@ -8,13 +8,14 @@ import { SharedLinkService, StorageService, SystemConfigService, - WEB_ROOT_PATH, + WEB_ROOT, } from '@app/domain'; import { ImmichLogger } from '@app/infra/logger'; import { Injectable } from '@nestjs/common'; import { Cron, CronExpression, Interval } from '@nestjs/schedule'; import { NextFunction, Request, Response } from 'express'; import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; const render = (index: string, meta: OpenGraphTags) => { const tags = ` @@ -71,7 +72,7 @@ export class AppService { ssr(excludePaths: string[]) { let index = ''; try { - index = readFileSync(WEB_ROOT_PATH).toString(); + index = readFileSync(join(WEB_ROOT, 'index.html')).toString(); } catch { this.logger.warn('Unable to open `www/index.html, skipping SSR.'); } diff --git a/server/src/immich/main.ts b/server/src/immich/main.ts index 0039019b6266b..d2fe44f1f3ed0 100644 --- a/server/src/immich/main.ts +++ b/server/src/immich/main.ts @@ -1,10 +1,11 @@ -import { envName, isDev, serverVersion } from '@app/domain'; +import { WEB_ROOT, envName, isDev, serverVersion } from '@app/domain'; import { WebSocketAdapter } from '@app/infra'; import { ImmichLogger } from '@app/infra/logger'; import { NestFactory } from '@nestjs/core'; import { NestExpressApplication } from '@nestjs/platform-express'; import { json } from 'body-parser'; import cookieParser from 'cookie-parser'; +import { existsSync } from 'node:fs'; import sirv from 'sirv'; import { AppModule } from './app.module'; import { AppService } from './app.service'; @@ -29,20 +30,22 @@ export async function bootstrap() { const excludePaths = ['/.well-known/immich', '/custom.css']; app.setGlobalPrefix('api', { exclude: excludePaths }); - // copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46 - // provides serving of precompressed assets and caching of immutable assets - app.use( - sirv('www', { - etag: true, - gzip: true, - brotli: true, - setHeaders: (res, pathname) => { - if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) { - res.setHeader('cache-control', 'public,max-age=31536000,immutable'); - } - }, - }), - ); + if (existsSync(WEB_ROOT)) { + // copied from https://github.com/sveltejs/kit/blob/679b5989fe62e3964b9a73b712d7b41831aa1f07/packages/adapter-node/src/handler.js#L46 + // provides serving of precompressed assets and caching of immutable assets + app.use( + sirv(WEB_ROOT, { + etag: true, + gzip: true, + brotli: true, + setHeaders: (res, pathname) => { + if (pathname.startsWith(`/_app/immutable`) && res.statusCode === 200) { + res.setHeader('cache-control', 'public,max-age=31536000,immutable'); + } + }, + }), + ); + } app.use(app.get(AppService).ssr(excludePaths)); const server = await app.listen(port);