diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index ecc75dd945..7593ba1604 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -92,10 +92,12 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**getAlbumMapMarkers**](doc//AlbumsApi.md#getalbummapmarkers) | **GET** /albums/{id}/map-markers | Retrieve album map markers *AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | Retrieve album statistics *AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | List all albums +*AlbumsApi* | [**getOwnAlbumUser**](doc//AlbumsApi.md#getownalbumuser) | **GET** /albums/{id}/user/self | Get own sharing permissions *AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | Remove assets from an album *AlbumsApi* | [**removeUserFromAlbum**](doc//AlbumsApi.md#removeuserfromalbum) | **DELETE** /albums/{id}/user/{userId} | Remove user from album *AlbumsApi* | [**updateAlbumInfo**](doc//AlbumsApi.md#updatealbuminfo) | **PATCH** /albums/{id} | Update an album *AlbumsApi* | [**updateAlbumUser**](doc//AlbumsApi.md#updatealbumuser) | **PUT** /albums/{id}/user/{userId} | Update user role +*AlbumsApi* | [**updateOwnAlbumUser**](doc//AlbumsApi.md#updateownalbumuser) | **PUT** /albums/{id}/user/self | Update own sharing permissions *AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload *AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset *AssetsApi* | [**deleteAssetMetadata**](doc//AssetsApi.md#deleteassetmetadata) | **DELETE** /assets/{id}/metadata/{key} | Delete asset metadata by key @@ -452,7 +454,7 @@ Class | Method | HTTP request | Description - [MemoryStatisticsResponseDto](doc//MemoryStatisticsResponseDto.md) - [MemoryType](doc//MemoryType.md) - [MemoryUpdateDto](doc//MemoryUpdateDto.md) - - [MergePersonDto](doc//MergePersonDto.md) + - [MergeFaceClusterDto](doc//MergeFaceClusterDto.md) - [MetadataSearchDto](doc//MetadataSearchDto.md) - [MirrorAxis](doc//MirrorAxis.md) - [MirrorParameters](doc//MirrorParameters.md) @@ -544,6 +546,8 @@ Class | Method | HTTP request | Description - [SharedLinkType](doc//SharedLinkType.md) - [SharedLinksResponse](doc//SharedLinksResponse.md) - [SharedLinksUpdate](doc//SharedLinksUpdate.md) + - [SharingOptionsResponseDto](doc//SharingOptionsResponseDto.md) + - [SharingPermission](doc//SharingPermission.md) - [SignUpDto](doc//SignUpDto.md) - [SmartSearchDto](doc//SmartSearchDto.md) - [SourceType](doc//SourceType.md) @@ -643,6 +647,7 @@ Class | Method | HTTP request | Description - [UpdateAlbumUserDto](doc//UpdateAlbumUserDto.md) - [UpdateAssetDto](doc//UpdateAssetDto.md) - [UpdateLibraryDto](doc//UpdateLibraryDto.md) + - [UpdateSharingOptionsDto](doc//UpdateSharingOptionsDto.md) - [UsageByUserDto](doc//UsageByUserDto.md) - [UserAdminCreateDto](doc//UserAdminCreateDto.md) - [UserAdminDeleteDto](doc//UserAdminDeleteDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 1769c8af75..1441d7960e 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -198,7 +198,7 @@ part 'model/memory_search_order.dart'; part 'model/memory_statistics_response_dto.dart'; part 'model/memory_type.dart'; part 'model/memory_update_dto.dart'; -part 'model/merge_person_dto.dart'; +part 'model/merge_face_cluster_dto.dart'; part 'model/metadata_search_dto.dart'; part 'model/mirror_axis.dart'; part 'model/mirror_parameters.dart'; @@ -290,6 +290,8 @@ part 'model/shared_link_response_dto.dart'; part 'model/shared_link_type.dart'; part 'model/shared_links_response.dart'; part 'model/shared_links_update.dart'; +part 'model/sharing_options_response_dto.dart'; +part 'model/sharing_permission.dart'; part 'model/sign_up_dto.dart'; part 'model/smart_search_dto.dart'; part 'model/source_type.dart'; @@ -389,6 +391,7 @@ part 'model/update_album_dto.dart'; part 'model/update_album_user_dto.dart'; part 'model/update_asset_dto.dart'; part 'model/update_library_dto.dart'; +part 'model/update_sharing_options_dto.dart'; part 'model/usage_by_user_dto.dart'; part 'model/user_admin_create_dto.dart'; part 'model/user_admin_delete_dto.dart'; diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index e0fc383c1d..6038b248c1 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -580,6 +580,63 @@ class AlbumsApi { return null; } + /// Get own sharing permissions + /// + /// Get the own sharing permissions in a specific album. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getOwnAlbumUserWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/albums/{id}/user/self' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Get own sharing permissions + /// + /// Get the own sharing permissions in a specific album. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getOwnAlbumUser(String id,) async { + final response = await getOwnAlbumUserWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SharingOptionsResponseDto',) as SharingOptionsResponseDto; + + } + return null; + } + /// Remove assets from an album /// /// Remove multiple assets from a specific album by its ID. @@ -816,4 +873,57 @@ class AlbumsApi { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } } + + /// Update own sharing permissions + /// + /// Change the own sharing permissions in a specific album. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [UpdateSharingOptionsDto] updateSharingOptionsDto (required): + Future updateOwnAlbumUserWithHttpInfo(String id, UpdateSharingOptionsDto updateSharingOptionsDto,) async { + // ignore: prefer_const_declarations + final apiPath = r'/albums/{id}/user/self' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = updateSharingOptionsDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + apiPath, + 'PUT', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Update own sharing permissions + /// + /// Change the own sharing permissions in a specific album. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [UpdateSharingOptionsDto] updateSharingOptionsDto (required): + Future updateOwnAlbumUser(String id, UpdateSharingOptionsDto updateSharingOptionsDto,) async { + final response = await updateOwnAlbumUserWithHttpInfo(id, updateSharingOptionsDto,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } } diff --git a/mobile/openapi/lib/api/people_api.dart b/mobile/openapi/lib/api/people_api.dart index 99821f31aa..2b8969db64 100644 --- a/mobile/openapi/lib/api/people_api.dart +++ b/mobile/openapi/lib/api/people_api.dart @@ -448,14 +448,14 @@ class PeopleApi { /// /// * [String] id (required): /// - /// * [MergePersonDto] mergePersonDto (required): - Future mergePersonWithHttpInfo(String id, MergePersonDto mergePersonDto,) async { + /// * [MergeFaceClusterDto] mergeFaceClusterDto (required): + Future mergePersonWithHttpInfo(String id, MergeFaceClusterDto mergeFaceClusterDto,) async { // ignore: prefer_const_declarations final apiPath = r'/people/{id}/merge' .replaceAll('{id}', id); // ignore: prefer_final_locals - Object? postBody = mergePersonDto; + Object? postBody = mergeFaceClusterDto; final queryParams = []; final headerParams = {}; @@ -483,9 +483,9 @@ class PeopleApi { /// /// * [String] id (required): /// - /// * [MergePersonDto] mergePersonDto (required): - Future?> mergePerson(String id, MergePersonDto mergePersonDto,) async { - final response = await mergePersonWithHttpInfo(id, mergePersonDto,); + /// * [MergeFaceClusterDto] mergeFaceClusterDto (required): + Future?> mergePerson(String id, MergeFaceClusterDto mergeFaceClusterDto,) async { + final response = await mergePersonWithHttpInfo(id, mergeFaceClusterDto,); 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 103a5db5f4..a6f24cb6ea 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -442,8 +442,8 @@ class ApiClient { return MemoryTypeTypeTransformer().decode(value); case 'MemoryUpdateDto': return MemoryUpdateDto.fromJson(value); - case 'MergePersonDto': - return MergePersonDto.fromJson(value); + case 'MergeFaceClusterDto': + return MergeFaceClusterDto.fromJson(value); case 'MetadataSearchDto': return MetadataSearchDto.fromJson(value); case 'MirrorAxis': @@ -626,6 +626,10 @@ class ApiClient { return SharedLinksResponse.fromJson(value); case 'SharedLinksUpdate': return SharedLinksUpdate.fromJson(value); + case 'SharingOptionsResponseDto': + return SharingOptionsResponseDto.fromJson(value); + case 'SharingPermission': + return SharingPermissionTypeTransformer().decode(value); case 'SignUpDto': return SignUpDto.fromJson(value); case 'SmartSearchDto': @@ -824,6 +828,8 @@ class ApiClient { return UpdateAssetDto.fromJson(value); case 'UpdateLibraryDto': return UpdateLibraryDto.fromJson(value); + case 'UpdateSharingOptionsDto': + return UpdateSharingOptionsDto.fromJson(value); case 'UsageByUserDto': return UsageByUserDto.fromJson(value); case 'UserAdminCreateDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index b5d348edd6..4a561d56b0 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -163,6 +163,9 @@ String parameterToString(dynamic value) { if (value is SharedLinkType) { return SharedLinkTypeTypeTransformer().encode(value).toString(); } + if (value is SharingPermission) { + return SharingPermissionTypeTransformer().encode(value).toString(); + } if (value is SourceType) { return SourceTypeTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index eca87789ce..19fae49ea6 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -37,6 +37,7 @@ class AssetResponseDto { this.owner, required this.ownerId, this.people = const [], + this.permissions = const [], this.resized, this.stack, this.tags = const [], @@ -140,6 +141,8 @@ class AssetResponseDto { List people; + List permissions; + /// Is resized /// /// Please note: This property should have been non-nullable! Since the specification file @@ -195,6 +198,7 @@ class AssetResponseDto { other.owner == owner && other.ownerId == ownerId && _deepEquality.equals(other.people, people) && + _deepEquality.equals(other.permissions, permissions) && other.resized == resized && other.stack == stack && _deepEquality.equals(other.tags, tags) && @@ -231,6 +235,7 @@ class AssetResponseDto { (owner == null ? 0 : owner!.hashCode) + (ownerId.hashCode) + (people.hashCode) + + (permissions.hashCode) + (resized == null ? 0 : resized!.hashCode) + (stack == null ? 0 : stack!.hashCode) + (tags.hashCode) + @@ -241,7 +246,7 @@ class AssetResponseDto { (width == null ? 0 : width!.hashCode); @override - String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; + String toString() => 'AssetResponseDto[checksum=$checksum, createdAt=$createdAt, duplicateId=$duplicateId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, height=$height, id=$id, isArchived=$isArchived, isEdited=$isEdited, isFavorite=$isFavorite, isOffline=$isOffline, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalMimeType=$originalMimeType, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, permissions=$permissions, resized=$resized, stack=$stack, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt, visibility=$visibility, width=$width]'; Map toJson() { final json = {}; @@ -301,6 +306,7 @@ class AssetResponseDto { } json[r'ownerId'] = this.ownerId; json[r'people'] = this.people; + json[r'permissions'] = this.permissions; if (this.resized != null) { json[r'resized'] = this.resized; } else { @@ -361,6 +367,7 @@ class AssetResponseDto { owner: UserResponseDto.fromJson(json[r'owner']), ownerId: mapValueOfType(json, r'ownerId')!, people: PersonResponseDto.listFromJson(json[r'people']), + permissions: SharingPermission.listFromJson(json[r'permissions']), resized: mapValueOfType(json, r'resized'), stack: AssetStackResponseDto.fromJson(json[r'stack']), tags: TagResponseDto.listFromJson(json[r'tags']), @@ -433,6 +440,7 @@ class AssetResponseDto { 'originalFileName', 'originalPath', 'ownerId', + 'permissions', 'thumbhash', 'type', 'updatedAt', diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 444b080c12..8f7a236f9a 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -42,6 +42,7 @@ class JobName { static const databaseBackup = JobName._(r'DatabaseBackup'); static const facialRecognitionQueueAll = JobName._(r'FacialRecognitionQueueAll'); static const facialRecognition = JobName._(r'FacialRecognition'); + static const facialRecognitionMerge = JobName._(r'FacialRecognitionMerge'); static const fileDelete = JobName._(r'FileDelete'); static const fileMigrationQueueAll = JobName._(r'FileMigrationQueueAll'); static const libraryDeleteCheck = JobName._(r'LibraryDeleteCheck'); @@ -100,6 +101,7 @@ class JobName { databaseBackup, facialRecognitionQueueAll, facialRecognition, + facialRecognitionMerge, fileDelete, fileMigrationQueueAll, libraryDeleteCheck, @@ -193,6 +195,7 @@ class JobNameTypeTransformer { case r'DatabaseBackup': return JobName.databaseBackup; case r'FacialRecognitionQueueAll': return JobName.facialRecognitionQueueAll; case r'FacialRecognition': return JobName.facialRecognition; + case r'FacialRecognitionMerge': return JobName.facialRecognitionMerge; case r'FileDelete': return JobName.fileDelete; case r'FileMigrationQueueAll': return JobName.fileMigrationQueueAll; case r'LibraryDeleteCheck': return JobName.libraryDeleteCheck; diff --git a/mobile/openapi/lib/model/manual_job_name.dart b/mobile/openapi/lib/model/manual_job_name.dart index 27753eb9dc..89b1419154 100644 --- a/mobile/openapi/lib/model/manual_job_name.dart +++ b/mobile/openapi/lib/model/manual_job_name.dart @@ -29,6 +29,7 @@ class ManualJobName { static const memoryCleanup = ManualJobName._(r'memory-cleanup'); static const memoryCreate = ManualJobName._(r'memory-create'); static const backupDatabase = ManualJobName._(r'backup-database'); + static const personGroupMerge = ManualJobName._(r'person-group-merge'); /// List of all possible values in this [enum][ManualJobName]. static const values = [ @@ -38,6 +39,7 @@ class ManualJobName { memoryCleanup, memoryCreate, backupDatabase, + personGroupMerge, ]; static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value); @@ -82,6 +84,7 @@ class ManualJobNameTypeTransformer { case r'memory-cleanup': return ManualJobName.memoryCleanup; case r'memory-create': return ManualJobName.memoryCreate; case r'backup-database': return ManualJobName.backupDatabase; + case r'person-group-merge': return ManualJobName.personGroupMerge; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/merge_person_dto.dart b/mobile/openapi/lib/model/merge_face_cluster_dto.dart similarity index 61% rename from mobile/openapi/lib/model/merge_person_dto.dart rename to mobile/openapi/lib/model/merge_face_cluster_dto.dart index 8a647890c3..9e55fa44c2 100644 --- a/mobile/openapi/lib/model/merge_person_dto.dart +++ b/mobile/openapi/lib/model/merge_face_cluster_dto.dart @@ -10,17 +10,17 @@ part of openapi.api; -class MergePersonDto { - /// Returns a new [MergePersonDto] instance. - MergePersonDto({ +class MergeFaceClusterDto { + /// Returns a new [MergeFaceClusterDto] instance. + MergeFaceClusterDto({ this.ids = const [], }); - /// Person IDs to merge + /// Face cluster IDs to merge List ids; @override - bool operator ==(Object other) => identical(this, other) || other is MergePersonDto && + bool operator ==(Object other) => identical(this, other) || other is MergeFaceClusterDto && _deepEquality.equals(other.ids, ids); @override @@ -29,7 +29,7 @@ class MergePersonDto { (ids.hashCode); @override - String toString() => 'MergePersonDto[ids=$ids]'; + String toString() => 'MergeFaceClusterDto[ids=$ids]'; Map toJson() { final json = {}; @@ -37,15 +37,15 @@ class MergePersonDto { return json; } - /// Returns a new [MergePersonDto] instance and imports its values from + /// Returns a new [MergeFaceClusterDto] instance and imports its values from /// [value] if it's a [Map], null otherwise. // ignore: prefer_constructors_over_static_methods - static MergePersonDto? fromJson(dynamic value) { - upgradeDto(value, "MergePersonDto"); + static MergeFaceClusterDto? fromJson(dynamic value) { + upgradeDto(value, "MergeFaceClusterDto"); if (value is Map) { final json = value.cast(); - return MergePersonDto( + return MergeFaceClusterDto( ids: json[r'ids'] is Iterable ? (json[r'ids'] as Iterable).cast().toList(growable: false) : const [], @@ -54,11 +54,11 @@ class MergePersonDto { return null; } - static List listFromJson(dynamic json, {bool growable = false,}) { - final result = []; + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; if (json is List && json.isNotEmpty) { for (final row in json) { - final value = MergePersonDto.fromJson(row); + final value = MergeFaceClusterDto.fromJson(row); if (value != null) { result.add(value); } @@ -67,12 +67,12 @@ class MergePersonDto { return result.toList(growable: growable); } - static Map mapFromJson(dynamic json) { - final map = {}; + 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 = MergePersonDto.fromJson(entry.value); + final value = MergeFaceClusterDto.fromJson(entry.value); if (value != null) { map[entry.key] = value; } @@ -81,14 +81,14 @@ class MergePersonDto { return map; } - // maps a json object with a list of MergePersonDto-objects as value to a dart map - static Map> mapListFromJson(dynamic json, {bool growable = false,}) { - final map = >{}; + // maps a json object with a list of MergeFaceClusterDto-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] = MergePersonDto.listFromJson(entry.value, growable: growable,); + map[entry.key] = MergeFaceClusterDto.listFromJson(entry.value, growable: growable,); } } return map; diff --git a/mobile/openapi/lib/model/person_response_dto.dart b/mobile/openapi/lib/model/person_response_dto.dart index 455dfb98d6..8267494573 100644 --- a/mobile/openapi/lib/model/person_response_dto.dart +++ b/mobile/openapi/lib/model/person_response_dto.dart @@ -15,6 +15,7 @@ class PersonResponseDto { PersonResponseDto({ required this.birthDate, this.color, + required this.faceClusterId, required this.id, this.isFavorite, required this.isHidden, @@ -35,6 +36,9 @@ class PersonResponseDto { /// String? color; + /// Face cluster ID + String? faceClusterId; + /// Person ID String id; @@ -69,6 +73,7 @@ class PersonResponseDto { bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto && other.birthDate == birthDate && other.color == color && + other.faceClusterId == faceClusterId && other.id == id && other.isFavorite == isFavorite && other.isHidden == isHidden && @@ -81,6 +86,7 @@ class PersonResponseDto { // ignore: unnecessary_parenthesis (birthDate == null ? 0 : birthDate!.hashCode) + (color == null ? 0 : color!.hashCode) + + (faceClusterId == null ? 0 : faceClusterId!.hashCode) + (id.hashCode) + (isFavorite == null ? 0 : isFavorite!.hashCode) + (isHidden.hashCode) + @@ -89,7 +95,7 @@ class PersonResponseDto { (updatedAt == null ? 0 : updatedAt!.hashCode); @override - String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; + String toString() => 'PersonResponseDto[birthDate=$birthDate, color=$color, faceClusterId=$faceClusterId, id=$id, isFavorite=$isFavorite, isHidden=$isHidden, name=$name, thumbnailPath=$thumbnailPath, updatedAt=$updatedAt]'; Map toJson() { final json = {}; @@ -102,6 +108,11 @@ class PersonResponseDto { json[r'color'] = this.color; } else { // json[r'color'] = null; + } + if (this.faceClusterId != null) { + json[r'faceClusterId'] = this.faceClusterId; + } else { + // json[r'faceClusterId'] = null; } json[r'id'] = this.id; if (this.isFavorite != null) { @@ -131,6 +142,7 @@ class PersonResponseDto { return PersonResponseDto( birthDate: mapDateTime(json, r'birthDate', r''), color: mapValueOfType(json, r'color'), + faceClusterId: mapValueOfType(json, r'faceClusterId'), id: mapValueOfType(json, r'id')!, isFavorite: mapValueOfType(json, r'isFavorite'), isHidden: mapValueOfType(json, r'isHidden')!, @@ -185,6 +197,7 @@ class PersonResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'birthDate', + 'faceClusterId', 'id', 'isHidden', 'name', diff --git a/mobile/openapi/lib/model/sharing_options_response_dto.dart b/mobile/openapi/lib/model/sharing_options_response_dto.dart new file mode 100644 index 0000000000..a7b93bb71f --- /dev/null +++ b/mobile/openapi/lib/model/sharing_options_response_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SharingOptionsResponseDto { + /// Returns a new [SharingOptionsResponseDto] instance. + SharingOptionsResponseDto({ + required this.inTimeline, + this.permissions = const [], + }); + + bool inTimeline; + + List permissions; + + @override + bool operator ==(Object other) => identical(this, other) || other is SharingOptionsResponseDto && + other.inTimeline == inTimeline && + _deepEquality.equals(other.permissions, permissions); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (inTimeline.hashCode) + + (permissions.hashCode); + + @override + String toString() => 'SharingOptionsResponseDto[inTimeline=$inTimeline, permissions=$permissions]'; + + Map toJson() { + final json = {}; + json[r'inTimeline'] = this.inTimeline; + json[r'permissions'] = this.permissions; + return json; + } + + /// Returns a new [SharingOptionsResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SharingOptionsResponseDto? fromJson(dynamic value) { + upgradeDto(value, "SharingOptionsResponseDto"); + if (value is Map) { + final json = value.cast(); + + return SharingOptionsResponseDto( + inTimeline: mapValueOfType(json, r'inTimeline')!, + permissions: SharingPermission.listFromJson(json[r'permissions']), + ); + } + 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 = SharingOptionsResponseDto.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 = SharingOptionsResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SharingOptionsResponseDto-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] = SharingOptionsResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'inTimeline', + 'permissions', + }; +} + diff --git a/mobile/openapi/lib/model/sharing_permission.dart b/mobile/openapi/lib/model/sharing_permission.dart new file mode 100644 index 0000000000..6fee710056 --- /dev/null +++ b/mobile/openapi/lib/model/sharing_permission.dart @@ -0,0 +1,112 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +/// Sharing permission schema +class SharingPermission { + /// Instantiate a new enum with the provided [value]. + const SharingPermission._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const all = SharingPermission._(r'all'); + static const assetPeriodRead = SharingPermission._(r'asset.read'); + static const assetPeriodUpdate = SharingPermission._(r'asset.update'); + static const assetPeriodEdit = SharingPermission._(r'asset.edit'); + static const assetPeriodDelete = SharingPermission._(r'asset.delete'); + static const assetPeriodShare = SharingPermission._(r'asset.share'); + static const exifPeriodRead = SharingPermission._(r'exif.read'); + static const personPeriodRead = SharingPermission._(r'person.read'); + static const personPeriodUpdate = SharingPermission._(r'person.update'); + static const personPeriodMerge = SharingPermission._(r'person.merge'); + static const personPeriodDelete = SharingPermission._(r'person.delete'); + + /// List of all possible values in this [enum][SharingPermission]. + static const values = [ + all, + assetPeriodRead, + assetPeriodUpdate, + assetPeriodEdit, + assetPeriodDelete, + assetPeriodShare, + exifPeriodRead, + personPeriodRead, + personPeriodUpdate, + personPeriodMerge, + personPeriodDelete, + ]; + + static SharingPermission? fromJson(dynamic value) => SharingPermissionTypeTransformer().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 = SharingPermission.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SharingPermission] to String, +/// and [decode] dynamic data back to [SharingPermission]. +class SharingPermissionTypeTransformer { + factory SharingPermissionTypeTransformer() => _instance ??= const SharingPermissionTypeTransformer._(); + + const SharingPermissionTypeTransformer._(); + + String encode(SharingPermission data) => data.value; + + /// Decodes a [dynamic value][data] to a SharingPermission. + /// + /// 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. + SharingPermission? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'all': return SharingPermission.all; + case r'asset.read': return SharingPermission.assetPeriodRead; + case r'asset.update': return SharingPermission.assetPeriodUpdate; + case r'asset.edit': return SharingPermission.assetPeriodEdit; + case r'asset.delete': return SharingPermission.assetPeriodDelete; + case r'asset.share': return SharingPermission.assetPeriodShare; + case r'exif.read': return SharingPermission.exifPeriodRead; + case r'person.read': return SharingPermission.personPeriodRead; + case r'person.update': return SharingPermission.personPeriodUpdate; + case r'person.merge': return SharingPermission.personPeriodMerge; + case r'person.delete': return SharingPermission.personPeriodDelete; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SharingPermissionTypeTransformer] instance. + static SharingPermissionTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/sync_asset_face_v2.dart b/mobile/openapi/lib/model/sync_asset_face_v2.dart index aeefc2ece9..52a4183928 100644 --- a/mobile/openapi/lib/model/sync_asset_face_v2.dart +++ b/mobile/openapi/lib/model/sync_asset_face_v2.dart @@ -19,11 +19,11 @@ class SyncAssetFaceV2 { required this.boundingBoxY1, required this.boundingBoxY2, required this.deletedAt, + required this.faceClusterId, required this.id, required this.imageHeight, required this.imageWidth, required this.isVisible, - required this.personId, required this.sourceType, }); @@ -57,6 +57,9 @@ class SyncAssetFaceV2 { /// Face deleted at DateTime? deletedAt; + /// Person ID + String? faceClusterId; + /// Asset face ID String id; @@ -75,9 +78,6 @@ class SyncAssetFaceV2 { /// Is the face visible in the asset bool isVisible; - /// Person ID - String? personId; - /// Source type String sourceType; @@ -89,11 +89,11 @@ class SyncAssetFaceV2 { other.boundingBoxY1 == boundingBoxY1 && other.boundingBoxY2 == boundingBoxY2 && other.deletedAt == deletedAt && + other.faceClusterId == faceClusterId && other.id == id && other.imageHeight == imageHeight && other.imageWidth == imageWidth && other.isVisible == isVisible && - other.personId == personId && other.sourceType == sourceType; @override @@ -105,15 +105,15 @@ class SyncAssetFaceV2 { (boundingBoxY1.hashCode) + (boundingBoxY2.hashCode) + (deletedAt == null ? 0 : deletedAt!.hashCode) + + (faceClusterId == null ? 0 : faceClusterId!.hashCode) + (id.hashCode) + (imageHeight.hashCode) + (imageWidth.hashCode) + (isVisible.hashCode) + - (personId == null ? 0 : personId!.hashCode) + (sourceType.hashCode); @override - String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, personId=$personId, sourceType=$sourceType]'; + String toString() => 'SyncAssetFaceV2[assetId=$assetId, boundingBoxX1=$boundingBoxX1, boundingBoxX2=$boundingBoxX2, boundingBoxY1=$boundingBoxY1, boundingBoxY2=$boundingBoxY2, deletedAt=$deletedAt, faceClusterId=$faceClusterId, id=$id, imageHeight=$imageHeight, imageWidth=$imageWidth, isVisible=$isVisible, sourceType=$sourceType]'; Map toJson() { final json = {}; @@ -128,16 +128,16 @@ class SyncAssetFaceV2 { : this.deletedAt!.toUtc().toIso8601String(); } else { // json[r'deletedAt'] = null; + } + if (this.faceClusterId != null) { + json[r'faceClusterId'] = this.faceClusterId; + } else { + // json[r'faceClusterId'] = null; } json[r'id'] = this.id; json[r'imageHeight'] = this.imageHeight; json[r'imageWidth'] = this.imageWidth; json[r'isVisible'] = this.isVisible; - if (this.personId != null) { - json[r'personId'] = this.personId; - } else { - // json[r'personId'] = null; - } json[r'sourceType'] = this.sourceType; return json; } @@ -157,11 +157,11 @@ class SyncAssetFaceV2 { boundingBoxY1: mapValueOfType(json, r'boundingBoxY1')!, boundingBoxY2: mapValueOfType(json, r'boundingBoxY2')!, deletedAt: mapDateTime(json, r'deletedAt', r'/^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$/'), + faceClusterId: mapValueOfType(json, r'faceClusterId'), id: mapValueOfType(json, r'id')!, imageHeight: mapValueOfType(json, r'imageHeight')!, imageWidth: mapValueOfType(json, r'imageWidth')!, isVisible: mapValueOfType(json, r'isVisible')!, - personId: mapValueOfType(json, r'personId'), sourceType: mapValueOfType(json, r'sourceType')!, ); } @@ -216,11 +216,11 @@ class SyncAssetFaceV2 { 'boundingBoxY1', 'boundingBoxY2', 'deletedAt', + 'faceClusterId', 'id', 'imageHeight', 'imageWidth', 'isVisible', - 'personId', 'sourceType', }; } diff --git a/mobile/openapi/lib/model/update_sharing_options_dto.dart b/mobile/openapi/lib/model/update_sharing_options_dto.dart new file mode 100644 index 0000000000..bcdef62b2c --- /dev/null +++ b/mobile/openapi/lib/model/update_sharing_options_dto.dart @@ -0,0 +1,107 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class UpdateSharingOptionsDto { + /// Returns a new [UpdateSharingOptionsDto] instance. + UpdateSharingOptionsDto({ + required this.inTimeline, + this.permissions = const [], + }); + + bool inTimeline; + + List permissions; + + @override + bool operator ==(Object other) => identical(this, other) || other is UpdateSharingOptionsDto && + other.inTimeline == inTimeline && + _deepEquality.equals(other.permissions, permissions); + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (inTimeline.hashCode) + + (permissions.hashCode); + + @override + String toString() => 'UpdateSharingOptionsDto[inTimeline=$inTimeline, permissions=$permissions]'; + + Map toJson() { + final json = {}; + json[r'inTimeline'] = this.inTimeline; + json[r'permissions'] = this.permissions; + return json; + } + + /// Returns a new [UpdateSharingOptionsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static UpdateSharingOptionsDto? fromJson(dynamic value) { + upgradeDto(value, "UpdateSharingOptionsDto"); + if (value is Map) { + final json = value.cast(); + + return UpdateSharingOptionsDto( + inTimeline: mapValueOfType(json, r'inTimeline')!, + permissions: SharingPermission.listFromJson(json[r'permissions']), + ); + } + 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 = UpdateSharingOptionsDto.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 = UpdateSharingOptionsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of UpdateSharingOptionsDto-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] = UpdateSharingOptionsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'inTimeline', + 'permissions', + }; +} + diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index badf9ce25d..711fd9dd90 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2277,6 +2277,121 @@ "x-immich-permission": "album.read" } }, + "/albums/{id}/user/self": { + "get": { + "description": "Get the own sharing permissions in a specific album.", + "operationId": "getOwnAlbumUser", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharingOptionsResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Get own sharing permissions", + "tags": [ + "Albums" + ], + "x-immich-history": [ + { + "version": "v3", + "state": "Added" + }, + { + "version": "v3", + "state": "Stable" + } + ], + "x-immich-permission": "albumAsset.create", + "x-immich-state": "Stable" + }, + "put": { + "description": "Change the own sharing permissions in a specific album.", + "operationId": "updateOwnAlbumUser", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSharingOptionsDto" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Update own sharing permissions", + "tags": [ + "Albums" + ], + "x-immich-history": [ + { + "version": "v3", + "state": "Added" + }, + { + "version": "v3", + "state": "Stable" + } + ], + "x-immich-permission": "albumAsset.create", + "x-immich-state": "Stable" + } + }, "/albums/{id}/user/{userId}": { "delete": { "description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.", @@ -8345,7 +8460,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MergePersonDto" + "$ref": "#/components/schemas/MergeFaceClusterDto" } } }, @@ -16942,6 +17057,12 @@ }, "type": "array" }, + "permissions": { + "items": { + "$ref": "#/components/schemas/SharingPermission" + }, + "type": "array" + }, "resized": { "description": "Is resized", "type": "boolean", @@ -17013,6 +17134,7 @@ "originalFileName", "originalPath", "ownerId", + "permissions", "thumbhash", "type", "updatedAt", @@ -18072,6 +18194,7 @@ "DatabaseBackup", "FacialRecognitionQueueAll", "FacialRecognition", + "FacialRecognitionMerge", "FileDelete", "FileMigrationQueueAll", "LibraryDeleteCheck", @@ -18481,7 +18604,8 @@ "user-cleanup", "memory-cleanup", "memory-create", - "backup-database" + "backup-database", + "person-group-merge" ], "type": "string" }, @@ -18807,10 +18931,10 @@ }, "type": "object" }, - "MergePersonDto": { + "MergeFaceClusterDto": { "properties": { "ids": { - "description": "Person IDs to merge", + "description": "Face cluster IDs to merge", "items": { "format": "uuid", "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", @@ -19835,6 +19959,11 @@ ], "x-immich-state": "Stable" }, + "faceClusterId": { + "description": "Face cluster ID", + "nullable": true, + "type": "string" + }, "id": { "description": "Person ID", "type": "string" @@ -19885,6 +20014,7 @@ }, "required": [ "birthDate", + "faceClusterId", "id", "isHidden", "name", @@ -21797,6 +21927,41 @@ }, "type": "object" }, + "SharingOptionsResponseDto": { + "properties": { + "inTimeline": { + "type": "boolean" + }, + "permissions": { + "items": { + "$ref": "#/components/schemas/SharingPermission" + }, + "type": "array" + } + }, + "required": [ + "inTimeline", + "permissions" + ], + "type": "object" + }, + "SharingPermission": { + "description": "Sharing permission schema", + "enum": [ + "all", + "asset.read", + "asset.update", + "asset.edit", + "asset.delete", + "asset.share", + "exif.read", + "person.read", + "person.update", + "person.merge", + "person.delete" + ], + "type": "string" + }, "SignUpDto": { "properties": { "email": { @@ -22893,6 +23058,11 @@ "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "type": "string" }, + "faceClusterId": { + "description": "Person ID", + "nullable": true, + "type": "string" + }, "id": { "description": "Asset face ID", "type": "string" @@ -22913,11 +23083,6 @@ "description": "Is the face visible in the asset", "type": "boolean" }, - "personId": { - "description": "Person ID", - "nullable": true, - "type": "string" - }, "sourceType": { "description": "Source type", "type": "string" @@ -22930,11 +23095,11 @@ "boundingBoxY1", "boundingBoxY2", "deletedAt", + "faceClusterId", "id", "imageHeight", "imageWidth", "isVisible", - "personId", "sourceType" ], "type": "object" @@ -25426,6 +25591,24 @@ }, "type": "object" }, + "UpdateSharingOptionsDto": { + "properties": { + "inTimeline": { + "type": "boolean" + }, + "permissions": { + "items": { + "$ref": "#/components/schemas/SharingPermission" + }, + "type": "array" + } + }, + "required": [ + "inTimeline", + "permissions" + ], + "type": "object" + }, "UsageByUserDto": { "properties": { "photos": { diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index e82074d02c..7d8f5c4c41 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -555,6 +555,14 @@ export type MapMarkerResponseDto = { /** State/Province name */ state: string | null; }; +export type SharingOptionsResponseDto = { + inTimeline: boolean; + permissions: SharingPermission[]; +}; +export type UpdateSharingOptionsDto = { + inTimeline: boolean; + permissions: SharingPermission[]; +}; export type UpdateAlbumUserDto = { role: AlbumUserRole; }; @@ -792,6 +800,8 @@ export type PersonResponseDto = { birthDate: string | null; /** Person color (hex) */ color?: string; + /** Face cluster ID */ + faceClusterId: string | null; /** Person ID */ id: string; /** Is favorite */ @@ -875,6 +885,7 @@ export type AssetResponseDto = { /** Owner user ID */ ownerId: string; people?: PersonResponseDto[]; + permissions: SharingPermission[]; /** Is resized */ resized?: boolean; stack?: (AssetStackResponseDto) | null; @@ -1460,8 +1471,8 @@ export type PersonUpdateDto = { /** Person name */ name?: string; }; -export type MergePersonDto = { - /** Person IDs to merge */ +export type MergeFaceClusterDto = { + /** Face cluster IDs to merge */ ids: string[]; }; export type AssetFaceUpdateItem = { @@ -2922,6 +2933,8 @@ export type SyncAssetFaceV2 = { boundingBoxY2: number; /** Face deleted at */ deletedAt: string | null; + /** Person ID */ + faceClusterId: string | null; /** Asset face ID */ id: string; /** Image height */ @@ -2930,8 +2943,6 @@ export type SyncAssetFaceV2 = { imageWidth: number; /** Is the face visible in the asset */ isVisible: boolean; - /** Person ID */ - personId: string | null; /** Source type */ sourceType: string; }; @@ -3727,6 +3738,32 @@ export function getAlbumMapMarkers({ id, key, slug }: { ...opts })); } +/** + * Get own sharing permissions + */ +export function getOwnAlbumUser({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: SharingOptionsResponseDto; + }>(`/albums/${encodeURIComponent(id)}/user/self`, { + ...opts + })); +} +/** + * Update own sharing permissions + */ +export function updateOwnAlbumUser({ id, updateSharingOptionsDto }: { + id: string; + updateSharingOptionsDto: UpdateSharingOptionsDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/albums/${encodeURIComponent(id)}/user/self`, oazapfts.json({ + ...opts, + method: "PUT", + body: updateSharingOptionsDto + }))); +} /** * Remove user from album */ @@ -5131,9 +5168,9 @@ export function updatePerson({ id, personUpdateDto }: { /** * Merge people */ -export function mergePerson({ id, mergePersonDto }: { +export function mergePerson({ id, mergeFaceClusterDto }: { id: string; - mergePersonDto: MergePersonDto; + mergeFaceClusterDto: MergeFaceClusterDto; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -5141,7 +5178,7 @@ export function mergePerson({ id, mergePersonDto }: { }>(`/people/${encodeURIComponent(id)}/merge`, oazapfts.json({ ...opts, method: "POST", - body: mergePersonDto + body: mergeFaceClusterDto }))); } /** @@ -6788,6 +6825,19 @@ export enum BulkIdErrorReason { Unknown = "unknown", Validation = "validation" } +export enum SharingPermission { + All = "all", + AssetRead = "asset.read", + AssetUpdate = "asset.update", + AssetEdit = "asset.edit", + AssetDelete = "asset.delete", + AssetShare = "asset.share", + ExifRead = "exif.read", + PersonRead = "person.read", + PersonUpdate = "person.update", + PersonMerge = "person.merge", + PersonDelete = "person.delete" +} export enum Permission { All = "all", ActivityCreate = "activity.create", @@ -6995,7 +7045,8 @@ export enum ManualJobName { UserCleanup = "user-cleanup", MemoryCleanup = "memory-cleanup", MemoryCreate = "memory-create", - BackupDatabase = "backup-database" + BackupDatabase = "backup-database", + PersonGroupMerge = "person-group-merge" } export enum QueueName { ThumbnailGeneration = "thumbnailGeneration", @@ -7072,6 +7123,7 @@ export enum JobName { DatabaseBackup = "DatabaseBackup", FacialRecognitionQueueAll = "FacialRecognitionQueueAll", FacialRecognition = "FacialRecognition", + FacialRecognitionMerge = "FacialRecognitionMerge", FileDelete = "FileDelete", FileMigrationQueueAll = "FileMigrationQueueAll", LibraryDeleteCheck = "LibraryDeleteCheck", diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index 90a8fa5a25..d67230e261 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -11,6 +11,7 @@ import { GetAlbumsDto, UpdateAlbumDto, UpdateAlbumUserDto, + UpdateSharingPermissionsDto as UpdateSharingOptionsDto, } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; @@ -165,6 +166,33 @@ export class AlbumController { return this.service.addUsers(auth, id, dto); } + @Get(':id/user/self') + @Authenticated({ permission: Permission.AlbumAssetCreate }) + @Endpoint({ + summary: 'Get own sharing permissions', + description: 'Get the own sharing permissions in a specific album.', + history: new HistoryBuilder().added('v3').stable('v3'), + }) + getOwnAlbumUser(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { + return this.service.getSelf(auth, id); + } + + @Put(':id/user/self') + @Authenticated({ permission: Permission.AlbumAssetCreate }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Update own sharing permissions', + description: 'Change the own sharing permissions in a specific album.', + history: new HistoryBuilder().added('v3').stable('v3'), + }) + updateOwnAlbumUser( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: UpdateSharingOptionsDto, + ): Promise { + return this.service.updateSelf(auth, id, dto); + } + @Put(':id/user/:userId') @Authenticated({ permission: Permission.AlbumUserUpdate }) @HttpCode(HttpStatus.NO_CONTENT) diff --git a/server/src/controllers/person.controller.ts b/server/src/controllers/person.controller.ts index 5abd6eb1b4..066e545341 100644 --- a/server/src/controllers/person.controller.ts +++ b/server/src/controllers/person.controller.ts @@ -19,7 +19,7 @@ import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { AssetFaceUpdateDto, - MergePersonDto, + MergeFaceClusterDto, PeopleResponseDto, PeopleUpdateDto, PersonCreateDto, @@ -182,7 +182,7 @@ export class PersonController { mergePerson( @Auth() auth: AuthDto, @Param() { id }: UUIDParamDto, - @Body() dto: MergePersonDto, + @Body() dto: MergeFaceClusterDto, ): Promise { return this.service.mergePerson(auth, id, dto); } diff --git a/server/src/database.ts b/server/src/database.ts index 08f080e0ae..d1788aa553 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -9,6 +9,7 @@ import { MemoryType, Permission, SharedLinkType, + SharingPermission, SourceType, UserAvatarColor, UserStatus, @@ -209,6 +210,7 @@ export type Partner = { updatedAt: Date; updateId: string; inTimeline: boolean; + permissions: SharingPermission[]; }; export type Place = { @@ -252,6 +254,7 @@ export type Person = { faceAssetId: string | null; isHidden: boolean; thumbnailPath: string; + faceClusterId: string | null; }; export type AssetFace = { @@ -264,7 +267,7 @@ export type AssetFace = { boundingBoxY2: number; imageHeight: number; imageWidth: number; - personId: string | null; + faceClusterId: string | null; sourceType: SourceType; person?: ShallowDehydrateObject | null; updatedAt: Date; diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 095e399b96..4787201628 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -3,8 +3,8 @@ import { createZodDto } from 'nestjs-zod'; import { AlbumUser, AuthSharedLink } from 'src/database'; import { BulkIdErrorReasonSchema } from 'src/dtos/asset-ids.response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto'; -import { UserResponseSchema, mapUser } from 'src/dtos/user.dto'; -import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema } from 'src/enum'; +import { mapUser, UserResponseSchema } from 'src/dtos/user.dto'; +import { AlbumUserRole, AlbumUserRoleSchema, AssetOrder, AssetOrderSchema, SharingPermissionSchema } from 'src/enum'; import { MaybeDehydrated } from 'src/types'; import { asDateString } from 'src/utils/date'; import { stringToBool } from 'src/validation'; @@ -63,6 +63,14 @@ const UpdateAlbumSchema = z }) .meta({ id: 'UpdateAlbumDto' }); +const UpdateSharingOptionsSchema = z + .object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) }) + .meta({ id: 'UpdateSharingOptionsDto' }); + +const SharingOptionsResponseSchema = z + .object({ inTimeline: z.boolean(), permissions: z.array(SharingPermissionSchema) }) + .meta({ id: 'SharingOptionsResponseDto' }); + const GetAlbumsSchema = z .object({ isOwned: stringToBool @@ -147,6 +155,8 @@ export class UpdateAlbumDto extends createZodDto(UpdateAlbumSchema) {} export class GetAlbumsDto extends createZodDto(GetAlbumsSchema) {} export class AlbumStatisticsResponseDto extends createZodDto(AlbumStatisticsResponseSchema) {} export class UpdateAlbumUserDto extends createZodDto(UpdateAlbumUserSchema) {} +export class UpdateSharingPermissionsDto extends createZodDto(UpdateSharingOptionsSchema) {} +export class SharingPermissionsResponseDto extends createZodDto(SharingOptionsResponseSchema) {} export class AlbumResponseDto extends createZodDto(AlbumResponseSchema) {} class AlbumUserResponseDto extends createZodDto(AlbumUserResponseSchema) {} diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index 6d72fd971a..7cc5d08d4f 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -15,6 +15,8 @@ import { AssetVisibility, AssetVisibilitySchema, ChecksumAlgorithm, + SharingPermission, + SharingPermissionSchema, } from 'src/enum'; import { MaybeDehydrated } from 'src/types'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; @@ -45,6 +47,7 @@ const SanitizedAssetResponseSchema = z hasMetadata: z.boolean().describe('Whether asset has metadata'), width: z.int().min(0).nullable().describe('Asset width'), height: z.int().min(0).nullable().describe('Asset height'), + permissions: z.array(SharingPermissionSchema), }) .meta({ id: 'SanitizedAssetResponseDto' }); @@ -113,6 +116,7 @@ export const AssetResponseSchema = SanitizedAssetResponseSchema.extend( .boolean() .describe('Is edited') .meta(new HistoryBuilder().added('v2.5.0').beta('v2.5.0').getExtensions()), + permissions: z.array(SharingPermissionSchema), }).shape, ).meta({ id: 'AssetResponseDto' }); @@ -154,6 +158,7 @@ export type MapAsset = { width: number | null; height: number | null; isEdited: boolean; + permissions?: { permission: SharingPermission }[]; }; export type AssetMapOptions = { @@ -192,8 +197,16 @@ const mapStack = (entity: { stack?: Stack | null }) => { export function mapAsset(entity: MaybeDehydrated, options: AssetMapOptions = {}): AssetResponseDto { const { stripMetadata = false, withStack = false } = options; + const permissions = + options.auth?.user.id === entity.ownerId + ? [SharingPermission.All] + : (entity.permissions?.map(({ permission }) => permission) ?? []); - if (stripMetadata) { + if ( + stripMetadata || + (entity.permissions && + !(permissions.includes(SharingPermission.All) || permissions.includes(SharingPermission.ExifRead))) + ) { const sanitizedAssetResponse: SanitizedAssetResponseDto = { id: entity.id, type: entity.type, @@ -205,6 +218,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt hasMetadata: false, width: entity.width, height: entity.height, + permissions, }; return sanitizedAssetResponse as AssetResponseDto; } @@ -242,5 +256,6 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt width: entity.width, height: entity.height, isEdited: entity.isEdited, + permissions, }; } diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index f39cfd1c88..927e8ea11e 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -2,7 +2,6 @@ import { Selectable } from 'kysely'; import { createZodDto } from 'nestjs-zod'; import { AssetFace, Person } from 'src/database'; import { HistoryBuilder } from 'src/decorators'; -import { AuthDto } from 'src/dtos/auth.dto'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; import { SourceTypeSchema } from 'src/enum'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; @@ -40,11 +39,11 @@ const PeopleUpdateSchema = z }) .meta({ id: 'PeopleUpdateDto' }); -const MergePersonSchema = z +const MergeFaceClusterSchema = z .object({ - ids: z.array(z.uuidv4()).describe('Person IDs to merge'), + ids: z.array(z.uuidv4()).describe('Face cluster IDs to merge'), }) - .meta({ id: 'MergePersonDto' }); + .meta({ id: 'MergeFaceClusterDto' }); const PersonSearchSchema = z .object({ @@ -81,13 +80,14 @@ export const PersonResponseSchema = z .optional() .describe('Person color (hex)') .meta(new HistoryBuilder().added('v1.126.0').stable('v2').getExtensions()), + faceClusterId: z.string().nullable().describe('Face cluster ID'), }) .meta({ id: 'PersonResponseDto' }); export class PersonCreateDto extends createZodDto(PersonCreateSchema) {} export class PersonUpdateDto extends createZodDto(PersonUpdateSchema) {} export class PeopleUpdateDto extends createZodDto(PeopleUpdateSchema) {} -export class MergePersonDto extends createZodDto(MergePersonSchema) {} +export class MergeFaceClusterDto extends createZodDto(MergeFaceClusterSchema) {} export class PersonSearchDto extends createZodDto(PersonSearchSchema) {} export class PersonResponseDto extends createZodDto(PersonResponseSchema) {} @@ -179,6 +179,7 @@ export function mapPerson(person: MaybeDehydrated): PersonResponseDto { isFavorite: person.isFavorite, color: person.color ?? undefined, updatedAt: asDateString(person.updatedAt), + faceClusterId: person.faceClusterId, }; } @@ -207,12 +208,11 @@ function mapFacesWithoutPerson( export function mapFaces( face: AssetFace, - auth: AuthDto, edits?: AssetEditActionItem[], assetDimensions?: ImageDimensions, ): AssetFaceResponseDto { return { ...mapFacesWithoutPerson(face, edits, assetDimensions), - person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null, + person: face.person ? mapPerson(face.person) : null, }; } diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 35ef874dfa..d8a7165fd3 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -374,10 +374,13 @@ const SyncAssetFaceV1Schema = z }) .meta({ id: 'SyncAssetFaceV1' }); -const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.extend({ - deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'), - isVisible: z.boolean().describe('Is the face visible in the asset'), -}).meta({ id: 'SyncAssetFaceV2' }); +const SyncAssetFaceV2Schema = SyncAssetFaceV1Schema.omit({ personId: true }) + .extend({ + deletedAt: isoDatetimeToDate.nullable().describe('Face deleted at'), + isVisible: z.boolean().describe('Is the face visible in the asset'), + faceClusterId: z.string().nullable().describe('Person ID'), + }) + .meta({ id: 'SyncAssetFaceV2' }); const SyncAssetFaceDeleteV1Schema = z .object({ assetFaceId: z.string().describe('Asset face ID') }) diff --git a/server/src/enum.ts b/server/src/enum.ts index bc52e65f83..9214560a9a 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -306,6 +306,28 @@ export enum Permission { AdminAuthUnlinkAll = 'adminAuth.unlinkAll', } +export enum SharingPermission { + All = 'all', + + AssetRead = 'asset.read', + AssetUpdate = 'asset.update', + AssetEdit = 'asset.edit', + AssetDelete = 'asset.delete', + AssetShare = 'asset.share', + + ExifRead = 'exif.read', + + PersonRead = 'person.read', + PersonUpdate = 'person.update', + PersonMerge = 'person.merge', + PersonDelete = 'person.delete', +} + +export const SharingPermissionSchema = z + .enum(SharingPermission) + .describe('Sharing permission schema') + .meta({ id: 'SharingPermission' }); + export enum SharedLinkType { Album = 'ALBUM', @@ -404,6 +426,7 @@ export enum ManualJobName { MemoryCleanup = 'memory-cleanup', MemoryCreate = 'memory-create', BackupDatabase = 'backup-database', + PersonGroupMerge = 'person-group-merge', } export const ManualJobNameSchema = z.enum(ManualJobName).describe('Manual job name').meta({ id: 'ManualJobName' }); @@ -813,6 +836,7 @@ export enum JobName { FacialRecognitionQueueAll = 'FacialRecognitionQueueAll', FacialRecognition = 'FacialRecognition', + FacialRecognitionMerge = 'FacialRecognitionMerge', FileDelete = 'FileDelete', FileMigrationQueueAll = 'FileMigrationQueueAll', diff --git a/server/src/queries/access.repository.sql b/server/src/queries/access.repository.sql index 94d3b4d003..f6d72b74c0 100644 --- a/server/src/queries/access.repository.sql +++ b/server/src/queries/access.repository.sql @@ -149,6 +149,40 @@ where "albumAssets"."livePhotoVideoId" ] && array[$2]::uuid[] +-- AccessRepository.asset.checkSharedAccess +select + "album_asset"."assetId" +from + "album_asset" + inner join "album_user" on "album_asset"."albumId" = "album_user"."albumId" + and "album_user"."userId" = $1 +where + "album_asset"."assetId" in ($2) + and "album_asset"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + ( + "album_user"."permissions" @> $3::sharing_permission_enum[] + or $4 = any ("album_user"."permissions") + ) + ) +union +select + "asset"."id" as "assetId" +from + "partner" + inner join "asset" on "asset"."ownerId" = "partner"."sharedById" + and "asset"."id" in ($5) +where + "partner"."sharedWithId" = $6 + and ( + "partner"."permissions" @> $7::sharing_permission_enum[] + or $8 = any ("partner"."permissions") + ) + -- AccessRepository.authDevice.checkOwnerAccess select "session"."id" diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index 4d90bbf0d5..d9ff2f75d6 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -182,18 +182,25 @@ select from ( select - "asset_face".*, - "person" as "person" + ( + select + to_json(obj) + from + ( + select + "person".* + from + "face_cluster" + inner join "person" on "person"."faceClusterId" = "face_cluster"."id" + where + "face_cluster"."id" = "asset_face"."faceClusterId" + limit + $1 + ) as obj + ) as "person", + "asset_face".* from "asset_face" - left join lateral ( - select - "person".* - from - "person" - where - "asset_face"."personId" = "person"."id" - ) as "person" on true where "asset_face"."assetId" = "asset"."id" and "asset_face"."deletedAt" is null @@ -224,7 +231,7 @@ from "asset" left join "asset_exif" on "asset"."id" = "asset_exif"."assetId" where - "asset"."id" = any ($1::uuid[]) + "asset"."id" = any ($2::uuid[]) -- AssetRepository.deleteAll delete from "asset" @@ -290,13 +297,44 @@ limit -- AssetRepository.getById select - "asset".* + "asset".*, + ( + select + coalesce(json_agg(agg), '[]') + from + ( + select distinct + unnest("album_user"."permissions") as "permission" + from + "album_user" + inner join "album_asset" on "album_user"."albumId" = "album_asset"."albumId" + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."userId" = "asset"."ownerId" + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = $1 + ) + union + select distinct + unnest("partner"."permissions") as "permission" + from + "partner" + where + "partner"."sharedById" = "asset"."ownerId" + and "partner"."sharedWithId" = $2 + ) as agg + ) as "permissions" from "asset" where - "asset"."id" = $1::uuid + "asset"."id" = $3::uuid limit - $2 + $4 -- AssetRepository.updateAll update "asset" diff --git a/server/src/queries/memory.repository.sql b/server/src/queries/memory.repository.sql index 44339cbcd9..8315788df2 100644 --- a/server/src/queries/memory.repository.sql +++ b/server/src/queries/memory.repository.sql @@ -47,7 +47,7 @@ select $1 as "one" from "asset_face" - inner join "person" on "person"."id" = "asset_face"."personId" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" where "asset_face"."assetId" = "asset"."id" and "person"."isHidden" = $2 @@ -86,7 +86,7 @@ select $1 as "one" from "asset_face" - inner join "person" on "person"."id" = "asset_face"."personId" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" where "asset_face"."assetId" = "asset"."id" and "person"."isHidden" = $2 diff --git a/server/src/queries/person.repository.sql b/server/src/queries/person.repository.sql index 318c151cca..f7be93e520 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -3,9 +3,6 @@ -- PersonRepository.reassignFaces update "asset_face" set - "personId" = $1 -where - "asset_face"."personId" = $2 -- PersonRepository.delete delete from "person" @@ -24,27 +21,64 @@ limit 3 -- PersonRepository.getAllForUser -select - "person".* +select distinct + on ("person"."faceClusterId") "person".* from "person" - inner join "asset_face" on "asset_face"."personId" = "person"."id" + inner join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId" inner join "asset" on "asset_face"."assetId" = "asset"."id" and "asset"."visibility" = 'timeline' and "asset"."deletedAt" is null where - "person"."ownerId" = $1 + ( + "person"."ownerId" = $1 + or ( + exists ( + select + from + "partner" + where + "partner"."sharedById" = "person"."ownerId" + and "partner"."sharedWithId" = $2 + and ( + $3 = any ("partner"."permissions") + or "partner"."permissions" @> $4 + ) + ) + or exists ( + select + from + "album_user" + where + "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = $5 + ) + and "album_user"."userId" = "person"."ownerId" + and ( + $6 = any ("album_user"."permissions") + or "album_user"."permissions" @> $7 + ) + ) + ) + ) and "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "person"."isHidden" = $2 + and "person"."isHidden" = $8 group by "person"."id" having ( - "person"."name" != $3 - or count("asset_face"."assetId") >= $4 + "person"."name" != $9 + or count("asset_face"."assetId") >= $10 ) order by + "person"."faceClusterId", + "person"."ownerId" = $11 desc, "person"."isHidden" asc, "person"."isFavorite" desc, NULLIF(person.name, '') is null asc, @@ -52,16 +86,16 @@ order by NULLIF(person.name, '') asc nulls last, "person"."createdAt" limit - $5 + $12 offset - $6 + $13 -- PersonRepository.getAllWithoutFaces select "person".* from "person" - left join "asset_face" on "asset_face"."personId" = "person"."id" + left join "asset_face" on "asset_face"."faceClusterId" = "person"."faceClusterId" where "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true @@ -83,15 +117,26 @@ select from "person" where - "person"."id" = "asset_face"."personId" + "person"."faceClusterId" = "asset_face"."faceClusterId" + order by + "person"."ownerId" = ( + select + "asset"."ownerId" + from + "asset" + where + "asset"."id" = "asset_face"."assetId" + ) desc + limit + $1 ) as obj ) as "person" from "asset_face" where - "asset_face"."assetId" = $1 + "asset_face"."assetId" = $2 and "asset_face"."deletedAt" is null - and "asset_face"."isVisible" = $2 + and "asset_face"."isVisible" = $3 order by "asset_face"."boundingBoxX1" asc @@ -108,19 +153,30 @@ select from "person" where - "person"."id" = "asset_face"."personId" + "person"."faceClusterId" = "asset_face"."faceClusterId" + order by + "person"."ownerId" = ( + select + "asset"."ownerId" + from + "asset" + where + "asset"."id" = "asset_face"."assetId" + ) desc + limit + $1 ) as obj ) as "person" from "asset_face" where - "asset_face"."id" = $1 + "asset_face"."id" = $2 and "asset_face"."deletedAt" is null -- PersonRepository.getFaceForFacialRecognitionJob select "asset_face"."id", - "asset_face"."personId", + "asset_face"."faceClusterId", "asset_face"."sourceType", ( select @@ -190,7 +246,7 @@ where -- PersonRepository.reassignFace update "asset_face" set - "personId" = $1 + "faceClusterId" = $1 where "asset_face"."id" = $2 @@ -209,9 +265,10 @@ where "person"."ownerId" = $1 and f_unaccent ("person"."name") %> f_unaccent ($2) order by - f_unaccent ("person"."name") <->>> f_unaccent ($3) + f_unaccent ("person"."name") <->>> f_unaccent ($3), + "person"."ownerId" = $4 desc limit - $4 + $5 -- PersonRepository.getDistinctNames select distinct @@ -234,9 +291,52 @@ from and "asset"."visibility" = 'timeline' and "asset"."deletedAt" is null where - "asset_face"."deletedAt" is null + ( + "asset"."ownerId" = $1 + or exists ( + select + from + "partner" + where + "partner"."sharedById" = "asset"."ownerId" + and "partner"."sharedWithId" = $2 + and ( + $3 = any ("partner"."permissions") + or "partner"."permissions" @> $4 + ) + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $5 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and ( + $6 = any ("album_user"."permissions") + or "album_user"."permissions" @> $7 + ) + ) + ) + ) + and "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "asset_face"."personId" = $1 + and "asset_face"."faceClusterId" = ( + select + "person"."faceClusterId" + from + "person" + where + "person"."id" = $8 + ) -- PersonRepository.getNumberOfPeople select @@ -256,7 +356,7 @@ where from "asset_face" where - "asset_face"."personId" = "person"."id" + "asset_face"."faceClusterId" = "person"."faceClusterId" and "asset_face"."deletedAt" is null and "asset_face"."isVisible" = $2 and exists ( @@ -269,7 +369,42 @@ where and "asset"."deletedAt" is null ) ) - and "person"."ownerId" = $3 + and ( + "person"."ownerId" = $3 + or ( + exists ( + select + from + "partner" + where + "partner"."sharedById" = "person"."ownerId" + and "partner"."sharedWithId" = $4 + and ( + $5 = any ("partner"."permissions") + or "partner"."permissions" @> $6 + ) + ) + or exists ( + select + from + "album_user" + where + "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = $7 + ) + and "album_user"."userId" = "person"."ownerId" + and ( + $8 = any ("album_user"."permissions") + or "album_user"."permissions" @> $9 + ) + ) + ) + ) -- PersonRepository.refreshFaces with @@ -299,14 +434,26 @@ select from "person" where - "person"."id" = "asset_face"."personId" + "person"."faceClusterId" = "asset_face"."faceClusterId" + order by + "person"."ownerId" = ( + select + "asset"."ownerId" + from + "asset" + where + "asset"."id" = "asset_face"."assetId" + ) desc + limit + $1 ) as obj ) as "person" from "asset_face" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" where - "asset_face"."assetId" in ($1) - and "asset_face"."personId" in ($2) + "person"."id" in ($2) + and "asset_face"."assetId" in ($3) and "asset_face"."deletedAt" is null -- PersonRepository.getRandomFace @@ -315,7 +462,7 @@ select from "asset_face" where - "asset_face"."personId" = $1 + "asset_face"."faceClusterId" = $1 and "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true @@ -351,8 +498,9 @@ select "asset_face"."id" from "asset_face" + inner join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" + and "person"."id" = $1 inner join "asset" on "asset"."id" = "asset_face"."assetId" - and "asset"."isOffline" = $1 + and "asset"."isOffline" = $2 where - "asset_face"."assetId" = $2 - and "asset_face"."personId" = $3 + "asset_face"."assetId" = $3 diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 3e75d88af8..3be942dbb2 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -10,15 +10,52 @@ where "asset"."visibility" = $1 and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) - and "asset"."isFavorite" = $5 + and ( + "asset"."ownerId" = $4 + or exists ( + select + from + "partner" + where + "partner"."sharedById" = "asset"."ownerId" + and "partner"."sharedWithId" = $5 + and ( + $6 = any ("partner"."permissions") + or "partner"."permissions" @> $7 + ) + and "partner"."inTimeline" = $8 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $9 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $10 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and ( + $11 = any ("album_user"."permissions") + or "album_user"."permissions" @> $12 + ) + ) + ) + ) + and "asset"."isFavorite" = $13 and "asset"."deletedAt" is null order by "asset"."fileCreatedAt" desc limit - $6 + $14 offset - $7 + $15 -- SearchRepository.searchStatistics select @@ -30,8 +67,45 @@ where "asset"."visibility" = $1 and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) - and "asset"."isFavorite" = $5 + and ( + "asset"."ownerId" = $4 + or exists ( + select + from + "partner" + where + "partner"."sharedById" = "asset"."ownerId" + and "partner"."sharedWithId" = $5 + and ( + $6 = any ("partner"."permissions") + or "partner"."permissions" @> $7 + ) + and "partner"."inTimeline" = $8 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $9 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $10 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and ( + $11 = any ("album_user"."permissions") + or "album_user"."permissions" @> $12 + ) + ) + ) + ) + and "asset"."isFavorite" = $13 and "asset"."deletedAt" is null -- SearchRepository.searchRandom @@ -44,13 +118,50 @@ where "asset"."visibility" = $1 and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) - and "asset"."isFavorite" = $5 + and ( + "asset"."ownerId" = $4 + or exists ( + select + from + "partner" + where + "partner"."sharedById" = "asset"."ownerId" + and "partner"."sharedWithId" = $5 + and ( + $6 = any ("partner"."permissions") + or "partner"."permissions" @> $7 + ) + and "partner"."inTimeline" = $8 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $9 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $10 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and ( + $11 = any ("album_user"."permissions") + or "album_user"."permissions" @> $12 + ) + ) + ) + ) + and "asset"."isFavorite" = $13 and "asset"."deletedAt" is null order by random() limit - $6 + $14 -- SearchRepository.searchLargeAssets select @@ -63,14 +174,51 @@ where "asset"."visibility" = $1 and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) - and "asset"."isFavorite" = $5 + and ( + "asset"."ownerId" = $4 + or exists ( + select + from + "partner" + where + "partner"."sharedById" = "asset"."ownerId" + and "partner"."sharedWithId" = $5 + and ( + $6 = any ("partner"."permissions") + or "partner"."permissions" @> $7 + ) + and "partner"."inTimeline" = $8 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $9 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $10 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and ( + $11 = any ("album_user"."permissions") + or "album_user"."permissions" @> $12 + ) + ) + ) + ) + and "asset"."isFavorite" = $13 and "asset"."deletedAt" is null - and "asset_exif"."fileSizeInByte" > $6 + and "asset_exif"."fileSizeInByte" > $14 order by "asset_exif"."fileSizeInByte" desc limit - $7 + $15 -- SearchRepository.searchSmart begin @@ -86,15 +234,52 @@ where "asset"."visibility" = $1 and "asset"."fileCreatedAt" >= $2 and "asset_exif"."lensModel" = $3 - and "asset"."ownerId" = any ($4::uuid[]) - and "asset"."isFavorite" = $5 + and ( + "asset"."ownerId" = $4 + or exists ( + select + from + "partner" + where + "partner"."sharedById" = "asset"."ownerId" + and "partner"."sharedWithId" = $5 + and ( + $6 = any ("partner"."permissions") + or "partner"."permissions" @> $7 + ) + and "partner"."inTimeline" = $8 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $9 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $10 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and ( + $11 = any ("album_user"."permissions") + or "album_user"."permissions" @> $12 + ) + ) + ) + ) + and "asset"."isFavorite" = $13 and "asset"."deletedAt" is null order by - smart_search.embedding <=> $6 + smart_search.embedding <=> $14 limit - $7 + $15 offset - $8 + $16 commit -- SearchRepository.getEmbedding @@ -113,15 +298,30 @@ with "cte" as ( select "asset_face"."id", - "asset_face"."personId", - face_search.embedding <=> $1 as "distance" + "asset_face"."faceClusterId", + face_search.embedding <=> $1 as "distance", + "asset"."ownerId" from "asset_face" inner join "asset" on "asset"."id" = "asset_face"."assetId" inner join "face_search" on "face_search"."faceId" = "asset_face"."id" - left join "person" on "person"."id" = "asset_face"."personId" + left join "person" on "person"."faceClusterId" = "asset_face"."faceClusterId" where - "asset"."ownerId" = any ($2::uuid[]) + "asset"."ownerId" in ( + select + "user"."id" + from + "user" + where + "user"."trustedGroupId" in ( + select + "user"."trustedGroupId" + from + "user" + where + "user"."id" = any ($2::uuid[]) + ) + ) and "asset"."deletedAt" is null order by "distance" diff --git a/server/src/queries/sync.repository.sql b/server/src/queries/sync.repository.sql index 1404a24ba7..ad23054206 100644 --- a/server/src/queries/sync.repository.sql +++ b/server/src/queries/sync.repository.sql @@ -527,7 +527,7 @@ order by select "asset_face"."id", "assetId", - "personId", + "faceClusterId", "imageWidth", "imageHeight", "boundingBoxX1", diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index c5a4f139a7..f384e23dd8 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -397,3 +397,73 @@ set where "user"."deletedAt" is null and "user"."id" = $2::uuid + +-- UserRepository.getInSameTrustedGroup +select + "user"."id" +from + "user" +where + "user"."trustedGroupId" = ( + select + "user"."trustedGroupId" + from + "user" + where + "user"."id" = $1 + ) + +-- UserRepository.mergeTrustedGroups +update "user" +set + "trustedGroupId" = "u"."trustedGroupId" +from + "user" as "u" +where + "u"."id" = $1 + and "user"."trustedGroupId" = ( + select + "user"."trustedGroupId" + from + "user" + where + "user"."id" = $2 + and "user"."trustedGroupId" != "u"."trustedGroupId" + ) + +-- UserRepository.updateTrustedGroups +update "user" +set + "trustedGroupId" = uuid_generate_v4 () +where + "user"."trustedGroupId" = ( + select + "user"."trustedGroupId" + from + "user" + where + "user"."id" = $1 + ) + and "user"."id" != $2 + and "user"."id" not in ( + select + "partner"."sharedById" as "userId" + from + "partner" + where + "sharedWithId" = $3 + union + select + "album_user"."userId" + from + "album_user" + where + "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = $4 + ) + ) diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 5752b70863..842e596a2e 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -2,7 +2,9 @@ import { Injectable } from '@nestjs/common'; import { Kysely, NotNull, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { ChunkedSet, DummyValue, GenerateSql } from 'src/decorators'; -import { AlbumUserRole, AssetVisibility } from 'src/enum'; +import { AlbumUserRole, AssetVisibility, SharingPermission } from 'src/enum'; +import { hasAssetPermissions } from 'src/repositories/asset.repository'; +import { hasPermissions } from 'src/repositories/person.repository'; import { DB } from 'src/schema'; import { asUuid } from 'src/utils/database'; @@ -273,6 +275,46 @@ class AssetAccess { return allowedIds; }); } + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET, [SharingPermission.All]] }) + async checkSharedAccess(userId: string, assetIds: Set, permissions: SharingPermission[]) { + const ids = await this.db + .selectFrom('album_asset') + .select('album_asset.assetId') + .where('album_asset.assetId', 'in', [...assetIds]) + .where('album_asset.albumId', 'in', (eb) => + eb + .selectFrom('album_user') + .select('album_user.albumId') + .where((eb) => + eb.or([ + eb('album_user.permissions', '@>', sql`${permissions}::sharing_permission_enum[]`), + eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')), + ]), + ), + ) + .innerJoin('album_user', (join) => + join.onRef('album_asset.albumId', '=', 'album_user.albumId').on('album_user.userId', '=', userId), + ) + .union((eb) => + eb + .selectFrom('partner') + .where('partner.sharedWithId', '=', userId) + .where((eb) => + eb.or([ + eb('partner.permissions', '@>', sql`${permissions}::sharing_permission_enum[]`), + eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')), + ]), + ) + .innerJoin('asset', (join) => + join.onRef('asset.ownerId', '=', 'partner.sharedById').on('asset.id', 'in', [...assetIds]), + ) + .select('asset.id as assetId'), + ) + .execute(); + + return new Set(ids.map(({ assetId }) => assetId)); + } } class AuthDeviceAccess { @@ -452,6 +494,37 @@ class PersonAccess { .execute() .then((faces) => new Set(faces.map((face) => face.id))); } + + async checkSharedAccess(userId: string, personIds: Set, permissions: SharingPermission[]) { + if (personIds.size === 0) { + return new Set(); + } + + const ids = await this.db + .selectFrom('person') + .select('person.id') + .where('person.id', 'in', [...personIds]) + .where(hasPermissions(userId, permissions)) + .execute(); + + return new Set(ids.map(({ id }) => id)); + } + + async checkSharedFaceAccess(userId: string, faceIds: Set, permissions: SharingPermission[]) { + if (faceIds.size === 0) { + return new Set(); + } + + const ids = await this.db + .selectFrom('asset_face') + .select('asset_face.id') + .leftJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId')) + .where('asset_face.id', 'in', [...faceIds]) + .where(hasAssetPermissions(userId, permissions)) + .execute(); + + return new Set(ids.map(({ id }) => id)); + } } class PartnerAccess { diff --git a/server/src/repositories/album-user.repository.ts b/server/src/repositories/album-user.repository.ts index 558a0c05d7..bd5882aa2d 100644 --- a/server/src/repositories/album-user.repository.ts +++ b/server/src/repositories/album-user.repository.ts @@ -38,4 +38,13 @@ export class AlbumUserRepository { async delete({ userId, albumId }: AlbumPermissionId): Promise { await this.db.deleteFrom('album_user').where('userId', '=', userId).where('albumId', '=', albumId).execute(); } + + get({ userId, albumId }: AlbumPermissionId) { + return this.db + .selectFrom('album_user') + .select(['permissions', 'inTimeline']) + .where('userId', '=', userId) + .where('albumId', '=', albumId) + .executeTakeFirstOrThrow(); + } } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index b144666773..99947d81c0 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -17,7 +17,15 @@ import { InjectKysely } from 'nestjs-kysely'; import { LockableProperty, Stack } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileType, AssetOrder, AssetOrderBy, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { + AssetFileType, + AssetOrder, + AssetOrderBy, + AssetStatus, + AssetType, + AssetVisibility, + SharingPermission, +} from 'src/enum'; import { DB } from 'src/schema'; import { AssetAudioTable, AssetKeyframeTable, AssetVideoTable } from 'src/schema/tables/asset-av.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -41,6 +49,7 @@ import { withFiles, withLibrary, withOwner, + withPermissions, withSmartSearch, withTagId, withTags, @@ -165,6 +174,47 @@ const withBoundingBox = (qb: SelectQueryBuilder + (eb: ExpressionBuilder) => + eb.or([ + eb('asset.ownerId', '=', userId), + eb.exists( + eb + .selectFrom('partner') + .whereRef('partner.sharedById', '=', 'asset.ownerId') + .where('partner.sharedWithId', '=', userId) + .where((eb) => + eb.or([ + eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')), + eb('partner.permissions', '@>', eb.val(permissions)), + ]), + ) + .$if(!ignoreTimelineVisibility, (qb) => qb.where('partner.inTimeline', '=', true)), + ), + eb.exists( + eb + .selectFrom('album_asset') + .whereRef('album_asset.assetId', '=', 'asset.id') + .innerJoin('album_user', (join) => + join.onRef('album_user.albumId', '=', 'album_asset.albumId').on('album_user.userId', '=', userId), + ) + .$if(!ignoreTimelineVisibility, (qb) => qb.where('album_user.inTimeline', '=', true)) + .where('album_user.albumId', 'in', (eb) => + eb + .selectFrom('album_user') + .select('album_user.albumId') + .whereRef('album_user.userId', '=', 'asset.ownerId') + .where((eb) => + eb.or([ + eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')), + eb('album_user.permissions', '@>', eb.val(permissions)), + ]), + ), + ), + ), + ]); + @Injectable() export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -556,17 +606,22 @@ export class AssetRepository { .executeTakeFirst(); } - @GenerateSql({ params: [DummyValue.UUID] }) + @GenerateSql({ params: [DummyValue.UUID, {}, DummyValue.UUID] }) getById( id: string, { exifInfo, faces, files, library, owner, smartSearch, stack, tags, edits }: GetByIdsRelations = {}, + userId?: string, ) { return this.db .selectFrom('asset') .selectAll('asset') .where('asset.id', '=', asUuid(id)) .$if(!!exifInfo, withExif) - .$if(!!faces, (qb) => qb.select(faces?.person ? withFacesAndPeople : withFaces).$narrowType<{ faces: NotNull }>()) + .$if(!!faces, (qb) => + qb + .select(faces?.person ? (eb) => withFacesAndPeople(eb, { userId }) : withFaces) + .$narrowType<{ faces: NotNull }>(), + ) .$if(!!library, (qb) => qb.select(withLibrary)) .$if(!!owner, (qb) => qb.select(withOwner)) .$if(!!smartSearch, withSmartSearch) @@ -602,6 +657,7 @@ export class AssetRepository { .$if(!!files, (qb) => qb.select(withFiles)) .$if(!!tags, (qb) => qb.select(withTags)) .$if(!!edits, (qb) => qb.select(withEdits)) + .$if(!!userId, (qb) => qb.select(withPermissions(userId!))) .limit(1) .executeTakeFirst(); } @@ -744,7 +800,9 @@ export class AssetRepository { ) .where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])), ) - .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.userIds, (qb) => + qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)), + ) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!)) .$if(options.isDuplicate !== undefined, (qb) => @@ -829,7 +887,9 @@ export class AssetRepository { ), ) .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) - .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.userIds, (qb) => + qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead], !!options.personId)), + ) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(!!options.withStacked, (qb) => qb diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 5bb5276db7..1eff276fa7 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -15,7 +15,7 @@ import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/mis type JobMapItem = { jobName: JobName; queueName: QueueName; - handler: (job: JobOf) => Promise; + handler: (job?: JobOf) => Promise; label: string; }; @@ -95,14 +95,17 @@ export class JobRepository { } } - async run({ name, data }: JobItem) { - const item = this.handlers[name as JobName]; + async run(job: JobItem) { + const item = this.handlers[job.name]; if (!item) { - this.logger.warn(`Skipping unknown job: "${name}"`); + this.logger.warn(`Skipping unknown job: "${job.name}"`); return JobStatus.Skipped; } - return item.handler(data); + if ('data' in job) { + return item.handler(job.data); + } + return item.handler(); } setConcurrency(queueName: QueueName, concurrency: number) { @@ -167,7 +170,7 @@ export class JobRepository { const queueName = this.getQueueName(item.name); const job = { name: item.name, - data: item.data || {}, + data: ('data' in item ? item.data : undefined) || {}, options: this.getJobOptions(item) || undefined, } as JobItem & { data: any; options: JobsOptions | undefined }; diff --git a/server/src/repositories/memory.repository.ts b/server/src/repositories/memory.repository.ts index 09aa5ad880..6c68049cd1 100644 --- a/server/src/repositories/memory.repository.ts +++ b/server/src/repositories/memory.repository.ts @@ -73,7 +73,7 @@ export class MemoryRepository implements IBulkAsset { eb.exists( eb .selectFrom('asset_face') - .innerJoin('person', 'person.id', 'asset_face.personId') + .innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId') .select((eb) => eb.val(1).as('one')) .whereRef('asset_face.assetId', '=', 'asset.id') .where('person.isHidden', '=', true), diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 2a9f822e94..c55584c33f 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -4,7 +4,8 @@ import { jsonObjectFrom } from 'kysely/helpers/postgres'; import { InjectKysely } from 'nestjs-kysely'; import { AssetFace } from 'src/database'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; -import { AssetFileType, AssetVisibility, SourceType } from 'src/enum'; +import { AssetFileType, AssetVisibility, SharingPermission, SourceType } from 'src/enum'; +import { hasAssetPermissions } from 'src/repositories/asset.repository'; import { DB } from 'src/schema'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; @@ -33,9 +34,9 @@ export interface AssetFaceId { } export interface UpdateFacesData { - oldPersonId?: string; + oldFaceClusterId?: string; faceIds?: string[]; - newPersonId: string; + newFaceClusterId: string; } export interface PersonStatistics { @@ -54,7 +55,7 @@ export interface GetAllPeopleOptions { } export interface GetAllFacesOptions { - personId?: string | null; + faceClusterId?: string | null; assetId?: string; sourceType?: SourceType; } @@ -63,9 +64,27 @@ export type UnassignFacesOptions = DeleteFacesOptions; export type SelectFaceOptions = (keyof Selectable)[]; -const withPerson = (eb: ExpressionBuilder) => { +const withPerson = (eb: ExpressionBuilder, userId?: string) => { return jsonObjectFrom( - eb.selectFrom('person').selectAll('person').whereRef('person.id', '=', 'asset_face.personId'), + eb + .selectFrom('person') + .selectAll('person') + .whereRef('person.faceClusterId', '=', 'asset_face.faceClusterId') + .$if(!!userId, (qb) => + qb.where((eb) => + eb.or([eb('person.ownerId', '=', userId!), hasPermissions(userId!, [SharingPermission.PersonRead])(eb)]), + ), + ) + .orderBy( + (eb) => + eb( + 'person.ownerId', + '=', + eb.selectFrom('asset').select('asset.ownerId').whereRef('asset.id', '=', 'asset_face.assetId'), + ), + 'desc', + ) + .limit(1), ).as('person'); }; @@ -75,16 +94,47 @@ const withFaceSearch = (eb: ExpressionBuilder) => { ).as('faceSearch'); }; +export const hasPermissions = + (userId: string, permissions: SharingPermission[]) => (eb: ExpressionBuilder) => + eb.or([ + eb.exists((eb) => + eb + .selectFrom('partner') + .whereRef('partner.sharedById', '=', 'person.ownerId') + .where('partner.sharedWithId', '=', userId) + .where((eb) => + eb.or([ + eb(eb.val(SharingPermission.All), '=', eb.fn.any('partner.permissions')), + eb('partner.permissions', '@>', eb.val(permissions)), + ]), + ), + ), + eb.exists((eb) => + eb + .selectFrom('album_user') + .where('album_user.albumId', 'in', (eb) => + eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId), + ) + .whereRef('album_user.userId', '=', 'person.ownerId') + .where((eb) => + eb.or([ + eb(eb.val(SharingPermission.All), '=', eb.fn.any('album_user.permissions')), + eb('album_user.permissions', '@>', eb.val(permissions)), + ]), + ), + ), + ]); + @Injectable() export class PersonRepository { constructor(@InjectKysely() private db: Kysely) {} @GenerateSql({ params: [{ oldPersonId: DummyValue.UUID, newPersonId: DummyValue.UUID }] }) - async reassignFaces({ oldPersonId, faceIds, newPersonId }: UpdateFacesData): Promise { + async reassignFaces({ oldFaceClusterId, faceIds, newFaceClusterId }: UpdateFacesData): Promise { const result = await this.db .updateTable('asset_face') - .set({ personId: newPersonId }) - .$if(!!oldPersonId, (qb) => qb.where('asset_face.personId', '=', oldPersonId!)) + .set({ faceClusterId: newFaceClusterId }) + .$if(!!oldFaceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', oldFaceClusterId!)) .$if(!!faceIds, (qb) => qb.where('asset_face.id', 'in', faceIds!)) .executeTakeFirst(); @@ -94,7 +144,7 @@ export class PersonRepository { async unassignFaces({ sourceType }: UnassignFacesOptions): Promise { await this.db .updateTable('asset_face') - .set({ personId: null }) + .set({ faceClusterId: null }) .where('asset_face.sourceType', '=', sourceType) .execute(); } @@ -117,8 +167,8 @@ export class PersonRepository { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .$if(options.personId === null, (qb) => qb.where('asset_face.personId', 'is', null)) - .$if(!!options.personId, (qb) => qb.where('asset_face.personId', '=', options.personId!)) + .$if(options.faceClusterId === null, (qb) => qb.where('asset_face.faceClusterId', 'is', null)) + .$if(!!options.faceClusterId, (qb) => qb.where('asset_face.faceClusterId', '=', options.faceClusterId!)) .$if(!!options.sourceType, (qb) => qb.where('asset_face.sourceType', '=', options.sourceType!)) .$if(!!options.assetId, (qb) => qb.where('asset_face.assetId', '=', options.assetId!)) .where('asset_face.deletedAt', 'is', null) @@ -153,16 +203,20 @@ export class PersonRepository { const items = await this.db .selectFrom('person') .selectAll('person') - .innerJoin('asset_face', 'asset_face.personId', 'person.id') + .innerJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId') .innerJoin('asset', (join) => join .onRef('asset_face.assetId', '=', 'asset.id') .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.deletedAt', 'is', null), ) - .where('person.ownerId', '=', userId) + .where((eb) => + eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]), + ) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) + .orderBy('person.faceClusterId') + .orderBy((eb) => eb('person.ownerId', '=', userId), 'desc') .orderBy('person.isHidden', 'asc') .orderBy('person.isFavorite', 'desc') .having((eb) => @@ -171,6 +225,7 @@ export class PersonRepository { eb((innerEb) => innerEb.fn.count('asset_face.assetId'), '>=', options?.minimumFaceCount || 1), ]), ) + .distinctOn('person.faceClusterId') .groupBy('person.id') .$if(!!options?.closestFaceAssetId, (qb) => qb.orderBy((eb) => @@ -209,7 +264,7 @@ export class PersonRepository { return this.db .selectFrom('person') .selectAll('person') - .leftJoin('asset_face', 'asset_face.personId', 'person.id') + .leftJoin('asset_face', 'asset_face.faceClusterId', 'person.faceClusterId') .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) .having((eb) => eb.fn.count('asset_face.assetId'), '=', 0) @@ -218,13 +273,13 @@ export class PersonRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - getFaces(assetId: string, options?: { isVisible?: boolean }) { - const isVisible = options === undefined ? true : options.isVisible; + getFaces(assetId: string, options: { isVisible?: boolean; userId?: string } = {}) { + const { isVisible = true, userId } = options; return this.db .selectFrom('asset_face') .selectAll('asset_face') - .select(withPerson) + .select((eb) => withPerson(eb, userId)) .where('asset_face.assetId', '=', assetId) .where('asset_face.deletedAt', 'is', null) .$if(isVisible !== undefined, (qb) => qb.where('asset_face.isVisible', '=', isVisible!)) @@ -248,7 +303,7 @@ export class PersonRepository { getFaceForFacialRecognitionJob(id: string) { return this.db .selectFrom('asset_face') - .select(['asset_face.id', 'asset_face.personId', 'asset_face.sourceType']) + .select(['asset_face.id', 'asset_face.faceClusterId', 'asset_face.sourceType']) .select((eb) => jsonObjectFrom( eb @@ -289,10 +344,10 @@ export class PersonRepository { } @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID] }) - async reassignFace(assetFaceId: string, newPersonId: string): Promise { + async reassignFace(assetFaceId: string, newFaceClusterId: string): Promise { const result = await this.db .updateTable('asset_face') - .set({ personId: newPersonId }) + .set({ faceClusterId: newFaceClusterId }) .where('asset_face.id', '=', assetFaceId) .executeTakeFirst(); @@ -318,6 +373,7 @@ export class PersonRepository { .where('person.ownerId', '=', userId) .where(() => sql`f_unaccent("person"."name") %> f_unaccent(${personName})`) .orderBy(sql`f_unaccent("person"."name") <->>> f_unaccent(${personName})`) + .orderBy((eb) => eb('person.ownerId', '=', userId), 'desc') .limit(100) .$if(!withHidden, (qb) => qb.where('person.isHidden', '=', false)) .execute(); @@ -335,7 +391,7 @@ export class PersonRepository { } @GenerateSql({ params: [DummyValue.UUID] }) - async getStatistics(personId: string): Promise { + async getStatistics(userId: string, personId: string): Promise { const result = await this.db .selectFrom('asset_face') .leftJoin('asset', (join) => @@ -344,10 +400,13 @@ export class PersonRepository { .on('asset.visibility', '=', sql.lit(AssetVisibility.Timeline)) .on('asset.deletedAt', 'is', null), ) + .where(hasAssetPermissions(userId, [SharingPermission.AssetRead], true)) .select((eb) => eb.fn.count(eb.fn('distinct', ['asset.id'])).as('count')) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) - .where('asset_face.personId', '=', personId) + .where('asset_face.faceClusterId', '=', (eb) => + eb.selectFrom('person').select('person.faceClusterId').where('person.id', '=', personId), + ) .executeTakeFirst(); return { @@ -364,7 +423,7 @@ export class PersonRepository { eb.exists((eb) => eb .selectFrom('asset_face') - .whereRef('asset_face.personId', '=', 'person.id') + .whereRef('asset_face.faceClusterId', '=', 'person.faceClusterId') .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', '=', true) .where((eb) => @@ -378,13 +437,20 @@ export class PersonRepository { ), ), ) - .where('person.ownerId', '=', userId) + .where((eb) => + eb.or([eb('person.ownerId', '=', userId), hasPermissions(userId, [SharingPermission.PersonRead])(eb)]), + ) .select((eb) => eb.fn.coalesce(eb.fn.countAll(), zero).as('total')) .select((eb) => eb.fn.coalesce(eb.fn.countAll().filterWhere('isHidden', '=', true), zero).as('hidden')) .executeTakeFirstOrThrow(); } - create(person: Insertable) { + async create(person: Insertable) { + if (!person.faceClusterId) { + const { id } = await this.db.insertInto('face_cluster').defaultValues().returning('id').executeTakeFirstOrThrow(); + person.faceClusterId = id; + } + return this.db.insertInto('person').values(person).returningAll().executeTakeFirstOrThrow(); } @@ -475,8 +541,9 @@ export class PersonRepository { .selectFrom('asset_face') .selectAll('asset_face') .select(withPerson) + .innerJoin('person', (join) => join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId')) + .where('person.id', 'in', personIds) .where('asset_face.assetId', 'in', assetIds) - .where('asset_face.personId', 'in', personIds) .where('asset_face.deletedAt', 'is', null) .execute(); } @@ -486,7 +553,12 @@ export class PersonRepository { return this.db .selectFrom('asset_face') .selectAll('asset_face') - .where('asset_face.personId', '=', personId) + .innerJoin('person', (join) => + join.onRef('asset_face.faceClusterId', '=', 'person.faceClusterId').on('person.id', '=', personId), + ) + .where('asset_face.assetId', 'in', (eb) => + eb.selectFrom('asset').select('asset.id').whereRef('asset.ownerId', '=', 'person.ownerId'), + ) .where('asset_face.deletedAt', 'is', null) .where('asset_face.isVisible', 'is', true) .executeTakeFirst(); @@ -573,8 +645,14 @@ export class PersonRepository { .selectFrom('asset_face') .select('asset_face.id') .where('asset_face.assetId', '=', assetId) - .where('asset_face.personId', '=', personId) + .innerJoin('person', (join) => + join.onRef('person.faceClusterId', '=', 'asset_face.faceClusterId').on('person.id', '=', personId), + ) .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false)) .executeTakeFirst(); } + + getByFaceClusterId(faceClusterId: string) { + return this.db.selectFrom('person').selectAll().where('person.faceClusterId', '=', faceClusterId).execute(); + } } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 6f03c80ce1..e32ce549f8 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -325,15 +325,23 @@ export class SearchRepository { .selectFrom('asset_face') .select([ 'asset_face.id', - 'asset_face.personId', + 'asset_face.faceClusterId', sql`face_search.embedding <=> ${embedding}`.as('distance'), ]) .innerJoin('asset', 'asset.id', 'asset_face.assetId') + .select('asset.ownerId') .innerJoin('face_search', 'face_search.faceId', 'asset_face.id') - .leftJoin('person', 'person.id', 'asset_face.personId') - .where('asset.ownerId', '=', anyUuid(userIds)) + .leftJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId') + .where('asset.ownerId', 'in', (eb) => + eb + .selectFrom('user') + .select('user.id') + .where('user.trustedGroupId', 'in', (eb) => + eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', anyUuid(userIds)), + ), + ) .where('asset.deletedAt', 'is', null) - .$if(!!hasPerson, (qb) => qb.where('asset_face.personId', 'is not', null)) + .$if(!!hasPerson, (qb) => qb.where('asset_face.faceClusterId', 'is not', null)) .$if(!!minBirthDate, (qb) => qb.where((eb) => eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]), diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index 5ca1d541d6..8303fc8699 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -443,7 +443,7 @@ class AssetFaceSync extends BaseSync { .select([ 'asset_face.id', 'assetId', - 'personId', + 'faceClusterId', 'imageWidth', 'imageHeight', 'boundingBoxX1', diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 20b41c80f8..4e75f0b64f 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -325,4 +325,61 @@ export class UserRepository { await query.execute(); } + + @GenerateSql({ params: [DummyValue.UUID] }) + async getInSameTrustedGroup(userId: string) { + return this.db + .selectFrom('user') + .select('user.id') + .where('user.trustedGroupId', '=', (eb) => + eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId), + ) + .execute() + .then((result) => result.map(({ id }) => id)); + } + + @GenerateSql({ params: [{ userId: DummyValue.UUID, userIdToMerge: DummyValue.UUID }] }) + async mergeTrustedGroups({ userId, userIdToMerge }: { userId: string; userIdToMerge: string }) { + return this.db + .updateTable('user') + .from('user as u') + .where('u.id', '=', userId) + .where('user.trustedGroupId', '=', (eb) => + eb + .selectFrom('user') + .select('user.trustedGroupId') + .where('user.id', '=', userIdToMerge) + .whereRef('user.trustedGroupId', '!=', 'u.trustedGroupId'), + ) + .set((eb) => ({ + trustedGroupId: eb.ref('u.trustedGroupId'), + })) + .executeTakeFirst(); + } + + @GenerateSql({ params: [DummyValue.UUID] }) + async updateTrustedGroups(userId: string) { + return this.db + .updateTable('user') + .set((eb) => ({ trustedGroupId: eb.fn('uuid_generate_v4') })) + .where('user.trustedGroupId', '=', (eb) => + eb.selectFrom('user').select('user.trustedGroupId').where('user.id', '=', userId), + ) + .where('user.id', '!=', userId) + .where('user.id', 'not in', (eb) => + eb + .selectFrom('partner') + .select('partner.sharedById as userId') + .where('sharedWithId', '=', userId) + .union((eb) => + eb + .selectFrom('album_user') + .select('album_user.userId') + .where('album_user.albumId', 'in', (eb) => + eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId), + ), + ), + ) + .executeTakeFirst(); + } } diff --git a/server/src/schema/enums.ts b/server/src/schema/enums.ts index 73f8133441..f4d7910ef5 100644 --- a/server/src/schema/enums.ts +++ b/server/src/schema/enums.ts @@ -4,6 +4,7 @@ import { AssetStatus, AssetVisibility, ChecksumAlgorithm, + SharingPermission, SourceType, VideoSegmentCodec, } from 'src/enum'; @@ -37,3 +38,8 @@ export const video_stream_variant_codec_enum = registerEnum({ name: 'video_stream_variant_codec_enum', values: Object.values(VideoSegmentCodec), }); + +export const sharing_permission_enum = registerEnum({ + name: 'sharing_permission_enum', + values: Object.values(SharingPermission), +}); diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 033f0acd11..6246aea74d 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -4,6 +4,7 @@ import { asset_face_source_type, asset_visibility_enum, assets_status_enum, + sharing_permission_enum, } from 'src/schema/enums'; import { album_user_after_insert, @@ -45,6 +46,7 @@ import { AssetMetadataAuditTable } from 'src/schema/tables/asset-metadata-audit. import { AssetMetadataTable } from 'src/schema/tables/asset-metadata.table'; import { AssetOcrTable } from 'src/schema/tables/asset-ocr.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { FaceSearchTable } from 'src/schema/tables/face-search.table'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table'; import { LibraryTable } from 'src/schema/tables/library.table'; @@ -110,6 +112,7 @@ export class ImmichDatabase { AssetTable, AssetFileTable, AssetExifTable, + FaceClusterTable, FaceSearchTable, GeodataPlacesTable, LibraryTable, @@ -170,7 +173,13 @@ export class ImmichDatabase { asset_face_audit, ]; - enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum]; + enum = [ + album_user_role_enum, + assets_status_enum, + asset_face_source_type, + asset_visibility_enum, + sharing_permission_enum, + ]; } export interface Migrations { @@ -211,6 +220,7 @@ export interface DB { ocr_search: OcrSearchTable; face_search: FaceSearchTable; + face_cluster: FaceClusterTable; geodata_places: GeodataPlacesTable; diff --git a/server/src/schema/migrations/1778249950183-AddPermissionsToPartnerAndAlbum.ts b/server/src/schema/migrations/1778249950183-AddPermissionsToPartnerAndAlbum.ts new file mode 100644 index 0000000000..1471335553 --- /dev/null +++ b/server/src/schema/migrations/1778249950183-AddPermissionsToPartnerAndAlbum.ts @@ -0,0 +1,17 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TYPE "sharing_permission_enum" AS ENUM ('all','asset.read','asset.update','asset.edit','asset.delete','asset.share','exif.read','person.read','person.update','person.merge','person.delete');`.execute(db); + await sql`ALTER TABLE "user" ADD "trustedGroupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db); + await sql`ALTER TABLE "album_user" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{asset.read,exif.read}';`.execute(db); + await sql`ALTER TABLE "album_user" ADD "inTimeline" boolean NOT NULL DEFAULT false;`.execute(db); + await sql`ALTER TABLE "partner" ADD "permissions" sharing_permission_enum[] NOT NULL DEFAULT '{all}';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`DROP TYPE "sharing_permission_enum";`.execute(db); + await sql`ALTER TABLE "partner" DROP COLUMN "permissions";`.execute(db); + await sql`ALTER TABLE "user" DROP COLUMN "trustedGroupId";`.execute(db); + await sql`ALTER TABLE "album_user" DROP COLUMN "permissions";`.execute(db); + await sql`ALTER TABLE "album_user" DROP COLUMN "inTimeline";`.execute(db); +} diff --git a/server/src/schema/migrations/1778258250588-FaceClusterTable.ts b/server/src/schema/migrations/1778258250588-FaceClusterTable.ts new file mode 100644 index 0000000000..ff05eb16a0 --- /dev/null +++ b/server/src/schema/migrations/1778258250588-FaceClusterTable.ts @@ -0,0 +1,51 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "asset_face" RENAME COLUMN "personId" TO "faceClusterId";`.execute(db); + await sql`CREATE INDEX "asset_face_faceClusterId_assetId_idx" ON "asset_face" ("faceClusterId", "assetId");`.execute(db); + await sql`CREATE INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("faceClusterId", "assetId") WHERE ("deletedAt" IS NULL AND "isVisible" IS TRUE);`.execute(db); + await sql`CREATE INDEX "asset_face_assetId_faceClusterId_idx" ON "asset_face" ("assetId", "faceClusterId");`.execute(db); + await sql`DROP INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx";`.execute(db); + await sql`DROP INDEX "asset_face_assetId_personId_idx";`.execute(db); + await sql`DROP INDEX "asset_face_personId_assetId_idx";`.execute(db); + await sql`CREATE TABLE "face_cluster" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), + CONSTRAINT "face_cluster_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db); + await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_personId_fkey";`.execute(db); + await sql`ALTER TABLE "person" ADD "faceClusterId" uuid;`.execute(db); + await sql`CREATE INDEX "person_faceClusterId_idx" ON "person" ("faceClusterId");`.execute(db); + await sql`ALTER TABLE "person" ADD CONSTRAINT "person_faceClusterId_fkey" FOREIGN KEY ("faceClusterId") REFERENCES "face_cluster" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db); + await sql`CREATE INDEX "face_cluster_updateId_idx" ON "face_cluster" ("updateId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "face_cluster_updatedAt" + BEFORE UPDATE ON "face_cluster" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_face_cluster_updatedAt', '{"type":"trigger","name":"face_cluster_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"face_cluster_updatedAt\\"\\n BEFORE UPDATE ON \\"face_cluster\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx', '{"type":"index","name":"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx","sql":"CREATE INDEX \\"asset_face_faceClusterId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"faceClusterId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);"}'::jsonb);`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_personId_assetId_notDeleted_isVisible_idx';`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "person" DROP COLUMN "faceClusterId";`.execute(db); + await sql`DROP INDEX "person_faceClusterId_idx";`.execute(db); + await sql`ALTER TABLE "person" DROP CONSTRAINT "person_faceClusterId_fkey";`.execute(db); + await sql`ALTER TABLE "asset_face" RENAME COLUMN "faceClusterId" TO "personId";`.execute(db); + await sql`CREATE INDEX "asset_face_personId_assetId_notDeleted_isVisible_idx" ON "asset_face" ("personId", "assetId") WHERE ((("deletedAt" IS NULL) AND ("isVisible" IS TRUE)));`.execute(db); + await sql`CREATE INDEX "asset_face_assetId_personId_idx" ON "asset_face" ("assetId", "personId");`.execute(db); + await sql`CREATE INDEX "asset_face_personId_assetId_idx" ON "asset_face" ("personId", "assetId");`.execute(db); + await sql`DROP INDEX "asset_face_faceClusterId_assetId_idx";`.execute(db); + await sql`DROP INDEX "asset_face_faceClusterId_assetId_notDeleted_isVisible_idx";`.execute(db); + await sql`DROP INDEX "asset_face_assetId_faceClusterId_idx";`.execute(db); + await sql`ALTER TABLE "asset_face" ADD CONSTRAINT "asset_face_personId_fkey" FOREIGN KEY ("personId") REFERENCES "person" ("id") ON UPDATE CASCADE ON DELETE SET NULL;`.execute(db); + await sql`ALTER TABLE "asset_face" DROP CONSTRAINT "asset_face_faceClusterId_fkey";`.execute(db); + await sql`DROP TABLE "face_cluster";`.execute(db); + await sql`DROP TRIGGER "face_cluster_updatedAt" ON "face_cluster";`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_asset_face_personId_assetId_notDeleted_isVisible_idx', '{"sql":"CREATE INDEX \\"asset_face_personId_assetId_notDeleted_isVisible_idx\\" ON \\"asset_face\\" (\\"personId\\", \\"assetId\\") WHERE (\\"deletedAt\\" IS NULL AND \\"isVisible\\" IS TRUE);","name":"asset_face_personId_assetId_notDeleted_isVisible_idx","type":"index"}'::jsonb);`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_face_cluster_updatedAt';`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_asset_face_faceClusterId_assetId_notDeleted_isVisible_idx';`.execute(db); +} diff --git a/server/src/schema/tables/album-user.table.ts b/server/src/schema/tables/album-user.table.ts index 677d6ca2f2..527e4954c3 100644 --- a/server/src/schema/tables/album-user.table.ts +++ b/server/src/schema/tables/album-user.table.ts @@ -11,8 +11,8 @@ import { UpdateDateColumn, } from '@immich/sql-tools'; import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; -import { AlbumUserRole } from 'src/enum'; -import { album_user_role_enum } from 'src/schema/enums'; +import { AlbumUserRole, SharingPermission } from 'src/enum'; +import { album_user_role_enum, sharing_permission_enum } from 'src/schema/enums'; import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions'; import { AlbumTable } from 'src/schema/tables/album.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -69,4 +69,14 @@ export class AlbumUserTable { @UpdateDateColumn() updatedAt!: Generated; + + @Column({ + array: true, + enum: sharing_permission_enum, + default: [SharingPermission.AssetRead, SharingPermission.ExifRead], + }) + permissions!: Generated; + + @Column({ type: 'boolean', default: false }) + inTimeline!: Generated; } diff --git a/server/src/schema/tables/asset-face.table.ts b/server/src/schema/tables/asset-face.table.ts index b67e5e5dac..8c0277b3cb 100644 --- a/server/src/schema/tables/asset-face.table.ts +++ b/server/src/schema/tables/asset-face.table.ts @@ -15,7 +15,7 @@ import { SourceType } from 'src/enum'; import { asset_face_source_type } from 'src/schema/enums'; import { asset_face_audit } from 'src/schema/functions'; import { AssetTable } from 'src/schema/tables/asset.table'; -import { PersonTable } from 'src/schema/tables/person.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; @Table({ name: 'asset_face' }) @UpdatedAtTrigger('asset_face_updatedAt') @@ -26,13 +26,13 @@ import { PersonTable } from 'src/schema/tables/person.table'; when: 'pg_trigger_depth() = 0', }) // schemaFromDatabase does not preserve column order -@Index({ name: 'asset_face_assetId_personId_idx', columns: ['assetId', 'personId'] }) +@Index({ name: 'asset_face_assetId_faceClusterId_idx', columns: ['assetId', 'faceClusterId'] }) @Index({ - name: 'asset_face_personId_assetId_notDeleted_isVisible_idx', - columns: ['personId', 'assetId'], + name: 'asset_face_faceClusterId_assetId_notDeleted_isVisible_idx', + columns: ['faceClusterId', 'assetId'], where: '"deletedAt" IS NULL AND "isVisible" IS TRUE', }) -@Index({ columns: ['personId', 'assetId'] }) +@Index({ columns: ['faceClusterId', 'assetId'] }) export class AssetFaceTable { @PrimaryGeneratedColumn() id!: Generated; @@ -45,14 +45,14 @@ export class AssetFaceTable { }) assetId!: string; - @ForeignKeyColumn(() => PersonTable, { + @ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true, - // [personId, assetId] makes this redundant + // [faceClusterId, assetId] makes this redundant index: false, }) - personId!: string | null; + faceClusterId!: string | null; @Column({ default: 0, type: 'integer' }) imageWidth!: Generated; diff --git a/server/src/schema/tables/face-cluster.table.ts b/server/src/schema/tables/face-cluster.table.ts new file mode 100644 index 0000000000..9f45a683c6 --- /dev/null +++ b/server/src/schema/tables/face-cluster.table.ts @@ -0,0 +1,25 @@ +import { + CreateDateColumn, + Generated, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; + +@Table('face_cluster') +@UpdatedAtTrigger('face_cluster_updatedAt') +export class FaceClusterTable { + @PrimaryGeneratedColumn('uuid') + id!: Generated; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} diff --git a/server/src/schema/tables/partner.table.ts b/server/src/schema/tables/partner.table.ts index 408cac650f..352f547c6c 100644 --- a/server/src/schema/tables/partner.table.ts +++ b/server/src/schema/tables/partner.table.ts @@ -9,6 +9,8 @@ import { UpdateDateColumn, } from '@immich/sql-tools'; import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; +import { SharingPermission } from 'src/enum'; +import { sharing_permission_enum } from 'src/schema/enums'; import { partner_delete_audit } from 'src/schema/functions'; import { UserTable } from 'src/schema/tables/user.table'; @@ -46,4 +48,7 @@ export class PartnerTable { @UpdateIdColumn({ index: true }) updateId!: Generated; + + @Column({ array: true, enum: sharing_permission_enum, default: [SharingPermission.All] }) + permissions!: Generated; } diff --git a/server/src/schema/tables/person.table.ts b/server/src/schema/tables/person.table.ts index 35447acfd0..2940ca3611 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -14,6 +14,7 @@ import { import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { person_delete_audit } from 'src/schema/functions'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; +import { FaceClusterTable } from 'src/schema/tables/face-cluster.table'; import { UserTable } from 'src/schema/tables/user.table'; @Table('person') @@ -43,9 +44,6 @@ export class PersonTable { @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false }) ownerId!: string; - @Column({ default: '' }) - name!: Generated; - @Column({ default: '' }) thumbnailPath!: Generated; @@ -55,6 +53,9 @@ export class PersonTable { @Column({ type: 'date', nullable: true }) birthDate!: Timestamp | null; + @Column({ default: '' }) + name!: Generated; + @ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true }) faceAssetId!: string | null; @@ -66,4 +67,7 @@ export class PersonTable { @UpdateIdColumn({ index: true }) updateId!: Generated; + + @ForeignKeyColumn(() => FaceClusterTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true, index: true }) + faceClusterId!: string | null; } diff --git a/server/src/schema/tables/user.table.ts b/server/src/schema/tables/user.table.ts index 0839924d2a..fad4928773 100644 --- a/server/src/schema/tables/user.table.ts +++ b/server/src/schema/tables/user.table.ts @@ -4,6 +4,7 @@ import { CreateDateColumn, DeleteDateColumn, Generated, + GeneratedColumn, Index, PrimaryGeneratedColumn, Table, @@ -82,4 +83,7 @@ export class UserTable { @UpdateIdColumn({ index: true }) updateId!: Generated; + + @GeneratedColumn('uuid') + trustedGroupId!: Generated; } diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 723288e5b5..a2c78cb281 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -8,13 +8,15 @@ import { CreateAlbumDto, GetAlbumsDto, mapAlbum, + SharingPermissionsResponseDto, UpdateAlbumDto, UpdateAlbumUserDto, + UpdateSharingPermissionsDto, } from 'src/dtos/album.dto'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; import { MapMarkerResponseDto } from 'src/dtos/map.dto'; -import { AlbumUserRole, Permission } from 'src/enum'; +import { AlbumUserRole, Permission, SharingPermission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; import { addAssets, removeAssets } from 'src/utils/asset.util'; @@ -137,6 +139,11 @@ export class AlbumService extends BaseService { ); for (const { userId } of albumUsers) { + await this.userRepository.mergeTrustedGroups({ + userId: auth.user.id, + userIdToMerge: userId, + }); + await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name }); } @@ -306,7 +313,17 @@ export class AlbumService extends BaseService { throw new BadRequestException('Invalid user'); } - await this.albumUserRepository.create({ userId, albumId: id, role }); + await this.userRepository.mergeTrustedGroups({ + userId: auth.user.id, + userIdToMerge: userId, + }); + await this.albumUserRepository.create({ + userId, + albumId: id, + role, + permissions: [SharingPermission.AssetRead, SharingPermission.ExifRead], + }); + await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name }); } @@ -345,6 +362,19 @@ export class AlbumService extends BaseService { await this.albumUserRepository.update({ albumId: id, userId }, { role: dto.role }); } + async updateSelf(auth: AuthDto, albumId: string, dto: UpdateSharingPermissionsDto): Promise { + await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] }); + await this.albumUserRepository.update( + { albumId, userId: auth.user.id }, + { permissions: dto.permissions, inTimeline: dto.inTimeline }, + ); + } + + async getSelf(auth: AuthDto, albumId: string): Promise { + await this.requireAccess({ auth, permission: Permission.AlbumAssetCreate, ids: [albumId] }); + return this.albumUserRepository.get({ userId: auth.user.id, albumId }); + } + private async findOrFail(id: string, authUserId: string, options: AlbumInfoOptions) { const album = await this.albumRepository.getById(id, options, authUserId); if (!album) { diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index e2d2d95f81..902ecefb37 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -32,10 +32,11 @@ import { JobStatus, Permission, QueueName, + SharingPermission, } from 'src/enum'; import { BaseService } from 'src/services/base.service'; import { JobItem, JobOf } from 'src/types'; -import { requireElevatedPermission } from 'src/utils/access'; +import { hasPermissions, requireElevatedPermission } from 'src/utils/access'; import { getAssetFiles, getDimensions, @@ -62,14 +63,18 @@ export class AssetService extends BaseService { async get(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [id] }); - const asset = await this.assetRepository.getById(id, { - exifInfo: true, - owner: true, - faces: { person: true }, - stack: { assets: true }, - edits: true, - tags: true, - }); + const asset = await this.assetRepository.getById( + id, + { + exifInfo: true, + owner: true, + faces: { person: true }, + stack: { assets: true }, + edits: true, + tags: true, + }, + auth.user.id, + ); if (!asset) { throw new BadRequestException('Asset not found'); @@ -85,7 +90,7 @@ export class AssetService extends BaseService { delete data.owner; } - if (data.ownerId !== auth.user.id || auth.sharedLink) { + if (!hasPermissions(data, SharingPermission.PersonRead)) { data.people = []; } diff --git a/server/src/services/notification.service.ts b/server/src/services/notification.service.ts index e688f7dc7f..a91440d01b 100644 --- a/server/src/services/notification.service.ts +++ b/server/src/services/notification.service.ts @@ -85,7 +85,11 @@ export class NotificationService extends BaseService { return; } - this.logger.error(`Unable to run job handler (${job.name}): ${error}`, error?.stack, JSON.stringify(job.data)); + this.logger.error( + `Unable to run job handler (${job.name}): ${error}`, + error?.stack, + 'data' in job ? JSON.stringify(job.data) : {}, + ); switch (job.name) { case JobName.DatabaseBackup: { diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index cc950edb5b..51e52f4660 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -3,7 +3,7 @@ import { Partner } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; import { PartnerCreateDto, PartnerResponseDto, PartnerSearchDto, PartnerUpdateDto } from 'src/dtos/partner.dto'; import { mapUser } from 'src/dtos/user.dto'; -import { Permission } from 'src/enum'; +import { JobName, Permission, SharingPermission } from 'src/enum'; import { PartnerDirection, PartnerIds } from 'src/repositories/partner.repository'; import { BaseService } from 'src/services/base.service'; @@ -16,7 +16,15 @@ export class PartnerService extends BaseService { throw new BadRequestException(`Partner already exists`); } - const partner = await this.partnerRepository.create(partnerId); + const { numUpdatedRows } = await this.userRepository.mergeTrustedGroups({ + userId: auth.user.id, + userIdToMerge: sharedWithId, + }); + const partner = await this.partnerRepository.create({ ...partnerId, permissions: [SharingPermission.All] }); + if (numUpdatedRows > 0) { + await this.jobRepository.queue({ name: JobName.FacialRecognitionMerge, data: { id: sharedWithId } }); + } + return this.mapPartner(partner, PartnerDirection.SharedBy); } @@ -28,6 +36,10 @@ export class PartnerService extends BaseService { } await this.partnerRepository.remove(partnerId); + const { numUpdatedRows } = await this.userRepository.updateTrustedGroups(auth.user.id); + if (numUpdatedRows > 0) { + await this.jobRepository.queue({ name: JobName.FacialRecognitionQueueAll, data: { force: true } }); + } } async search(auth: AuthDto, { direction }: PartnerSearchDto): Promise { diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index fde5313f4d..a3204aa57b 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -1,4 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import e from 'express'; import { Insertable, Updateable } from 'kysely'; import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants'; import { Person } from 'src/database'; @@ -13,7 +14,7 @@ import { FaceDto, mapFaces, mapPerson, - MergePersonDto, + MergeFaceClusterDto, PeopleResponseDto, PeopleUpdateDto, PersonCreateDto, @@ -127,11 +128,11 @@ export class PersonService extends BaseService { async getFacesById(auth: AuthDto, dto: FaceDto): Promise { await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.id] }); - const faces = await this.personRepository.getFaces(dto.id); + const faces = await this.personRepository.getFaces(dto.id, { userId: auth.user.id }); const asset = await this.assetRepository.getForFaces(dto.id); const assetDimensions = getDimensions(asset); - return faces.map((face) => mapFaces(face, auth, asset.edits, assetDimensions)); + return faces.map((face) => mapFaces(face, asset.edits, assetDimensions)); } async createNewFeaturePhoto(changeFeaturePhoto: string[]) { @@ -159,7 +160,7 @@ export class PersonService extends BaseService { async getStatistics(auth: AuthDto, id: string): Promise { await this.requireAccess({ auth, permission: Permission.PersonRead, ids: [id] }); - return this.personRepository.getStatistics(id); + return this.personRepository.getStatistics(auth.user.id, id); } async getThumbnail(auth: AuthDto, id: string): Promise { @@ -438,7 +439,7 @@ export class PersonService extends BaseService { const lastRun = new Date().toISOString(); const facePagination = this.personRepository.getAllFaces( - force ? undefined : { personId: null, sourceType: SourceType.MachineLearning }, + force ? undefined : { faceClusterId: null, sourceType: SourceType.MachineLearning }, ); let jobs: { name: JobName.FacialRecognition; data: { id: string; deferred: false } }[] = []; @@ -481,8 +482,8 @@ export class PersonService extends BaseService { return JobStatus.Failed; } - if (face.personId) { - this.logger.debug(`Face ${id} already has a person assigned`); + if (face.faceClusterId) { + this.logger.debug(`Face ${id} already belongs to a face cluster`); return JobStatus.Skipped; } @@ -511,8 +512,8 @@ export class PersonService extends BaseService { return JobStatus.Skipped; } - let personId = matches.find((match) => match.personId)?.personId; - if (!personId) { + let faceClusterId = matches.find((match) => match.faceClusterId)?.faceClusterId; + if (!faceClusterId) { const matchWithPerson = await this.searchRepository.searchFaces({ userIds: [face.asset.ownerId], embedding: face.faceSearch.embedding, @@ -523,20 +524,102 @@ export class PersonService extends BaseService { }); if (matchWithPerson.length > 0) { - personId = matchWithPerson[0].personId; + faceClusterId = matchWithPerson[0].faceClusterId; } } - if (isCore && !personId) { + if (isCore && !faceClusterId) { this.logger.log(`Creating new person for face ${id}`); const newPerson = await this.personRepository.create({ ownerId: face.asset.ownerId, faceAssetId: face.id }); await this.jobRepository.queue({ name: JobName.PersonGenerateThumbnail, data: { id: newPerson.id } }); - personId = newPerson.id; + faceClusterId = newPerson.faceClusterId; } - if (personId) { - this.logger.debug(`Assigning face ${id} to person ${personId}`); - await this.personRepository.reassignFaces({ faceIds: [id], newPersonId: personId }); + if (faceClusterId) { + this.logger.debug(`Assigning face ${id} to face cluster ${faceClusterId}`); + await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId }); + } + + return JobStatus.Success; + } + + @OnJob({ name: JobName.FacialRecognitionMerge, queue: QueueName.FacialRecognition }) + async mergeClusters({ id: userId }: JobOf): Promise { + const { machineLearning } = await this.getConfig({ withCache: true }); + if (!isFacialRecognitionEnabled(machineLearning)) { + return JobStatus.Skipped; + } + + const faces = this.personRepository.getAllFaces({ sourceType: SourceType.MachineLearning }); + for await (const { id } of faces) { + const face = await this.personRepository.getFaceForFacialRecognitionJob(id); + if (!face?.faceSearch || !face.asset) { + this.logger.warn(`Face ${id} does not have an embedding`); + continue; + } + if (face.asset.ownerId === userId) { + continue; + } + + let faceClusterId: string | null = null; + const matchWithPerson = await this.searchRepository.searchFaces({ + userIds: [face.asset.ownerId], + embedding: face.faceSearch.embedding, + maxDistance: machineLearning.facialRecognition.maxDistance, + numResults: 10, + hasPerson: true, + minBirthDate: new Date(face.asset.fileCreatedAt), + }); + + if (matchWithPerson.length > 0) { + // favor a person that's not owned by us to merge people with a newly shared with user + // probably do smarter stuff here like pick the person with a name, if both have a name set aliases or whatever + const match = matchWithPerson.find((match) => match.ownerId !== userId) ?? matchWithPerson[0]; + if (match.faceClusterId && face.asset.ownerId !== match.ownerId) { + // TODO should probably be a DB constraint? + const people = await this.personRepository.getByFaceClusterId(match.faceClusterId); + if (!people.some((person) => person.ownerId === face.asset?.ownerId)) { + const person = await this.personRepository.create({ + ownerId: face.asset.ownerId, + faceClusterId: match.faceClusterId, + }); + await this.createNewFeaturePhoto([person.id]); + } + } + + faceClusterId = match.faceClusterId; + } + + if (!faceClusterId) { + const matches = await this.searchRepository.searchFaces({ + userIds: [userId], + embedding: face.faceSearch.embedding, + maxDistance: machineLearning.facialRecognition.maxDistance, + numResults: machineLearning.facialRecognition.minFaces, + minBirthDate: new Date(face.asset.fileCreatedAt), + }); + + const match = matches.find((match) => match.faceClusterId); + if (match && match.faceClusterId && face.asset.ownerId !== match.ownerId) { + // TODO should probably be a DB constraint? + const people = await this.personRepository.getByFaceClusterId(match.faceClusterId); + + if (!people.some((person) => person.ownerId === face.asset?.ownerId)) { + const person = await this.personRepository.create({ + ownerId: face.asset.ownerId, + faceClusterId: match.faceClusterId, + }); + await this.createNewFeaturePhoto([person.id]); + } + } + + faceClusterId = match?.faceClusterId ?? null; + } + + if (faceClusterId) { + this.logger.log(`Assigning face ${id} to face cluster ${faceClusterId}`); + await this.personRepository.reassignFaces({ faceIds: [id], newFaceClusterId: faceClusterId }); + } } return JobStatus.Success; @@ -554,7 +637,7 @@ export class PersonService extends BaseService { return JobStatus.Success; } - async mergePerson(auth: AuthDto, id: string, dto: MergePersonDto): Promise { + async mergePerson(auth: AuthDto, id: string, dto: MergeFaceClusterDto): Promise { const mergeIds = dto.ids; if (mergeIds.includes(id)) { throw new BadRequestException('Cannot merge a person into themselves'); @@ -600,7 +683,7 @@ export class PersonService extends BaseService { } const mergeName = mergePerson.name || mergePerson.id; - const mergeData: UpdateFacesData = { oldPersonId: mergeId, newPersonId: id }; + const mergeData: UpdateFacesData = { oldFaceClusterId: mergeId, newFaceClusterId: id }; this.logger.log(`Merging ${mergeName} into ${primaryName}`); await this.personRepository.reassignFaces(mergeData); @@ -613,6 +696,7 @@ export class PersonService extends BaseService { results.push({ id: mergeId, success: false, error: BulkIdErrorReason.UNKNOWN }); } } + return results; } @@ -682,8 +766,12 @@ export class PersonService extends BaseService { dto.imageHeight = originalDimensions.height; } + if (!person?.faceClusterId) { + throw new Error('Person must already have some recognized faces and belong to a face cluster'); + } + await this.personRepository.createAssetFace({ - personId: dto.personId, + faceClusterId: person.faceClusterId, assetId: dto.assetId, imageHeight: dto.imageHeight, imageWidth: dto.imageWidth, diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index c03f7bacaa..7c713d8014 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -208,6 +208,7 @@ export class SearchService extends BaseService { repository: this.partnerRepository, timelineEnabled: true, }); + console.log(auth.user.id, partnerIds); return [auth.user.id, ...partnerIds]; } diff --git a/server/src/types.ts b/server/src/types.ts index c7dc1f5e18..e9e58fe4c8 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -204,7 +204,9 @@ export type ConcurrentQueueName = Exclude< | QueueName.BackupDatabase >; -export type Jobs = { [K in JobItem['name']]: (JobItem & { name: K })['data'] }; +export type Jobs = { + [K in JobItem['name']]: 'data' extends keyof (JobItem & { name: K }) ? (JobItem & { name: K })['data'] : never; +}; export type JobOf = Jobs[T]; export interface IBaseJob { @@ -351,6 +353,7 @@ export type JobItem = | { name: JobName.AssetDetectFaces; data: IEntityJob } | { name: JobName.FacialRecognitionQueueAll; data: INightlyJob } | { name: JobName.FacialRecognition; data: IDeferrableJob } + | { name: JobName.FacialRecognitionMerge; data: IEntityJob } | { name: JobName.PersonGenerateThumbnail; data: IEntityJob } // Smart Search diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index b8e7733772..0237a298f6 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -1,7 +1,7 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { AuthSharedLink } from 'src/database'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AlbumUserRole, Permission } from 'src/enum'; +import { AlbumUserRole, Permission, SharingPermission } from 'src/enum'; import { AccessRepository } from 'src/repositories/access.repository'; import { setDifference, setIsEqual, setIsSuperset, setUnion } from 'src/utils/set'; @@ -115,37 +115,41 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe case Permission.AssetRead: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); - const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); - return setUnion(isOwner, isAlbum, isPartner); + const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]); + return setUnion(isOwner, isShared); } case Permission.AssetShare: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, false); - const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner)); - return setUnion(isOwner, isPartner); + const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetShare]); + return setUnion(isOwner, isShared); } case Permission.AssetView: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); - const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); - return setUnion(isOwner, isAlbum, isPartner); + const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetRead]); + return setUnion(isOwner, isShared); } case Permission.AssetDownload: { const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); - const isAlbum = await access.asset.checkAlbumAccess(auth.user.id, setDifference(ids, isOwner)); - const isPartner = await access.asset.checkPartnerAccess(auth.user.id, setDifference(ids, isOwner, isAlbum)); - return setUnion(isOwner, isAlbum, isPartner); + const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [ + SharingPermission.AssetRead, + SharingPermission.ExifRead, + ]); + return setUnion(isOwner, isShared); } case Permission.AssetUpdate: { - return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetUpdate]); + return setUnion(isOwner, isShared); } case Permission.AssetDelete: { - return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetDelete]); + return setUnion(isOwner, isShared); } case Permission.AssetCopy: { @@ -153,15 +157,21 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe } case Permission.AssetEditGet: { - return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]); + return setUnion(isOwner, isShared); } case Permission.AssetEditCreate: { - return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]); + return setUnion(isOwner, isShared); } case Permission.AssetEditDelete: { - return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isOwner = await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + const isShared = await access.asset.checkSharedAccess(auth.user.id, ids, [SharingPermission.AssetEdit]); + return setUnion(isOwner, isShared); } case Permission.AlbumRead: { @@ -246,7 +256,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe } case Permission.FaceDelete: { - return access.person.checkFaceOwnerAccess(auth.user.id, ids); + const isOwner = await access.person.checkFaceOwnerAccess(auth.user.id, ids); + const isShared = await access.person.checkSharedFaceAccess(auth.user.id, setDifference(ids, isOwner), [ + SharingPermission.AssetUpdate, + ]); + return setUnion(isOwner, isShared); } case Permission.NotificationRead: @@ -288,11 +302,40 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return access.person.checkFaceOwnerAccess(auth.user.id, ids); } - case Permission.PersonRead: - case Permission.PersonUpdate: - case Permission.PersonDelete: + case Permission.PersonRead: { + const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [ + SharingPermission.PersonRead, + ]); + + return setUnion(isOwner, isShared); + } + case Permission.PersonMerge: { - return await access.person.checkOwnerAccess(auth.user.id, ids); + const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [ + SharingPermission.PersonMerge, + ]); + + return setUnion(isOwner, isShared); + } + + case Permission.PersonUpdate: { + const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [ + SharingPermission.PersonUpdate, + ]); + + return setUnion(isOwner, isShared); + } + + case Permission.PersonDelete: { + const isOwner = await access.person.checkOwnerAccess(auth.user.id, ids); + const isShared = await access.person.checkSharedAccess(auth.user.id, setDifference(ids, isOwner), [ + SharingPermission.PersonDelete, + ]); + + return setUnion(isOwner, isShared); } case Permission.PersonReassign: { @@ -339,3 +382,20 @@ export const requireElevatedPermission = (auth: AuthDto) => { throw new UnauthorizedException('Elevated permission is required'); } }; + +export const hasPermissions = ( + assetLike: { permissions: SharingPermission[] }, + ...permissions: SharingPermission[] +) => { + if (assetLike.permissions.includes(SharingPermission.All)) { + return true; + } + + for (const permission of permissions) { + if (!assetLike.permissions.includes(permission)) { + return false; + } + } + + return true; +}; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index 5420e60361..9397c4e48b 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -4,7 +4,7 @@ import { AssetFile } from 'src/database'; import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; +import { AssetFileType, AssetType, AssetVisibility, Permission, SharingPermission } from 'src/enum'; import { AuthRequest } from 'src/middleware/auth.guard'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; @@ -134,6 +134,11 @@ export const getMyPartnerIds = async ({ userId, repository, timelineEnabled }: P continue; } + const permissions = [SharingPermission.All, SharingPermission.AssetRead]; + if (!permissions.some((permission) => partner.permissions.includes(permission))) { + continue; + } + partnerIds.add(partner.sharedById); } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index fbf32c0ac2..1ff7ac58d5 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -15,9 +15,17 @@ import { import { PostgresJSDialect } from 'kysely-postgres-js'; import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { Notice, PostgresError } from 'postgres'; -import { columns, lockableProperties, LockableProperty, Person } from 'src/database'; +import { columns, lockableProperties, LockableProperty } from 'src/database'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetOrderBy, AssetVisibility, DatabaseExtension, ExifOrientation } from 'src/enum'; +import { + AssetFileType, + AssetOrderBy, + AssetVisibility, + DatabaseExtension, + ExifOrientation, + SharingPermission, +} from 'src/enum'; +import { hasAssetPermissions } from 'src/repositories/asset.repository'; import { AssetSearchBuilderOptions } from 'src/repositories/search.repository'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; @@ -212,19 +220,22 @@ export function withFilePath(eb: ExpressionBuilder, type: AssetFile export function withFacesAndPeople( eb: ExpressionBuilder, - withHidden?: boolean, - withDeletedFace?: boolean, + { withHidden, withDeletedFace, userId: _ }: { withHidden?: boolean; withDeletedFace?: boolean; userId?: string } = {}, ) { return jsonArrayFrom( eb .selectFrom('asset_face') - .leftJoinLateral( - (eb) => - eb.selectFrom('person').selectAll('person').whereRef('asset_face.personId', '=', 'person.id').as('person'), - (join) => join.onTrue(), + .select((eb) => + jsonObjectFrom( + eb + .selectFrom('face_cluster') + .whereRef('face_cluster.id', '=', 'asset_face.faceClusterId') + .innerJoin('person', 'person.faceClusterId', 'face_cluster.id') + .selectAll('person') + .limit(1), + ).as('person'), ) .selectAll('asset_face') - .select((eb) => eb.table('person').$castTo>().as('person')) .whereRef('asset_face.assetId', '=', 'asset.id') .$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null)) .$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)), @@ -237,11 +248,12 @@ export function hasPeople(qb: SelectQueryBuilder, personIds: eb .selectFrom('asset_face') .select('assetId') - .where('personId', '=', anyUuid(personIds!)) + .innerJoin('person', 'person.faceClusterId', 'asset_face.faceClusterId') + .where('person.id', '=', anyUuid(personIds!)) .where('deletedAt', 'is', null) .where('isVisible', 'is', true) .groupBy('assetId') - .having((eb) => eb.fn.count('personId').distinct(), '=', personIds.length) + .having((eb) => eb.fn.count('person.id').distinct(), '=', personIds.length) .as('has_people'), (join) => join.onRef('has_people.assetId', '=', 'asset.id'), ); @@ -302,6 +314,30 @@ export function truncatedDate(order: AssetOrderBy = AssetOrderBy.TakenAt) { return sql`date_trunc(${sql.lit('MONTH')}, ${sql.ref(order === AssetOrderBy.CreatedAt ? 'asset.createdAt' : 'localDateTime')} AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; } +export function withPermissions(userId: string) { + return (eb: ExpressionBuilder) => + jsonArrayFrom( + eb + .selectFrom('album_user') + .select((eb) => eb.fn('unnest', ['album_user.permissions']).as('permission')) + .distinct() + .innerJoin('album_asset', 'album_user.albumId', 'album_asset.albumId') + .whereRef('album_asset.assetId', '=', 'asset.id') + .whereRef('album_user.userId', '=', 'asset.ownerId') + .where('album_user.albumId', 'in', (eb) => + eb.selectFrom('album_user').select('album_user.albumId').where('album_user.userId', '=', userId), + ) + .union( + eb + .selectFrom('partner') + .select((eb) => eb.fn('unnest', ['partner.permissions']).as('permission')) + .distinct() + .whereRef('partner.sharedById', '=', 'asset.ownerId') + .where('partner.sharedWithId', '=', userId), + ), + ).as('permissions'); +} + export function withTagId(qb: SelectQueryBuilder, tagId: string) { return qb.where((eb) => eb.exists( @@ -428,7 +464,7 @@ export function searchAssetBuilder(kysely: Kysely, options: AssetSearchBuild .$if(!!options.checksum, (qb) => qb.where('asset.checksum', '=', options.checksum!)) .$if(!!options.id, (qb) => qb.where('asset.id', '=', asUuid(options.id!))) .$if(!!options.libraryId, (qb) => qb.where('asset.libraryId', '=', asUuid(options.libraryId!))) - .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.userIds, (qb) => qb.where(hasAssetPermissions(options.userIds![0], [SharingPermission.AssetRead]))) .$if(!!options.encodedVideoPath, (qb) => qb .innerJoin('asset_file', (join) => diff --git a/server/src/utils/duplicate.spec.ts b/server/src/utils/duplicate.spec.ts index 155438f1bd..5fcd3959d6 100644 --- a/server/src/utils/duplicate.spec.ts +++ b/server/src/utils/duplicate.spec.ts @@ -38,6 +38,7 @@ const createAsset = ( fileSizeInByte !== null || Object.keys(exifFields).length > 0 ? ExifResponseSchema.parse({ fileSizeInByte, ...exifFields }) : undefined, + permissions: [], }); describe('duplicate utils', () => { diff --git a/server/src/utils/editor.ts b/server/src/utils/editor.ts index 21678f2a82..00409d8394 100644 --- a/server/src/utils/editor.ts +++ b/server/src/utils/editor.ts @@ -1,4 +1,3 @@ -import { AssetFace } from 'src/database'; import { AssetOcrResponseDto } from 'src/dtos/ocr.dto'; import { ImageDimensions } from 'src/types'; @@ -31,11 +30,21 @@ const scale = (box: BoundingBox, target: ImageDimensions, source?: ImageDimensio }; }; -export const checkFaceVisibility = ( - faces: AssetFace[], +export const checkFaceVisibility = < + T extends { + isVisible: boolean; + boundingBoxX1: number; + boundingBoxX2: number; + boundingBoxY1: number; + boundingBoxY2: number; + imageHeight: number; + imageWidth: number; + }, +>( + faces: T[], originalAssetDimensions: ImageDimensions, crop?: BoundingBox, -): { visible: AssetFace[]; hidden: AssetFace[] } => { +): { visible: T[]; hidden: T[] } => { if (!crop) { return { visible: faces.filter((face) => !face.isVisible), diff --git a/server/test/factories/album-user.factory.ts b/server/test/factories/album-user.factory.ts index 6e2f8cb832..3ad33dd12b 100644 --- a/server/test/factories/album-user.factory.ts +++ b/server/test/factories/album-user.factory.ts @@ -28,6 +28,8 @@ export class AlbumUserFactory { createdAt: newDate(), updateId: newUuidV7(), updatedAt: newDate(), + permissions: [], + inTimeline: false, ...dto, }); } diff --git a/server/test/factories/partner.factory.ts b/server/test/factories/partner.factory.ts index f631db1eb5..0c3507c68f 100644 --- a/server/test/factories/partner.factory.ts +++ b/server/test/factories/partner.factory.ts @@ -26,6 +26,7 @@ export class PartnerFactory { sharedWithId, updatedAt: newDate(), updateId: newUuidV7(), + permissions: [], ...dto, }) .sharedBy({ id: sharedById }) diff --git a/server/test/factories/user.factory.ts b/server/test/factories/user.factory.ts index 125ce91e86..b8acbb8228 100644 --- a/server/test/factories/user.factory.ts +++ b/server/test/factories/user.factory.ts @@ -35,6 +35,7 @@ export class UserFactory { status: UserStatus.Active, profileChangedAt: newDate(), updateId: newUuidV7(), + trustedGroupId: newUuid(), ...dto, }); } diff --git a/server/test/repositories/access.repository.mock.ts b/server/test/repositories/access.repository.mock.ts index f723113bd1..3f698e17ee 100644 --- a/server/test/repositories/access.repository.mock.ts +++ b/server/test/repositories/access.repository.mock.ts @@ -21,6 +21,7 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { checkAlbumAccess: vitest.fn().mockResolvedValue(new Set()), checkPartnerAccess: vitest.fn().mockResolvedValue(new Set()), checkSharedLinkAccess: vitest.fn().mockResolvedValue(new Set()), + checkSharedAccess: vitest.fn().mockResolvedValue(new Set()), }, album: { @@ -48,6 +49,8 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => { person: { checkFaceOwnerAccess: vitest.fn().mockResolvedValue(new Set()), checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()), + checkSharedAccess: vitest.fn().mockResolvedValue(new Set()), + checkSharedFaceAccess: vitest.fn().mockResolvedValue(new Set()), }, partner: { diff --git a/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte b/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte index 75504765e2..967af65e95 100644 --- a/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte +++ b/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte @@ -26,12 +26,13 @@ import { Route } from '$lib/route'; import { getGlobalActions } from '$lib/services/app.service'; import { getAssetActions } from '$lib/services/asset.service'; - import { getSharedLink, withoutIcons } from '$lib/utils'; + import { getSharedLink, hasPermissions, withoutIcons } from '$lib/utils'; import type { OnUndoDelete } from '$lib/utils/actions'; import { toTimelineAsset } from '$lib/utils/timeline-util'; import { AssetTypeEnum, AssetVisibility, + SharingPermission, type AlbumResponseDto, type AssetResponseDto, type PersonResponseDto, @@ -141,7 +142,7 @@ - {#if isOwner} + {#if hasPermissions(asset, SharingPermission.AssetDelete)} {/if} @@ -159,7 +160,7 @@ {/if} - {#if album && (isOwner || isAlbumOwner)} + {#if album && (hasPermissions(asset, SharingPermission.AssetShare) || isAlbumOwner)} {/if} @@ -187,7 +188,7 @@ {/if} {#if !isLocked} - {#if isOwner} + {#if hasPermissions(asset, SharingPermission.AssetUpdate)} {#if !asset.isArchived && !asset.isTrashed} {/if} - {#if isOwner} + {#if hasPermissions(asset, SharingPermission.AssetUpdate)}
diff --git a/web/src/lib/components/asset-viewer/DetailPanel.svelte b/web/src/lib/components/asset-viewer/DetailPanel.svelte index aa32f825a0..271cc41ba0 100644 --- a/web/src/lib/components/asset-viewer/DetailPanel.svelte +++ b/web/src/lib/components/asset-viewer/DetailPanel.svelte @@ -3,6 +3,7 @@ import DetailPanelDate from '$lib/components/asset-viewer/DetailPanelDate.svelte'; import DetailPanelDescription from '$lib/components/asset-viewer/DetailPanelDescription.svelte'; import DetailPanelLocation from '$lib/components/asset-viewer/DetailPanelLocation.svelte'; + import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte'; import DetailPanelRating from '$lib/components/asset-viewer/DetailPanelStarRating.svelte'; import DetailPanelTags from '$lib/components/asset-viewer/DetailPanelTags.svelte'; import { timeToLoadTheMap } from '$lib/constants'; @@ -11,7 +12,7 @@ import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte'; import { Route } from '$lib/route'; import { locale } from '$lib/stores/preferences.store'; - import { getAssetMediaUrl } from '$lib/utils'; + import { getAssetMediaUrl, hasPermissions } from '$lib/utils'; import { delay, getDimensions } from '$lib/utils/asset-utils'; import { getByteUnitString } from '$lib/utils/byte-units'; import { handleError } from '$lib/utils/handle-error'; @@ -20,6 +21,7 @@ AssetMediaSize, getAllAlbums, getAssetInfo, + SharingPermission, type AlbumResponseDto, type AssetResponseDto, } from '@immich/sdk'; @@ -32,7 +34,6 @@ import OnEvents from '../OnEvents.svelte'; import UserAvatar from '../shared-components/UserAvatar.svelte'; import AlbumListItemDetails from './AlbumListItemDetails.svelte'; - import DetailPanelPeople from '$lib/components/asset-viewer/DetailPanelPeople.svelte'; interface Props { asset: AssetResponseDto; @@ -42,6 +43,7 @@ let { asset, currentAlbum = null }: Props = $props(); let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId); + const allowExifUpdate = $derived(hasPermissions(asset, SharingPermission.AssetUpdate, SharingPermission.ExifRead)); let latlng = $derived( (() => { const lat = asset.exifInfo?.latitude; @@ -147,9 +149,9 @@ {/if} - - - + + +
{#if asset.exifInfo} @@ -160,7 +162,7 @@ {$t('no_exif_info_available')} {/if} - +
@@ -168,7 +170,7 @@

{asset.originalFileName} - {#if isOwner} + {#if allowExifUpdate} {/if} - +

diff --git a/web/src/lib/components/asset-viewer/DetailPanelDate.svelte b/web/src/lib/components/asset-viewer/DetailPanelDate.svelte index 65578735ef..085a437d5d 100644 --- a/web/src/lib/components/asset-viewer/DetailPanelDate.svelte +++ b/web/src/lib/components/asset-viewer/DetailPanelDate.svelte @@ -10,9 +10,10 @@ type Props = { asset: AssetResponseDto; + allowExifUpdate: boolean; }; - const { asset }: Props = $props(); + const { asset, allowExifUpdate }: Props = $props(); const timeZone = $derived(asset.exifInfo?.timeZone ?? undefined); const dateTime = $derived( @@ -20,13 +21,8 @@ ? fromISODateTime(asset.exifInfo.dateTimeOriginal, timeZone) : fromISODateTimeUTC(asset.localDateTime), ); - const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id); const handleChangeDate = async () => { - if (!isOwner) { - return; - } - await modalManager.show(AssetChangeDateModal, { asset: toTimelineAsset(asset), initialDate: dateTime, @@ -40,8 +36,8 @@ type="button" class="flex w-full place-items-start justify-between gap-4 py-4 text-start" onclick={handleChangeDate} - title={isOwner ? $t('edit_date') : ''} - class:hover:text-primary={isOwner} + title={allowExifUpdate ? $t('edit_date') : ''} + class:hover:text-primary={allowExifUpdate} data-testid="detail-panel-edit-date-button" >
@@ -68,13 +64,13 @@
- {#if isOwner} + {#if allowExifUpdate}
{/if} -{:else if !dateTime && isOwner} +{:else if !dateTime && allowExifUpdate}
diff --git a/web/src/lib/components/asset-viewer/DetailPanelDescription.svelte b/web/src/lib/components/asset-viewer/DetailPanelDescription.svelte index d8aea0f9eb..20649ddc34 100644 --- a/web/src/lib/components/asset-viewer/DetailPanelDescription.svelte +++ b/web/src/lib/components/asset-viewer/DetailPanelDescription.svelte @@ -8,10 +8,10 @@ interface Props { asset: AssetResponseDto; - isOwner: boolean; + allowExifUpdate: boolean; } - let { asset, isOwner }: Props = $props(); + let { asset, allowExifUpdate }: Props = $props(); let description = $derived(asset.exifInfo?.description ?? ''); @@ -29,7 +29,7 @@ }; -{#if isOwner} +{#if allowExifUpdate}