feat: sharing permissions

This commit is contained in:
Daniel Dietzler 2026-04-01 13:10:22 +02:00
parent 4bfb8b36c2
commit e9133801bd
No known key found for this signature in database
GPG Key ID: A1C0B97CD8E18DFF
61 changed files with 1879 additions and 146 deletions

View File

@ -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)

View File

@ -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';

View File

@ -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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
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<SharingOptionsResponseDto?> 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<Response> 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 = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['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<void> updateOwnAlbumUser(String id, UpdateSharingOptionsDto updateSharingOptionsDto,) async {
final response = await updateOwnAlbumUserWithHttpInfo(id, updateSharingOptionsDto,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
}

View File

@ -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':

View File

@ -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();
}

View File

@ -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<PersonWithFacesResponseDto> people;
List<SharingPermission> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -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<String>(json, r'ownerId')!,
people: PersonWithFacesResponseDto.listFromJson(json[r'people']),
permissions: SharingPermission.listFromJson(json[r'permissions']),
resized: mapValueOfType<bool>(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',

View File

@ -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;

View File

@ -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<SharingPermission> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return SharingOptionsResponseDto(
inTimeline: mapValueOfType<bool>(json, r'inTimeline')!,
permissions: SharingPermission.listFromJson(json[r'permissions']),
);
}
return null;
}
static List<SharingOptionsResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SharingOptionsResponseDto>[];
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<String, SharingOptionsResponseDto> mapFromJson(dynamic json) {
final map = <String, SharingOptionsResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<SharingOptionsResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SharingOptionsResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'inTimeline',
'permissions',
};
}

View File

@ -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 = <SharingPermission>[
all,
assetPeriodRead,
assetPeriodUpdate,
assetPeriodEdit,
assetPeriodDelete,
assetPeriodShare,
exifPeriodRead,
exifPeriodUpdate,
personPeriodRead,
personPeriodCreate,
personPeriodMerge,
];
static SharingPermission? fromJson(dynamic value) => SharingPermissionTypeTransformer().decode(value);
static List<SharingPermission> listFromJson(dynamic json, {bool growable = false,}) {
final result = <SharingPermission>[];
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;
}

View File

@ -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<SharingPermission> 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<String, dynamic> toJson() {
final json = <String, dynamic>{};
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<String, dynamic>();
return UpdateSharingOptionsDto(
inTimeline: mapValueOfType<bool>(json, r'inTimeline')!,
permissions: SharingPermission.listFromJson(json[r'permissions']),
);
}
return null;
}
static List<UpdateSharingOptionsDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <UpdateSharingOptionsDto>[];
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<String, UpdateSharingOptionsDto> mapFromJson(dynamic json) {
final map = <String, UpdateSharingOptionsDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // 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<String, List<UpdateSharingOptionsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<UpdateSharingOptionsDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
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 = <String>{
'inTimeline',
'permissions',
};
}

View File

@ -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": {

View File

@ -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",

10
pnpm-lock.yaml generated
View File

@ -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

View File

@ -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",

View File

@ -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<void> {
return this.service.updateSelf(auth, id, dto);
}
@Put(':id/user/:userId')
@Authenticated({ permission: Permission.AlbumUserUpdate })
@HttpCode(HttpStatus.NO_CONTENT)

View File

@ -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 = {

View File

@ -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) {}

View File

@ -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<MapAsset>, 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<MapAsset>, options: AssetMapOpt
hasMetadata: false,
width: entity.width,
height: entity.height,
permissions,
};
return sanitizedAssetResponse as AssetResponseDto;
}
@ -268,5 +282,6 @@ export function mapAsset(entity: MaybeDehydrated<MapAsset>, options: AssetMapOpt
width: entity.width,
height: entity.height,
isEdited: entity.isEdited,
permissions,
};
}

View File

@ -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',

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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"
)

View File

@ -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<string>, 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<SharingPermission[]>`${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<SharingPermission[]>`${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<string>, permissions: SharingPermission[]) {
if (personIds.size === 0) {
return new Set<string>();
}
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<string>, permissions: SharingPermission[]) {
if (faceIds.size === 0) {
return new Set<string>();
}
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 {

View File

@ -38,4 +38,13 @@ export class AlbumUserRepository {
async delete({ userId, albumId }: AlbumPermissionId): Promise<void> {
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();
}
}

View File

@ -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 = <T>(qb: SelectQueryBuilder<DB, 'asset' | 'asset_exif', T
);
};
export const hasAssetPermissions =
(userId: string, permissions: SharingPermission[], ignoreTimelineVisibility: boolean = false) =>
(eb: ExpressionBuilder<DB, 'asset'>) =>
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<DB>) {}
@ -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

View File

@ -15,7 +15,7 @@ import { getKeyByValue, getMethodNames, ImmichStartupError } from 'src/utils/mis
type JobMapItem = {
jobName: JobName;
queueName: QueueName;
handler: (job: JobOf<any>) => Promise<JobStatus>;
handler: (job?: JobOf<any>) => Promise<JobStatus>;
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);

View File

@ -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<DB, 'asset_face'>) => {
).as('faceSearch');
};
export const hasPermissions =
(userId: string, permissions: SharingPermission[]) => (eb: ExpressionBuilder<DB, 'person'>) =>
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<DB>) {}
@ -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<PersonStatistics> {
async getStatistics(userId: string, personId: string): Promise<PersonStatistics> {
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<number>(), zero).as('total'))
.select((eb) => eb.fn.coalesce(eb.fn.countAll<number>().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()
);
}
}

View File

@ -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<FaceEmbeddingSearch, 'numResults' | 'hasPerson'>) {
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<number>`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

View File

@ -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();
}
}

View File

@ -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),
});

View File

@ -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 {

View File

@ -0,0 +1,13 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}

View File

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "person" ADD "groupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "person" DROP COLUMN "groupId";`.execute(db);
}

View File

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "user" ADD "trustedGroupId" uuid NOT NULL DEFAULT uuid_generate_v4();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "user" DROP COLUMN "trustedGroupId";`.execute(db);
}

View File

@ -0,0 +1,9 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" ADD "inTimeline" boolean NOT NULL DEFAULT false;`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "album_user" DROP COLUMN "inTimeline";`.execute(db);
}

View File

@ -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<Timestamp>;
@Column({
array: true,
enum: sharing_permission_enum,
default: [SharingPermission.AssetRead, SharingPermission.ExifRead],
})
permissions!: Generated<SharingPermission[]>;
@Column({ type: 'boolean', default: false })
inTimeline!: Generated<boolean>;
}

View File

@ -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<string>;
@Column({ array: true, enum: sharing_permission_enum, default: [SharingPermission.All] })
permissions!: Generated<SharingPermission[]>;
}

View File

@ -5,6 +5,7 @@ import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
GeneratedColumn,
Index,
PrimaryGeneratedColumn,
Table,
@ -66,4 +67,7 @@ export class PersonTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@GeneratedColumn('uuid')
groupId!: Generated<string>;
}

View File

@ -4,6 +4,7 @@ import {
CreateDateColumn,
DeleteDateColumn,
Generated,
GeneratedColumn,
Index,
PrimaryGeneratedColumn,
Table,
@ -82,4 +83,7 @@ export class UserTable {
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
@GeneratedColumn('uuid')
trustedGroupId!: Generated<string>;
}

View File

@ -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<void> {
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<SharingPermissionsResponseDto> {
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) {

View File

@ -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<AssetResponseDto | SanitizedAssetResponseDto> {
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 = [];
}

View File

@ -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: {

View File

@ -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);
}

View File

@ -159,7 +159,7 @@ export class PersonService extends BaseService {
async getStatistics(auth: AuthDto, id: string): Promise<PersonStatisticsResponseDto> {
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<ImmichFileResponse> {
@ -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<JobName.PersonFileMigration>): Promise<JobStatus> {
const person = await this.personRepository.getById(id);

View File

@ -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];
}

View File

@ -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<T extends JobName> = 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 }

View File

@ -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;
};

View File

@ -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);
}

View File

@ -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<DB, 'asset'>) {
).as('tags');
}
export function withPermissions(userId: string) {
return (eb: ExpressionBuilder<DB, 'asset'>) =>
jsonArrayFrom(
eb
.selectFrom('album_user')
.select((eb) => eb.fn<SharingPermission>('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<SharingPermission>('unnest', ['partner.permissions']).as('permission'))
.distinct()
.whereRef('partner.sharedById', '=', 'asset.ownerId')
.where('partner.sharedWithId', '=', userId),
),
).as('permissions');
}
export function truncatedDate<O>() {
return sql<O>`date_trunc(${sql.lit('MONTH')}, "localDateTime" AT TIME ZONE 'UTC') AT TIME ZONE 'UTC'`;
}
@ -353,7 +378,7 @@ export function searchAssetBuilder(kysely: Kysely<DB>, 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) =>

View File

@ -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 @@
<ActionButton action={Actions.Edit} />
{#if isOwner}
{#if hasPermissions(asset, SharingPermission.AssetDelete)}
<DeleteAction {asset} {onAction} {preAction} {onUndoDelete} />
{/if}
@ -159,7 +160,7 @@
{/if}
<ActionMenuItem action={Actions.AddToAlbum} />
{#if album && (isOwner || isAlbumOwner)}
{#if album && (hasPermissions(asset, SharingPermission.AssetShare) || isAlbumOwner)}
<RemoveFromAlbumAction {album} onRemove={onRemoveFromAlbum} assetIds={[asset.id]} menuItem />
{/if}
@ -187,7 +188,7 @@
{/if}
{#if !isLocked}
{#if isOwner}
{#if hasPermissions(asset, SharingPermission.AssetUpdate)}
<ArchiveAction {asset} {onAction} {preAction} />
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
@ -217,7 +218,7 @@
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
{#if isOwner}
{#if hasPermissions(asset, SharingPermission.AssetUpdate)}
<hr />
<ActionMenuItem action={Actions.RefreshFacesJob} />
<ActionMenuItem action={Actions.RefreshMetadataJob} />

View File

@ -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 @@
</section>
{/if}
<DetailPanelDescription {asset} {isOwner} />
<DetailPanelRating {asset} {isOwner} />
<DetailPanelDescription {asset} {allowExifUpdate} />
<DetailPanelRating {asset} {allowExifUpdate} />
{#if !authManager.isSharedLink && isOwner}
{#if !authManager.isSharedLink && hasPermissions(asset, SharingPermission.PersonRead)}
<section class="px-4 pt-4 text-sm">
<div class="flex h-10 w-full items-center justify-between">
<Text size="small" color="muted">{$t('people')}</Text>
@ -276,7 +278,7 @@
<Text size="small" color="muted">{$t('no_exif_info_available')}</Text>
{/if}
<DetailPanelDate {asset} />
<DetailPanelDate {asset} {allowExifUpdate} />
<div class="flex gap-4 py-4">
<div><Icon icon={mdiImageOutline} size="24" /></div>
@ -284,7 +286,7 @@
<div>
<p class="break-all flex place-items-center gap-2 whitespace-pre-wrap">
{asset.originalFileName}
{#if isOwner}
{#if allowExifUpdate}
<IconButton
icon={mdiInformationOutline}
aria-label={$t('show_file_location')}
@ -387,7 +389,7 @@
</div>
{/if}
<DetailPanelLocation {isOwner} {asset} />
<DetailPanelLocation {allowExifUpdate} {asset} />
</div>
</section>

View File

@ -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"
>
<div class="flex gap-4">
@ -68,13 +64,13 @@
</div>
</div>
{#if isOwner}
{#if allowExifUpdate}
<div class="p-1">
<Icon icon={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !dateTime && isOwner}
{:else if !dateTime && allowExifUpdate}
<div class="flex justify-between place-items-start gap-4 py-4">
<div class="flex gap-4">
<Icon icon={mdiCalendar} size="24" />

View File

@ -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 @@
};
</script>
{#if isOwner}
{#if allowExifUpdate}
<section class="px-4 mt-10">
<Textarea
bind:value={description}

View File

@ -7,11 +7,11 @@
import { t } from 'svelte-i18n';
type Props = {
isOwner: boolean;
allowExifUpdate: boolean;
asset: AssetResponseDto;
};
let { isOwner, asset = $bindable() }: Props = $props();
let { allowExifUpdate, asset = $bindable() }: Props = $props();
const onAction = async () => {
const point = await modalManager.show(GeolocationPointPickerModal, { asset });
@ -34,9 +34,9 @@
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4"
onclick={isOwner ? onAction : undefined}
title={isOwner ? $t('edit_location') : ''}
class:hover:text-primary={isOwner}
onclick={allowExifUpdate ? onAction : undefined}
title={allowExifUpdate ? $t('edit_location') : ''}
class:hover:text-primary={allowExifUpdate}
>
<div class="flex gap-4">
<div><Icon icon={mdiMapMarkerOutline} size="24" /></div>
@ -58,13 +58,13 @@
</div>
</div>
{#if isOwner}
{#if allowExifUpdate}
<div>
<Icon icon={mdiPencil} size="20" />
</div>
{/if}
</button>
{:else if !asset.exifInfo?.city && isOwner}
{:else if !asset.exifInfo?.city && allowExifUpdate}
<button
type="button"
class="flex w-full text-start justify-between place-items-start gap-4 py-4 rounded-lg hover:text-primary"

View File

@ -8,10 +8,10 @@
interface Props {
asset: AssetResponseDto;
isOwner: boolean;
allowExifUpdate: boolean;
}
let { asset, isOwner }: Props = $props();
let { asset, allowExifUpdate }: Props = $props();
let rating = $derived(asset.exifInfo?.rating || null) as Rating;
@ -26,6 +26,10 @@
{#if !authManager.isSharedLink && authManager.authenticated && authManager.preferences.ratings.enabled}
<section class="px-4 pt-4">
<StarRating {rating} readOnly={!isOwner} onRating={(rating) => handlePromiseError(handleChangeRating(rating))} />
<StarRating
{rating}
readOnly={!allowExifUpdate}
onRating={(rating) => handlePromiseError(handleChangeRating(rating))}
/>
</section>
{/if}

View File

@ -0,0 +1,78 @@
<script lang="ts">
import { getOwnAlbumUser, SharingPermission, updateOwnAlbumUser } from '@immich/sdk';
import { Checkbox, Field, FormModal, Heading, Stack, Switch, toastManager } from '@immich/ui';
import { onMount } from 'svelte';
import { init } from 'svelte-i18n';
type Props = {
onClose: () => void;
albumId?: string;
partnerId?: string;
};
const { onClose, ...rest }: Props = $props();
let checkedPermissions = $state<SharingPermission[]>([]);
let viewInTimeline = $state<boolean>(false);
const onCheckedChange = (permission: SharingPermission, checked: boolean) => {
if (checked) {
checkedPermissions.push(permission);
} else {
checkedPermissions = checkedPermissions.filter((perm) => perm !== permission);
}
};
const onSubmit = async () => {
const permissions =
checkedPermissions.length === Object.values(SharingPermission).length - 1
? [SharingPermission.All]
: checkedPermissions;
if (rest.albumId) {
await updateOwnAlbumUser({
id: rest.albumId,
updateSharingOptionsDto: { permissions, inTimeline: viewInTimeline },
});
toastManager.success();
}
onClose();
};
onMount(async () => {
if (rest.albumId) {
const { permissions, inTimeline } = await getOwnAlbumUser({ id: rest.albumId });
checkedPermissions = permissions;
viewInTimeline = inTimeline;
}
});
</script>
<FormModal title="Sharing options" {onClose} {onSubmit}>
<Stack>
<Field label="View in timeline">
<Switch bind:checked={viewInTimeline} />
</Field>
<Heading>Permissions</Heading>
<Field label={SharingPermission.All}>
<Checkbox
id="permission-{SharingPermission.All}"
checked={checkedPermissions.length === Object.values(SharingPermission).length - 1}
onCheckedChange={(checked) =>
checked
? (checkedPermissions = Object.values(SharingPermission).filter(
(permission) => permission !== SharingPermission.All,
))
: (checkedPermissions = [])}
/>
</Field>
{#each Object.values(SharingPermission).filter((permission) => permission !== SharingPermission.All) as permission (permission)}
<Field label={permission}>
<Checkbox
id="permission-{permission}"
checked={checkedPermissions.includes(permission)}
onCheckedChange={(checked) => onCheckedChange(permission, checked)}
/>
</Field>
{/each}
</Stack>
</FormModal>

View File

@ -5,6 +5,7 @@ import {
AssetVisibility,
getAssetInfo,
runAssetJobs,
SharingPermission,
updateAsset,
type AssetJobsDto,
type AssetResponseDto,
@ -41,7 +42,7 @@ import { eventManager } from '$lib/managers/event-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { getAssetMediaUrl, getSharedLink, hasPermissions, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils/asset-utils';
import { handleError } from '$lib/utils/handle-error';
import { getFormatter } from '$lib/utils/i18n';
@ -98,7 +99,12 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const Share: ActionItem = {
title: $t('share'),
icon: mdiShareVariantOutline,
$if: () => !!(authUser && !asset.isTrashed && asset.visibility !== AssetVisibility.Locked),
$if: () =>
!!(
hasPermissions(asset, SharingPermission.AssetShare) &&
!asset.isTrashed &&
asset.visibility !== AssetVisibility.Locked
),
onAction: () => modalManager.show(SharedLinkCreateModal, { assetIds: [asset.id] }),
};
@ -119,7 +125,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
const SharedLinkDownload: ActionItem = {
...Download,
$if: () => isOwner || !!sharedLink?.allowDownload,
$if: () => hasPermissions(asset, SharingPermission.AssetShare) || !!sharedLink?.allowDownload,
};
const PlayMotionPhoto: ActionItem = {
@ -222,7 +228,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
icon: mdiTune,
$if: () =>
!sharedLink &&
isOwner &&
hasPermissions(asset, SharingPermission.AssetEdit) &&
asset.type === AssetTypeEnum.Image &&
!asset.livePhotoVideoId &&
asset.exifInfo?.projectionType !== ProjectionType.EQUIRECTANGULAR &&

View File

@ -2,6 +2,7 @@ import {
AssetMediaSize,
AssetTypeEnum,
MemoryType,
SharingPermission,
finishOAuth,
getAssetOriginalPath,
getAssetPlaybackPath,
@ -413,3 +414,17 @@ export const transformToTitleCase = (text: string) => {
}
return result.trim();
};
export const hasPermissions = (asset: AssetResponseDto, ...permissions: SharingPermission[]) => {
if (asset.permissions.includes(SharingPermission.All)) {
return true;
}
for (const permission of permissions) {
if (!asset.permissions.includes(permission)) {
return false;
}
}
return true;
};

View File

@ -38,6 +38,7 @@
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import AlbumOptionsModal from '$lib/modals/AlbumOptionsModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import SharingOptionsModal from '$lib/modals/SharingOptionsModal.svelte';
import { Route } from '$lib/route';
import {
getAlbumActions,
@ -73,6 +74,7 @@
mdiLink,
mdiPlus,
mdiPresentationPlay,
mdiShareVariant,
} from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
@ -404,6 +406,15 @@
/>
{/if}
<IconButton
shape="round"
aria-label="Sharing permissions"
color="secondary"
size="medium"
icon={mdiShareVariant}
onclick={() => modalManager.show(SharingOptionsModal, { albumId: album.id })}
/>
<ActionButton action={Share} />
</div>
{/if}