diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index cf702f62ff..fa23faf604 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 @@ -551,6 +553,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) @@ -649,6 +653,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 1e819595fc..83730f9e68 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -299,6 +299,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'; @@ -397,6 +399,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 d08d1cba9d..b13cc642e5 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -571,6 +571,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. @@ -807,4 +864,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_client.dart b/mobile/openapi/lib/api_client.dart index 5a4b7b75c7..8b2840a9a5 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -644,6 +644,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': @@ -840,6 +844,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 3b36b23d6c..866def6984 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -169,6 +169,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 324d12fcbf..734c2dd21a 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 [], @@ -137,6 +138,8 @@ class AssetResponseDto { List people; + List permissions; + /// Is resized /// /// Please note: This property should have been non-nullable! Since the specification file @@ -193,6 +196,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) && @@ -230,6 +234,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, unassignedFaces=$unassignedFaces, 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, unassignedFaces=$unassignedFaces, 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 { @@ -364,6 +370,7 @@ class AssetResponseDto { owner: UserResponseDto.fromJson(json[r'owner']), ownerId: mapValueOfType(json, r'ownerId')!, people: PersonWithFacesResponseDto.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']), @@ -439,6 +446,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 08f70569f8..5f2f33a486 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -64,6 +64,7 @@ class JobName { static const personCleanup = JobName._(r'PersonCleanup'); static const personFileMigration = JobName._(r'PersonFileMigration'); static const personGenerateThumbnail = JobName._(r'PersonGenerateThumbnail'); + static const personGroupMerge = JobName._(r'PersonGroupMerge'); static const sessionCleanup = JobName._(r'SessionCleanup'); static const sendMail = JobName._(r'SendMail'); static const sidecarQueueAll = JobName._(r'SidecarQueueAll'); @@ -122,6 +123,7 @@ class JobName { personCleanup, personFileMigration, personGenerateThumbnail, + personGroupMerge, sessionCleanup, sendMail, sidecarQueueAll, @@ -215,6 +217,7 @@ class JobNameTypeTransformer { case r'PersonCleanup': return JobName.personCleanup; case r'PersonFileMigration': return JobName.personFileMigration; case r'PersonGenerateThumbnail': return JobName.personGenerateThumbnail; + case r'PersonGroupMerge': return JobName.personGroupMerge; case r'SessionCleanup': return JobName.sessionCleanup; case r'SendMail': return JobName.sendMail; case r'SidecarQueueAll': return JobName.sidecarQueueAll; 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..be3e485986 --- /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 exifPeriodUpdate = SharingPermission._(r'exif.update'); + static const personPeriodRead = SharingPermission._(r'person.read'); + static const personPeriodCreate = SharingPermission._(r'person.create'); + static const personPeriodMerge = SharingPermission._(r'person.merge'); + + /// List of all possible values in this [enum][SharingPermission]. + static const values = [ + all, + assetPeriodRead, + assetPeriodUpdate, + assetPeriodEdit, + assetPeriodDelete, + assetPeriodShare, + exifPeriodRead, + exifPeriodUpdate, + personPeriodRead, + personPeriodCreate, + personPeriodMerge, + ]; + + 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'exif.update': return SharingPermission.exifPeriodUpdate; + case r'person.read': return SharingPermission.personPeriodRead; + case r'person.create': return SharingPermission.personPeriodCreate; + case r'person.merge': return SharingPermission.personPeriodMerge; + 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/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 ac1de35252..022b4a9733 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2289,6 +2289,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.", @@ -16742,6 +16857,12 @@ }, "type": "array" }, + "permissions": { + "items": { + "$ref": "#/components/schemas/SharingPermission" + }, + "type": "array" + }, "resized": { "description": "Is resized", "type": "boolean", @@ -16818,6 +16939,7 @@ "originalFileName", "originalPath", "ownerId", + "permissions", "thumbhash", "type", "updatedAt", @@ -17887,6 +18009,7 @@ "PersonCleanup", "PersonFileMigration", "PersonGenerateThumbnail", + "PersonGroupMerge", "SessionCleanup", "SendMail", "SidecarQueueAll", @@ -21838,6 +21961,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", + "exif.update", + "person.read", + "person.create", + "person.merge" + ], + "type": "string" + }, "SignUpDto": { "properties": { "email": { @@ -25299,6 +25457,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/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 40c4bec235..7d42cd2b34 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-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; }; @@ -893,6 +901,7 @@ export type AssetResponseDto = { /** Owner user ID */ ownerId: string; people?: PersonWithFacesResponseDto[]; + permissions: SharingPermission[]; /** Is resized */ resized?: boolean; stack?: (AssetStackResponseDto) | null; @@ -3769,6 +3778,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 */ @@ -6756,6 +6791,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", + ExifUpdate = "exif.update", + PersonRead = "person.read", + PersonCreate = "person.create", + PersonMerge = "person.merge" +} export enum Permission { All = "all", ActivityCreate = "activity.create", @@ -7072,6 +7120,7 @@ export enum JobName { PersonCleanup = "PersonCleanup", PersonFileMigration = "PersonFileMigration", PersonGenerateThumbnail = "PersonGenerateThumbnail", + PersonGroupMerge = "PersonGroupMerge", SessionCleanup = "SessionCleanup", SendMail = "SendMail", SidecarQueueAll = "SidecarQueueAll", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fa8af285b..61cd484928 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -345,8 +345,8 @@ importers: specifier: 2.0.0-rc13 version: 2.0.0-rc13 '@immich/sql-tools': - specifier: ^0.5.1 - version: 0.5.1 + specifier: ^0.5.2 + version: 0.5.2 '@nestjs/bullmq': specifier: ^11.0.1 version: 11.0.4(@nestjs/common@11.1.19(class-transformer@0.5.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(bullmq@5.74.1) @@ -3015,8 +3015,8 @@ packages: '@immich/justified-layout-wasm@0.4.3': resolution: {integrity: sha512-fpcQ7zPhP3Cp1bEXhONVYSUeIANa2uzaQFGKufUZQo5FO7aFT77szTVChhlCy4XaVy5R4ZvgSkA/1TJmeORz7Q==} - '@immich/sql-tools@0.5.1': - resolution: {integrity: sha512-1yb5w8IS0PIVgTZ75fAsbaH1JowNNB7d6h0h8ZLQt32Y35xBzmZef/IL9LVAWnWBObzwWi12+RLcg0gkMS6dpA==} + '@immich/sql-tools@0.5.2': + resolution: {integrity: sha512-O1SJ+BbeFVsUTF4af1MfagJZM+lPgLjI8lQ3SZNjpo8SGJReSbUl2ii03OKuGni/G0yp2GnRLpOTNSHYGtVrcg==} hasBin: true '@immich/svelte-markdown-preprocess@0.4.1': @@ -15337,7 +15337,7 @@ snapshots: '@immich/justified-layout-wasm@0.4.3': {} - '@immich/sql-tools@0.5.1': + '@immich/sql-tools@0.5.2': dependencies: commander: 14.0.3 graph-data-structure: 4.5.0 diff --git a/server/package.json b/server/package.json index 7eab3b4842..0a920bd631 100644 --- a/server/package.json +++ b/server/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@extism/extism": "2.0.0-rc13", - "@immich/sql-tools": "^0.5.1", + "@immich/sql-tools": "^0.5.2", "@nestjs/bullmq": "^11.0.1", "@nestjs/common": "^11.0.4", "@nestjs/core": "^11.0.4", 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/database.ts b/server/src/database.ts index c001388e79..592404b02e 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -11,6 +11,7 @@ import { PluginContext, PluginTriggerType, SharedLinkType, + SharingPermission, SourceType, UserAvatarColor, UserStatus, @@ -213,6 +214,7 @@ export type Partner = { updatedAt: Date; updateId: string; inTimeline: boolean; + permissions: SharingPermission[]; }; export type Place = { diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 33870cd6fc..b09c557d68 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({ shared: stringToBool @@ -144,6 +152,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 faa1db4afb..c2e9695dc4 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -21,6 +21,8 @@ import { AssetVisibility, AssetVisibilitySchema, ChecksumAlgorithm, + SharingPermission, + SharingPermissionSchema, } from 'src/enum'; import { ImageDimensions, MaybeDehydrated } from 'src/types'; import { getDimensions } from 'src/utils/asset.util'; @@ -52,6 +54,7 @@ const SanitizedAssetResponseSchema = z hasMetadata: z.boolean().describe('Whether asset has metadata'), width: z.number().min(0).nullable().describe('Asset width'), height: z.number().min(0).nullable().describe('Asset height'), + permissions: z.array(SharingPermissionSchema), }) .meta({ id: 'SanitizedAssetResponseDto' }); @@ -121,6 +124,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' }); @@ -162,6 +166,7 @@ export type MapAsset = { width: number | null; height: number | null; isEdited: boolean; + permissions?: { permission: SharingPermission }[]; }; export type AssetMapOptions = { @@ -213,8 +218,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, @@ -226,6 +239,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt hasMetadata: false, width: entity.width, height: entity.height, + permissions, }; return sanitizedAssetResponse as AssetResponseDto; } @@ -268,5 +282,6 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt width: entity.width, height: entity.height, isEdited: entity.isEdited, + permissions, }; } diff --git a/server/src/enum.ts b/server/src/enum.ts index 8a1993b48f..a904c8c109 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -299,6 +299,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', + ExifUpdate = 'exif.update', + + PersonRead = 'person.read', + PersonCreate = 'person.create', + PersonMerge = 'person.merge', +} + +export const SharingPermissionSchema = z + .enum(SharingPermission) + .describe('Sharing permission schema') + .meta({ id: 'SharingPermission' }); + export enum SharedLinkType { Album = 'ALBUM', @@ -702,6 +724,7 @@ export enum JobName { PersonCleanup = 'PersonCleanup', PersonFileMigration = 'PersonFileMigration', PersonGenerateThumbnail = 'PersonGenerateThumbnail', + PersonGroupMerge = 'PersonGroupMerge', SessionCleanup = 'SessionCleanup', 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 ebc2de90e1..3798fbdfba 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -290,13 +290,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/person.repository.sql b/server/src/queries/person.repository.sql index 318c151cca..3ae8ca11a3 100644 --- a/server/src/queries/person.repository.sql +++ b/server/src/queries/person.repository.sql @@ -24,8 +24,8 @@ limit 3 -- PersonRepository.getAllForUser -select - "person".* +select distinct + on ("person"."groupId") "person".* from "person" inner join "asset_face" on "asset_face"."personId" = "person"."id" @@ -33,18 +33,49 @@ from 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 "partner"."permissions" @> $3 + ) + or exists ( + select + from + "album_user" + where + "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = $4 + ) + and "album_user"."userId" = "person"."ownerId" + and "album_user"."permissions" @> $5 + ) + ) + ) and "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "person"."isHidden" = $2 + and "person"."isHidden" = $6 group by "person"."id" having ( - "person"."name" != $3 - or count("asset_face"."assetId") >= $4 + "person"."name" != $7 + or count("asset_face"."assetId") >= $8 ) order by + "person"."groupId", + "person"."ownerId" = $9 desc, "person"."isHidden" asc, "person"."isFavorite" desc, NULLIF(person.name, '') is null asc, @@ -52,9 +83,9 @@ order by NULLIF(person.name, '') asc nulls last, "person"."createdAt" limit - $5 + $10 offset - $6 + $11 -- PersonRepository.getAllWithoutFaces select @@ -234,9 +265,39 @@ 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 "partner"."permissions" @> $3 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $4 + 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 "album_user"."permissions" @> $5 + ) + ) + ) + and "asset_face"."deletedAt" is null and "asset_face"."isVisible" is true - and "asset_face"."personId" = $1 + and "asset_face"."personId" = $6 -- PersonRepository.getNumberOfPeople select @@ -269,7 +330,36 @@ 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 "partner"."permissions" @> $5 + ) + or exists ( + select + from + "album_user" + where + "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = $6 + ) + and "album_user"."userId" = "person"."ownerId" + and "album_user"."permissions" @> $7 + ) + ) + ) -- PersonRepository.refreshFaces with @@ -356,3 +446,26 @@ from where "asset_face"."assetId" = $2 and "asset_face"."personId" = $3 + +-- PersonRepository.mergeIntoGroup +update "person" +set + "groupId" = "p"."groupId" +from + "person" as "p" +where + "person"."id" = any ($1::uuid[]) + and "p"."id" = $2 + +-- PersonRepository.streamForPeopleMerge +select + "asset"."ownerId", + "asset"."fileCreatedAt", + "asset_face"."personId", + "face_search"."embedding" +from + "asset_face" + inner join "asset" on "asset"."id" = "asset_face"."assetId" + inner join "face_search" on "face_search"."faceId" = "asset_face"."id" +where + "asset_face"."personId" is not null diff --git a/server/src/queries/search.repository.sql b/server/src/queries/search.repository.sql index 3e75d88af8..ca1f6c1692 100644 --- a/server/src/queries/search.repository.sql +++ b/server/src/queries/search.repository.sql @@ -10,15 +10,46 @@ 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 "partner"."permissions" @> $6 + and "partner"."inTimeline" = $7 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $8 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $9 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and "album_user"."permissions" @> $10 + ) + ) + ) + and "asset"."isFavorite" = $11 and "asset"."deletedAt" is null order by "asset"."fileCreatedAt" desc limit - $6 + $12 offset - $7 + $13 -- SearchRepository.searchStatistics select @@ -30,8 +61,39 @@ 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 "partner"."permissions" @> $6 + and "partner"."inTimeline" = $7 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $8 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $9 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and "album_user"."permissions" @> $10 + ) + ) + ) + and "asset"."isFavorite" = $11 and "asset"."deletedAt" is null -- SearchRepository.searchRandom @@ -44,13 +106,44 @@ 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 "partner"."permissions" @> $6 + and "partner"."inTimeline" = $7 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $8 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $9 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and "album_user"."permissions" @> $10 + ) + ) + ) + and "asset"."isFavorite" = $11 and "asset"."deletedAt" is null order by random() limit - $6 + $12 -- SearchRepository.searchLargeAssets select @@ -63,14 +156,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 "partner"."permissions" @> $6 + and "partner"."inTimeline" = $7 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $8 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $9 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and "album_user"."permissions" @> $10 + ) + ) + ) + and "asset"."isFavorite" = $11 and "asset"."deletedAt" is null - and "asset_exif"."fileSizeInByte" > $6 + and "asset_exif"."fileSizeInByte" > $12 order by "asset_exif"."fileSizeInByte" desc limit - $7 + $13 -- SearchRepository.searchSmart begin @@ -86,15 +210,46 @@ 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 "partner"."permissions" @> $6 + and "partner"."inTimeline" = $7 + ) + or exists ( + select + from + "album_asset" + inner join "album_user" on "album_user"."albumId" = "album_asset"."albumId" + and "album_user"."userId" = $8 + where + "album_asset"."assetId" = "asset"."id" + and "album_user"."inTimeline" = $9 + and "album_user"."albumId" in ( + select + "album_user"."albumId" + from + "album_user" + where + "album_user"."userId" = "asset"."ownerId" + and "album_user"."permissions" @> $10 + ) + ) + ) + and "asset"."isFavorite" = $11 and "asset"."deletedAt" is null order by - smart_search.embedding <=> $6 + smart_search.embedding <=> $12 limit - $7 + $13 offset - $8 + $14 commit -- SearchRepository.getEmbedding diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index c5a4f139a7..2f074fe00c 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -397,3 +397,36 @@ 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" + ) 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 784cf68b5b..1cc1cd903f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -17,7 +17,7 @@ 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, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetOrder, AssetStatus, AssetType, AssetVisibility, SharingPermission } from 'src/enum'; import { DB } from 'src/schema'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; import { AssetFileTable } from 'src/schema/tables/asset-file.table'; @@ -40,6 +40,7 @@ import { withFiles, withLibrary, withOwner, + withPermissions, withSmartSearch, withTagId, withTags, @@ -155,6 +156,37 @@ 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('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('album_user.permissions', '@>', eb.val(permissions)), + ), + ), + ]); + @Injectable() export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -485,10 +517,11 @@ 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') @@ -531,6 +564,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(); } @@ -673,7 +707,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) => @@ -757,7 +793,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 a94e5aa9f6..b7d0c7736d 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,13 +170,13 @@ 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 }; if (job.options?.jobId) { // need to use add() instead of addBulk() for jobId deduplication - promises.push(this.getQueue(queueName).add(item.name, item.data, job.options)); + promises.push(this.getQueue(queueName).add(item.name, job.data, job.options)); } else { itemsByQueue[queueName] = itemsByQueue[queueName] || []; itemsByQueue[queueName].push(job); diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 2a9f822e94..22123c4a6a 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -1,15 +1,16 @@ import { Injectable } from '@nestjs/common'; -import { ExpressionBuilder, Insertable, Kysely, Selectable, sql, Updateable } from 'kysely'; +import { ExpressionBuilder, Insertable, Kysely, NotNull, Selectable, sql, Updateable } from 'kysely'; 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'; import { PersonTable } from 'src/schema/tables/person.table'; -import { dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database'; +import { anyUuid, dummy, removeUndefinedKeys, withFilePath } from 'src/utils/database'; import { paginationHelper, PaginationOptions } from 'src/utils/pagination'; export interface PersonSearchOptions { @@ -75,6 +76,27 @@ 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('partner.permissions', '@>', sql.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('album_user.permissions', '@>', sql.val(permissions)), + ), + ]); + @Injectable() export class PersonRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -153,6 +175,7 @@ export class PersonRepository { const items = await this.db .selectFrom('person') .selectAll('person') + .distinctOn('person.groupId') .innerJoin('asset_face', 'asset_face.personId', 'person.id') .innerJoin('asset', (join) => join @@ -160,9 +183,13 @@ export class PersonRepository { .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.groupId') + .orderBy((eb) => eb('person.ownerId', '=', userId), 'desc') .orderBy('person.isHidden', 'asc') .orderBy('person.isFavorite', 'desc') .having((eb) => @@ -335,7 +362,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,6 +371,7 @@ 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) @@ -378,7 +406,9 @@ 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(); @@ -577,4 +607,32 @@ export class PersonRepository { .innerJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_face.assetId').on('asset.isOffline', '=', false)) .executeTakeFirst(); } + + @GenerateSql({ params: [DummyValue.UUID, [DummyValue.UUID]] }) + async mergeIntoGroup(personId: string, peopleIds: string[]) { + await this.db + .updateTable('person') + .where('person.id', '=', anyUuid(peopleIds)) + .from('person as p') + .where('p.id', '=', personId) + .set((eb) => ({ + groupId: eb.ref('p.groupId'), + })) + .execute(); + } + + @GenerateSql({ params: [], stream: true }) + streamForPeopleMerge() { + return ( + this.db + .selectFrom('asset_face') + .innerJoin('asset', 'asset.id', 'asset_face.assetId') + .innerJoin('face_search', 'face_search.faceId', 'asset_face.id') + .select(['asset.ownerId', 'asset.fileCreatedAt', 'asset_face.personId', 'face_search.embedding']) + .where('asset_face.personId', 'is not', null) + // TODO remove with kysely 0.29 + .$narrowType<{ personId: NotNull }>() + .stream() + ); + } } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 6f03c80ce1..f0379ce5fa 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely'; +import { Kysely, NotNull, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import { DummyValue, GenerateSql } from 'src/decorators'; import { AssetStatus, AssetType, AssetVisibility, VectorIndex } from 'src/enum'; @@ -349,6 +349,41 @@ export class SearchRepository { }); } + searchPeople({ + userIds, + embedding, + maxDistance, + minBirthDate, + }: Omit) { + return this.db.transaction().execute(async (trx) => { + await sql`set local vchordrq.probes = ${sql.lit(probes[VectorIndex.Face])}`.execute(trx); + return await trx + .with('cte', (qb) => + qb + .selectFrom('asset_face') + .select(['asset_face.personId', sql`face_search.embedding <=> ${embedding}`.as('distance')]) + .innerJoin('asset', 'asset.id', 'asset_face.assetId') + .innerJoin('face_search', 'face_search.faceId', 'asset_face.id') + .leftJoin('person', 'person.id', 'asset_face.personId') + .where('asset.ownerId', '=', anyUuid(userIds)) + .where('asset.deletedAt', 'is', null) + .where('asset_face.personId', 'is not', null) + .$narrowType<{ personId: NotNull }>() + .$if(!!minBirthDate, (qb) => + qb.where((eb) => + eb.or([eb('person.birthDate', 'is', null), eb('person.birthDate', '<=', minBirthDate!)]), + ), + ) + .orderBy('distance'), + ) + .selectFrom('cte') + .select('cte.personId') + .groupBy('cte.personId') + .where('cte.distance', '<=', maxDistance) + .execute(); + }); + } + @GenerateSql({ params: [DummyValue.STRING] }) searchPlaces(placeName: string) { return this.db diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 20b41c80f8..c8e59dd300 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -325,4 +325,35 @@ 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(); + } } diff --git a/server/src/schema/enums.ts b/server/src/schema/enums.ts index 2bfa4a3340..5e4b8e4334 100644 --- a/server/src/schema/enums.ts +++ b/server/src/schema/enums.ts @@ -1,5 +1,12 @@ import { registerEnum } from '@immich/sql-tools'; -import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum'; +import { + AlbumUserRole, + AssetStatus, + AssetVisibility, + ChecksumAlgorithm, + SharingPermission, + SourceType, +} from 'src/enum'; export const album_user_role_enum = registerEnum({ name: 'album_user_role_enum', @@ -25,3 +32,8 @@ export const asset_checksum_algorithm_enum = registerEnum({ name: 'asset_checksum_algorithm_enum', values: Object.values(ChecksumAlgorithm), }); + +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 618df795a2..38c53ee6d8 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, @@ -161,7 +162,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 { diff --git a/server/src/schema/migrations/1776872570643-AddPermissionsToPartnerAndAlbumUser.ts b/server/src/schema/migrations/1776872570643-AddPermissionsToPartnerAndAlbumUser.ts new file mode 100644 index 0000000000..a7cfe60fad --- /dev/null +++ b/server/src/schema/migrations/1776872570643-AddPermissionsToPartnerAndAlbumUser.ts @@ -0,0 +1,13 @@ +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','exif.update','person.read','person.create','person.merge');`.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 "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 "album_user" DROP COLUMN "permissions";`.execute(db); +} diff --git a/server/src/schema/migrations/1776959089718-PersonGroupId.ts b/server/src/schema/migrations/1776959089718-PersonGroupId.ts new file mode 100644 index 0000000000..5bad40cd10 --- /dev/null +++ b/server/src/schema/migrations/1776959089718-PersonGroupId.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "person" ADD "groupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "person" DROP COLUMN "groupId";`.execute(db); +} diff --git a/server/src/schema/migrations/1777024276526-UserTrustedClustersId.ts b/server/src/schema/migrations/1777024276526-UserTrustedClustersId.ts new file mode 100644 index 0000000000..e684b4693b --- /dev/null +++ b/server/src/schema/migrations/1777024276526-UserTrustedClustersId.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "user" ADD "trustedGroupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "user" DROP COLUMN "trustedGroupId";`.execute(db); +} diff --git a/server/src/schema/migrations/1777040966568-AddAlbumUserInTimeline.ts b/server/src/schema/migrations/1777040966568-AddAlbumUserInTimeline.ts new file mode 100644 index 0000000000..73e7d68b4e --- /dev/null +++ b/server/src/schema/migrations/1777040966568-AddAlbumUserInTimeline.ts @@ -0,0 +1,9 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`ALTER TABLE "album_user" ADD "inTimeline" boolean NOT NULL DEFAULT false;`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "album_user" DROP COLUMN "inTimeline";`.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/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..ae034f0d70 100644 --- a/server/src/schema/tables/person.table.ts +++ b/server/src/schema/tables/person.table.ts @@ -5,6 +5,7 @@ import { CreateDateColumn, ForeignKeyColumn, Generated, + GeneratedColumn, Index, PrimaryGeneratedColumn, Table, @@ -66,4 +67,7 @@ export class PersonTable { @UpdateIdColumn({ index: true }) updateId!: Generated; + + @GeneratedColumn('uuid') + groupId!: Generated; } 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 ef8a31dcb5..1460b94dc7 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -9,13 +9,15 @@ import { GetAlbumsDto, mapAlbum, MapAlbumDto, + 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, JobName, 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'; @@ -138,6 +140,16 @@ export class AlbumService extends BaseService { ); for (const { userId } of albumUsers) { + const { numUpdatedRows } = await this.userRepository.mergeTrustedGroups({ + userId: auth.user.id, + userIdToMerge: userId, + }); + + if (numUpdatedRows > 0) { + this.logger.log(`Merged trusted group of ${userId} into ${auth.user.id}`); + await this.jobRepository.queue({ name: JobName.PersonGroupMerge }); + } + await this.eventRepository.emit('AlbumInvite', { id: album.id, userId, senderName: auth.user.name }); } @@ -306,7 +318,22 @@ export class AlbumService extends BaseService { throw new BadRequestException('User not found'); } - await this.albumUserRepository.create({ userId, albumId: id, role }); + const { numUpdatedRows } = await this.userRepository.mergeTrustedGroups({ + userId: auth.user.id, + userIdToMerge: userId, + }); + await this.albumUserRepository.create({ + userId, + albumId: id, + role, + permissions: [SharingPermission.AssetRead, SharingPermission.ExifRead], + }); + + if (numUpdatedRows > 0) { + this.logger.log(`Merged trusted group of ${userId} into ${auth.user.id}`); + await this.jobRepository.queue({ name: JobName.PersonGroupMerge }); + } + await this.eventRepository.emit('AlbumInvite', { id, userId, senderName: auth.user.name }); } @@ -345,6 +372,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 613029fe3c..3450485bb3 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..c16bf817d9 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,16 @@ 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.PersonGroupMerge }); + } + return this.mapPartner(partner, PartnerDirection.SharedBy); } diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index fde5313f4d..94f4467f16 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -159,7 +159,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 { @@ -542,6 +542,25 @@ export class PersonService extends BaseService { return JobStatus.Success; } + @OnJob({ name: JobName.PersonGroupMerge, queue: QueueName.FacialRecognition }) + async handlePersonGroupMerge() { + const { machineLearning } = await this.getConfig({ withCache: true }); + + for await (const face of this.personRepository.streamForPeopleMerge()) { + const userIds = await this.userRepository.getInSameTrustedGroup(face.ownerId); + const peopleToGroup = await this.searchRepository.searchPeople({ + userIds, + embedding: face.embedding, + maxDistance: machineLearning.facialRecognition.maxDistance, + minBirthDate: new Date(face.fileCreatedAt), + }); + await this.personRepository.mergeIntoGroup( + face.personId, + peopleToGroup.map(({ personId }) => personId), + ); + } + } + @OnJob({ name: JobName.PersonFileMigration, queue: QueueName.Migration }) async handlePersonMigration({ id }: JobOf): Promise { const person = await this.personRepository.getById(id); diff --git a/server/src/services/search.service.ts b/server/src/services/search.service.ts index 9a6f8321a9..8a56ec1757 100644 --- a/server/src/services/search.service.ts +++ b/server/src/services/search.service.ts @@ -192,6 +192,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 179f9d1b61..8c7800ab9c 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -170,7 +170,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 { @@ -329,6 +331,7 @@ export type JobItem = | { name: JobName.FacialRecognitionQueueAll; data: INightlyJob } | { name: JobName.FacialRecognition; data: IDeferrableJob } | { name: JobName.PersonGenerateThumbnail; data: IEntityJob } + | { name: JobName.PersonGroupMerge } // Smart Search | { name: JobName.SmartSearchQueueAll; data: IBaseJob } diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index b8e7733772..887e72c5dc 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.AssetShare]); + 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,27 @@ 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: + case Permission.PersonDelete: { + return access.person.checkOwnerAccess(auth.user.id, ids); } case Permission.PersonReassign: { @@ -339,3 +369,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 05b1e0b199..74a1227f6c 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -17,7 +17,8 @@ import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres'; import { Notice, PostgresError } from 'postgres'; import { columns, lockableProperties, LockableProperty, Person } from 'src/database'; import { AssetEditActionItem } from 'src/dtos/editing.dto'; -import { AssetFileType, AssetVisibility, DatabaseExtension } from 'src/enum'; +import { AssetFileType, AssetVisibility, DatabaseExtension, 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'; @@ -223,6 +224,30 @@ export function withTags(eb: ExpressionBuilder) { ).as('tags'); } +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 truncatedDate() { return sql`date_trunc(${sql.lit('MONTH')}, "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`; } @@ -353,7 +378,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/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte b/web/src/lib/components/asset-viewer/AssetViewerNavBar.svelte index 5098a78619..2ffbc7fa34 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 f46ddee4d6..c7e4c35056 100644 --- a/web/src/lib/components/asset-viewer/DetailPanel.svelte +++ b/web/src/lib/components/asset-viewer/DetailPanel.svelte @@ -12,7 +12,7 @@ import { Route } from '$lib/route'; import { boundingBoxesArray } from '$lib/stores/people.store'; import { locale } from '$lib/stores/preferences.store'; - import { getAssetMediaUrl, getPeopleThumbnailUrl } from '$lib/utils'; + import { getAssetMediaUrl, getPeopleThumbnailUrl, 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'; @@ -21,6 +21,7 @@ AssetMediaSize, getAllAlbums, getAssetInfo, + SharingPermission, type AlbumResponseDto, type AssetResponseDto, } from '@immich/sdk'; @@ -54,6 +55,7 @@ let { asset, currentAlbum = null }: Props = $props(); let isOwner = $derived(authManager.authenticated && authManager.user.id === asset.ownerId); + const allowExifUpdate = $derived(hasPermissions(asset, SharingPermission.ExifUpdate)); let people = $derived(asset.people || []); let unassignedFaces = $derived(asset.unassignedFaces || []); let showingHiddenPeople = $state(false); @@ -162,10 +164,10 @@ {/if} - - + + - {#if !authManager.isSharedLink && isOwner} + {#if !authManager.isSharedLink && hasPermissions(asset, SharingPermission.PersonRead)}
{$t('people')} @@ -276,7 +278,7 @@ {$t('no_exif_info_available')} {/if} - +
@@ -284,7 +286,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 f5e85112bc..fe3a1c42bf 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 text-start justify-between place-items-start gap-4 py-4" 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 e766975b8c..a0d8bac6dd 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}