From 5abe4e2bd75a1f4619fa0339d5ca30c6e81e875e Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 4 Feb 2026 16:00:29 -0500 Subject: [PATCH] feat: asset file apis --- mobile/openapi/README.md | 6 + mobile/openapi/lib/api.dart | 3 + mobile/openapi/lib/api/asset_files_api.dart | 271 +++++++++++++++ mobile/openapi/lib/api_client.dart | 4 + mobile/openapi/lib/api_helper.dart | 3 + .../lib/model/asset_file_response_dto.dart | 154 +++++++++ mobile/openapi/lib/model/asset_file_type.dart | 91 +++++ mobile/openapi/lib/model/permission.dart | 9 + open-api/immich-openapi-specs.json | 311 ++++++++++++++++++ open-api/typescript-sdk/src/fetch-client.ts | 83 +++++ server/src/constants.ts | 1 + .../src/controllers/asset-file.controller.ts | 72 ++++ server/src/controllers/index.ts | 2 + server/src/dtos/asset-file.dto.ts | 54 +++ server/src/enum.ts | 5 + server/src/repositories/access.repository.ts | 24 ++ .../src/repositories/asset-file.repository.ts | 30 ++ server/src/repositories/index.ts | 2 + server/src/services/asset-file.service.ts | 48 +++ server/src/services/base.service.ts | 2 + server/src/services/index.ts | 2 + server/src/utils/access.ts | 9 + server/test/medium.factory.ts | 2 + server/test/utils.ts | 4 + 24 files changed, 1192 insertions(+) create mode 100644 mobile/openapi/lib/api/asset_files_api.dart create mode 100644 mobile/openapi/lib/model/asset_file_response_dto.dart create mode 100644 mobile/openapi/lib/model/asset_file_type.dart create mode 100644 server/src/controllers/asset-file.controller.ts create mode 100644 server/src/dtos/asset-file.dto.ts create mode 100644 server/src/repositories/asset-file.repository.ts create mode 100644 server/src/services/asset-file.service.ts diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 88d698e55b..85925dfb36 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -95,6 +95,10 @@ Class | Method | HTTP request | Description *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 +*AssetFilesApi* | [**deleteAssetFile**](doc//AssetFilesApi.md#deleteassetfile) | **DELETE** /asset-files/{id} | Delete an asset file +*AssetFilesApi* | [**downloadAssetFile**](doc//AssetFilesApi.md#downloadassetfile) | **GET** /asset-files/{id}/download | Download an asset file +*AssetFilesApi* | [**getAssetFile**](doc//AssetFilesApi.md#getassetfile) | **GET** /asset-files/{id} | Retrieve an asset file +*AssetFilesApi* | [**searchAssetFiles**](doc//AssetFilesApi.md#searchassetfiles) | **GET** /asset-files | Retrieve an asset file *AssetsApi* | [**checkBulkUpload**](doc//AssetsApi.md#checkbulkupload) | **POST** /assets/bulk-upload-check | Check bulk upload *AssetsApi* | [**checkExistingAssets**](doc//AssetsApi.md#checkexistingassets) | **POST** /assets/exist | Check existing assets *AssetsApi* | [**copyAsset**](doc//AssetsApi.md#copyasset) | **PUT** /assets/copy | Copy asset @@ -369,6 +373,8 @@ Class | Method | HTTP request | Description - [AssetFaceUpdateDto](doc//AssetFaceUpdateDto.md) - [AssetFaceUpdateItem](doc//AssetFaceUpdateItem.md) - [AssetFaceWithoutPersonResponseDto](doc//AssetFaceWithoutPersonResponseDto.md) + - [AssetFileResponseDto](doc//AssetFileResponseDto.md) + - [AssetFileType](doc//AssetFileType.md) - [AssetFullSyncDto](doc//AssetFullSyncDto.md) - [AssetIdsDto](doc//AssetIdsDto.md) - [AssetIdsResponseDto](doc//AssetIdsResponseDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 90e426b547..0f7034f12a 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -33,6 +33,7 @@ part 'auth/http_bearer_auth.dart'; part 'api/api_keys_api.dart'; part 'api/activities_api.dart'; part 'api/albums_api.dart'; +part 'api/asset_files_api.dart'; part 'api/assets_api.dart'; part 'api/authentication_api.dart'; part 'api/authentication_admin_api.dart'; @@ -109,6 +110,8 @@ part 'model/asset_face_response_dto.dart'; part 'model/asset_face_update_dto.dart'; part 'model/asset_face_update_item.dart'; part 'model/asset_face_without_person_response_dto.dart'; +part 'model/asset_file_response_dto.dart'; +part 'model/asset_file_type.dart'; part 'model/asset_full_sync_dto.dart'; part 'model/asset_ids_dto.dart'; part 'model/asset_ids_response_dto.dart'; diff --git a/mobile/openapi/lib/api/asset_files_api.dart b/mobile/openapi/lib/api/asset_files_api.dart new file mode 100644 index 0000000000..efb983942e --- /dev/null +++ b/mobile/openapi/lib/api/asset_files_api.dart @@ -0,0 +1,271 @@ +// +// 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 AssetFilesApi { + AssetFilesApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Delete an asset file + /// + /// Delete a file and remove it from the database. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future deleteAssetFileWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/asset-files/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Delete an asset file + /// + /// Delete a file and remove it from the database. + /// + /// Parameters: + /// + /// * [String] id (required): + Future deleteAssetFile(String id,) async { + final response = await deleteAssetFileWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + } + + /// Download an asset file + /// + /// Serve the contents of a specific asset file. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future downloadAssetFileWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/asset-files/{id}/download' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Download an asset file + /// + /// Serve the contents of a specific asset file. + /// + /// Parameters: + /// + /// * [String] id (required): + Future downloadAssetFile(String id,) async { + final response = await downloadAssetFileWithHttpInfo(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), 'MultipartFile',) as MultipartFile; + + } + return null; + } + + /// Retrieve an asset file + /// + /// Returns a metadata about a specific asset file. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getAssetFileWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final apiPath = r'/asset-files/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve an asset file + /// + /// Returns a metadata about a specific asset file. + /// + /// Parameters: + /// + /// * [String] id (required): + Future getAssetFile(String id,) async { + final response = await getAssetFileWithHttpInfo(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), 'AssetFileResponseDto',) as AssetFileResponseDto; + + } + return null; + } + + /// Retrieve an asset file + /// + /// Returns a metadata about a specific asset file. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] assetId (required): + /// Asset ID to filter files by + /// + /// * [bool] isEdited: + /// The file was generated from an edit + /// + /// * [bool] isProgressive: + /// The file is a progressively encoded JPEG + /// + /// * [AssetFileType] type: + /// Filter by type of file + Future searchAssetFilesWithHttpInfo(String assetId, { bool? isEdited, bool? isProgressive, AssetFileType? type, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/asset-files'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + queryParams.addAll(_queryParams('', 'assetId', assetId)); + if (isEdited != null) { + queryParams.addAll(_queryParams('', 'isEdited', isEdited)); + } + if (isProgressive != null) { + queryParams.addAll(_queryParams('', 'isProgressive', isProgressive)); + } + if (type != null) { + queryParams.addAll(_queryParams('', 'type', type)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve an asset file + /// + /// Returns a metadata about a specific asset file. + /// + /// Parameters: + /// + /// * [String] assetId (required): + /// Asset ID to filter files by + /// + /// * [bool] isEdited: + /// The file was generated from an edit + /// + /// * [bool] isProgressive: + /// The file is a progressively encoded JPEG + /// + /// * [AssetFileType] type: + /// Filter by type of file + Future?> searchAssetFiles(String assetId, { bool? isEdited, bool? isProgressive, AssetFileType? type, }) async { + final response = await searchAssetFilesWithHttpInfo(assetId, isEdited: isEdited, isProgressive: isProgressive, type: type, ); + 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) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 7f5cd50ed4..d4fd10475b 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -264,6 +264,10 @@ class ApiClient { return AssetFaceUpdateItem.fromJson(value); case 'AssetFaceWithoutPersonResponseDto': return AssetFaceWithoutPersonResponseDto.fromJson(value); + case 'AssetFileResponseDto': + return AssetFileResponseDto.fromJson(value); + case 'AssetFileType': + return AssetFileTypeTypeTransformer().decode(value); case 'AssetFullSyncDto': return AssetFullSyncDto.fromJson(value); case 'AssetIdsDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index 830325a5b6..a094300bc2 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -61,6 +61,9 @@ String parameterToString(dynamic value) { if (value is AssetEditAction) { return AssetEditActionTypeTransformer().encode(value).toString(); } + if (value is AssetFileType) { + return AssetFileTypeTypeTransformer().encode(value).toString(); + } if (value is AssetJobName) { return AssetJobNameTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/asset_file_response_dto.dart b/mobile/openapi/lib/model/asset_file_response_dto.dart new file mode 100644 index 0000000000..bc4624078a --- /dev/null +++ b/mobile/openapi/lib/model/asset_file_response_dto.dart @@ -0,0 +1,154 @@ +// +// 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 AssetFileResponseDto { + /// Returns a new [AssetFileResponseDto] instance. + AssetFileResponseDto({ + required this.createdAt, + required this.id, + required this.isEdited, + required this.isProgressive, + required this.path, + required this.type, + required this.updatedAt, + }); + + /// Creation date + DateTime createdAt; + + /// Asset file ID + String id; + + /// The file was generated from an edit + bool isEdited; + + /// The file is a progressively encoded JPEG + bool isProgressive; + + /// File path + String path; + + /// Type of file + AssetFileType type; + + /// Update date + DateTime updatedAt; + + @override + bool operator ==(Object other) => identical(this, other) || other is AssetFileResponseDto && + other.createdAt == createdAt && + other.id == id && + other.isEdited == isEdited && + other.isProgressive == isProgressive && + other.path == path && + other.type == type && + other.updatedAt == updatedAt; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (createdAt.hashCode) + + (id.hashCode) + + (isEdited.hashCode) + + (isProgressive.hashCode) + + (path.hashCode) + + (type.hashCode) + + (updatedAt.hashCode); + + @override + String toString() => 'AssetFileResponseDto[createdAt=$createdAt, id=$id, isEdited=$isEdited, isProgressive=$isProgressive, path=$path, type=$type, updatedAt=$updatedAt]'; + + Map toJson() { + final json = {}; + json[r'createdAt'] = this.createdAt.toUtc().toIso8601String(); + json[r'id'] = this.id; + json[r'isEdited'] = this.isEdited; + json[r'isProgressive'] = this.isProgressive; + json[r'path'] = this.path; + json[r'type'] = this.type; + json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String(); + return json; + } + + /// Returns a new [AssetFileResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static AssetFileResponseDto? fromJson(dynamic value) { + upgradeDto(value, "AssetFileResponseDto"); + if (value is Map) { + final json = value.cast(); + + return AssetFileResponseDto( + createdAt: mapDateTime(json, r'createdAt', r'')!, + id: mapValueOfType(json, r'id')!, + isEdited: mapValueOfType(json, r'isEdited')!, + isProgressive: mapValueOfType(json, r'isProgressive')!, + path: mapValueOfType(json, r'path')!, + type: AssetFileType.fromJson(json[r'type'])!, + updatedAt: mapDateTime(json, r'updatedAt', r'')!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetFileResponseDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = AssetFileResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of AssetFileResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = AssetFileResponseDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'createdAt', + 'id', + 'isEdited', + 'isProgressive', + 'path', + 'type', + 'updatedAt', + }; +} + diff --git a/mobile/openapi/lib/model/asset_file_type.dart b/mobile/openapi/lib/model/asset_file_type.dart new file mode 100644 index 0000000000..2020f9f8ac --- /dev/null +++ b/mobile/openapi/lib/model/asset_file_type.dart @@ -0,0 +1,91 @@ +// +// 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 AssetFileType { + /// Instantiate a new enum with the provided [value]. + const AssetFileType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const fullsize = AssetFileType._(r'fullsize'); + static const preview = AssetFileType._(r'preview'); + static const thumbnail = AssetFileType._(r'thumbnail'); + static const sidecar = AssetFileType._(r'sidecar'); + + /// List of all possible values in this [enum][AssetFileType]. + static const values = [ + fullsize, + preview, + thumbnail, + sidecar, + ]; + + static AssetFileType? fromJson(dynamic value) => AssetFileTypeTypeTransformer().decode(value); + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = AssetFileType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [AssetFileType] to String, +/// and [decode] dynamic data back to [AssetFileType]. +class AssetFileTypeTypeTransformer { + factory AssetFileTypeTypeTransformer() => _instance ??= const AssetFileTypeTypeTransformer._(); + + const AssetFileTypeTypeTransformer._(); + + String encode(AssetFileType data) => data.value; + + /// Decodes a [dynamic value][data] to a AssetFileType. + /// + /// 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. + AssetFileType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data) { + case r'fullsize': return AssetFileType.fullsize; + case r'preview': return AssetFileType.preview; + case r'thumbnail': return AssetFileType.thumbnail; + case r'sidecar': return AssetFileType.sidecar; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [AssetFileTypeTypeTransformer] instance. + static AssetFileTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/lib/model/permission.dart b/mobile/openapi/lib/model/permission.dart index 9092ede786..ccf88a1dc6 100644 --- a/mobile/openapi/lib/model/permission.dart +++ b/mobile/openapi/lib/model/permission.dart @@ -44,6 +44,9 @@ class Permission { static const assetPeriodReplace = Permission._(r'asset.replace'); static const assetPeriodCopy = Permission._(r'asset.copy'); static const assetPeriodDerive = Permission._(r'asset.derive'); + static const assetFilePeriodRead = Permission._(r'assetFile.read'); + static const assetFilePeriodDelete = Permission._(r'assetFile.delete'); + static const assetFilePeriodDownload = Permission._(r'assetFile.download'); static const assetPeriodEditPeriodGet = Permission._(r'asset.edit.get'); static const assetPeriodEditPeriodCreate = Permission._(r'asset.edit.create'); static const assetPeriodEditPeriodDelete = Permission._(r'asset.edit.delete'); @@ -203,6 +206,9 @@ class Permission { assetPeriodReplace, assetPeriodCopy, assetPeriodDerive, + assetFilePeriodRead, + assetFilePeriodDelete, + assetFilePeriodDownload, assetPeriodEditPeriodGet, assetPeriodEditPeriodCreate, assetPeriodEditPeriodDelete, @@ -397,6 +403,9 @@ class PermissionTypeTransformer { case r'asset.replace': return Permission.assetPeriodReplace; case r'asset.copy': return Permission.assetPeriodCopy; case r'asset.derive': return Permission.assetPeriodDerive; + case r'assetFile.read': return Permission.assetFilePeriodRead; + case r'assetFile.delete': return Permission.assetFilePeriodDelete; + case r'assetFile.download': return Permission.assetFilePeriodDownload; case r'asset.edit.get': return Permission.assetPeriodEditPeriodGet; case r'asset.edit.create': return Permission.assetPeriodEditPeriodCreate; case r'asset.edit.delete': return Permission.assetPeriodEditPeriodDelete; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 8359ebc173..8d1f8b2147 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2760,6 +2760,253 @@ "x-immich-state": "Stable" } }, + "/asset-files": { + "get": { + "description": "Returns a metadata about a specific asset file.", + "operationId": "searchAssetFiles", + "parameters": [ + { + "name": "assetId", + "required": true, + "in": "query", + "description": "Asset ID to filter files by", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "isEdited", + "required": false, + "in": "query", + "description": "The file was generated from an edit", + "schema": { + "type": "boolean" + } + }, + { + "name": "isProgressive", + "required": false, + "in": "query", + "description": "The file is a progressively encoded JPEG", + "schema": { + "type": "boolean" + } + }, + { + "name": "type", + "required": false, + "in": "query", + "description": "Filter by type of file", + "schema": { + "$ref": "#/components/schemas/AssetFileType" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AssetFileResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v2.6.0", + "state": "Added" + }, + { + "version": "v2.6.0", + "state": "Beta" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Beta" + } + }, + "/asset-files/{id}": { + "delete": { + "description": "Delete a file and remove it from the database.", + "operationId": "deleteAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Delete an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v2.6.0", + "state": "Added" + }, + { + "version": "v2.6.0", + "state": "Beta" + } + ], + "x-immich-permission": "assetFile.delete", + "x-immich-state": "Beta" + }, + "get": { + "description": "Returns a metadata about a specific asset file.", + "operationId": "getAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetFileResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v2.6.0", + "state": "Added" + }, + { + "version": "v2.6.0", + "state": "Beta" + } + ], + "x-immich-permission": "assetFile.read", + "x-immich-state": "Beta" + } + }, + "/asset-files/{id}/download": { + "get": { + "description": "Serve the contents of a specific asset file.", + "operationId": "downloadAssetFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download an asset file", + "tags": [ + "Asset files" + ], + "x-immich-history": [ + { + "version": "v2.6.0", + "state": "Added" + }, + { + "version": "v2.6.0", + "state": "Beta" + } + ], + "x-immich-permission": "assetFile.download", + "x-immich-state": "Beta" + } + }, "/assets": { "delete": { "description": "Deletes multiple assets at the same time.", @@ -15077,6 +15324,10 @@ "name": "Assets", "description": "An asset is an image or video that has been uploaded to Immich." }, + { + "name": "Asset files", + "description": "An asset file is a file associated with an asset, including edited versions, thumbnails, etc." + }, { "name": "Authentication", "description": "Endpoints related to user authentication, including OAuth." @@ -16320,6 +16571,63 @@ ], "type": "object" }, + "AssetFileResponseDto": { + "properties": { + "createdAt": { + "description": "Creation date", + "format": "date-time", + "type": "string" + }, + "id": { + "description": "Asset file ID", + "type": "string" + }, + "isEdited": { + "description": "The file was generated from an edit", + "type": "boolean" + }, + "isProgressive": { + "description": "The file is a progressively encoded JPEG", + "type": "boolean" + }, + "path": { + "description": "File path", + "type": "string" + }, + "type": { + "allOf": [ + { + "$ref": "#/components/schemas/AssetFileType" + } + ], + "description": "Type of file" + }, + "updatedAt": { + "description": "Update date", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "createdAt", + "id", + "isEdited", + "isProgressive", + "path", + "type", + "updatedAt" + ], + "type": "object" + }, + "AssetFileType": { + "enum": [ + "fullsize", + "preview", + "thumbnail", + "sidecar" + ], + "type": "string" + }, "AssetFullSyncDto": { "properties": { "lastId": { @@ -19520,6 +19828,9 @@ "asset.replace", "asset.copy", "asset.derive", + "assetFile.read", + "assetFile.delete", + "assetFile.download", "asset.edit.get", "asset.edit.create", "asset.edit.delete", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 75c55f7853..59268c6987 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -773,6 +773,22 @@ export type ApiKeyUpdateDto = { /** List of permissions */ permissions?: Permission[]; }; +export type AssetFileResponseDto = { + /** Creation date */ + createdAt: string; + /** Asset file ID */ + id: string; + /** The file was generated from an edit */ + isEdited: boolean; + /** The file is a progressively encoded JPEG */ + isProgressive: boolean; + /** File path */ + path: string; + /** Type of file */ + "type": AssetFileType; + /** Update date */ + updatedAt: string; +}; export type AssetBulkDeleteDto = { /** Force delete even if in use */ force?: boolean; @@ -3887,6 +3903,64 @@ export function updateApiKey({ id, apiKeyUpdateDto }: { body: apiKeyUpdateDto }))); } +/** + * Retrieve an asset file + */ +export function searchAssetFiles({ assetId, isEdited, isProgressive, $type }: { + assetId: string; + isEdited?: boolean; + isProgressive?: boolean; + $type?: AssetFileType; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto[]; + }>(`/asset-files${QS.query(QS.explode({ + assetId, + isEdited, + isProgressive, + "type": $type + }))}`, { + ...opts + })); +} +/** + * Delete an asset file + */ +export function deleteAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/asset-files/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Retrieve an asset file + */ +export function getAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AssetFileResponseDto; + }>(`/asset-files/${encodeURIComponent(id)}`, { + ...opts + })); +} +/** + * Download an asset file + */ +export function downloadAssetFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/asset-files/${encodeURIComponent(id)}/download`, { + ...opts + })); +} /** * Delete assets */ @@ -6862,6 +6936,9 @@ export enum Permission { AssetReplace = "asset.replace", AssetCopy = "asset.copy", AssetDerive = "asset.derive", + AssetFileRead = "assetFile.read", + AssetFileDelete = "assetFile.delete", + AssetFileDownload = "assetFile.download", AssetEditGet = "asset.edit.get", AssetEditCreate = "asset.edit.create", AssetEditDelete = "asset.edit.delete", @@ -6998,6 +7075,12 @@ export enum Permission { AdminSessionRead = "adminSession.read", AdminAuthUnlinkAll = "adminAuth.unlinkAll" } +export enum AssetFileType { + Fullsize = "fullsize", + Preview = "preview", + Thumbnail = "thumbnail", + Sidecar = "sidecar" +} export enum AssetMediaStatus { Created = "created", Replaced = "replaced", diff --git a/server/src/constants.ts b/server/src/constants.ts index 809c7e45a8..61ed423b3c 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -139,6 +139,7 @@ export const endpointTags: Record = { [ApiTag.Albums]: 'An album is a collection of assets that can be shared with other users or via shared links.', [ApiTag.ApiKeys]: 'An api key can be used to programmatically access the Immich API.', [ApiTag.Assets]: 'An asset is an image or video that has been uploaded to Immich.', + [ApiTag.AssetFiles]: 'An asset file is a file associated with an asset, including edited versions, thumbnails, etc.', [ApiTag.Authentication]: 'Endpoints related to user authentication, including OAuth.', [ApiTag.AuthenticationAdmin]: 'Administrative endpoints related to authentication.', [ApiTag.DatabaseBackups]: 'Manage backups of the Immich database.', diff --git a/server/src/controllers/asset-file.controller.ts b/server/src/controllers/asset-file.controller.ts new file mode 100644 index 0000000000..ffe0a334fb --- /dev/null +++ b/server/src/controllers/asset-file.controller.ts @@ -0,0 +1,72 @@ +import { Controller, Delete, Get, HttpCode, HttpStatus, Next, Param, Query, Res } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { NextFunction, Response } from 'express'; +import { Endpoint, HistoryBuilder } from 'src/decorators'; +import { AssetFileResponseDto, AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { ApiTag, Permission } from 'src/enum'; +import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; +import { LoggingRepository } from 'src/repositories/logging.repository'; +import { AssetFileService } from 'src/services/asset-file.service'; +import { sendFile } from 'src/utils/file'; +import { UUIDParamDto } from 'src/validation'; + +@ApiTags(ApiTag.AssetFiles) +@Controller('asset-files') +export class AssetFilesController { + constructor( + private service: AssetFileService, + private logger: LoggingRepository, + ) {} + + @Get() + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Retrieve an asset file', + description: 'Returns a metadata about a specific asset file.', + history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'), + }) + searchAssetFiles(@Auth() auth: AuthDto, @Query() dto: AssetFileSearchDto): Promise { + return this.service.search(auth, dto); + } + + @Get(':id') + @Authenticated({ permission: Permission.AssetFileRead }) + @Endpoint({ + summary: 'Retrieve an asset file', + description: 'Returns a metadata about a specific asset file.', + history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'), + }) + getAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.get(auth, id); + } + + @Delete(':id') + @Authenticated({ permission: Permission.AssetFileDelete }) + @HttpCode(HttpStatus.NO_CONTENT) + @Endpoint({ + summary: 'Delete an asset file', + description: 'Delete a file and remove it from the database.', + history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'), + }) + deleteAssetFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.delete(auth, id); + } + + @Get(':id/download') + @FileResponse() + @Authenticated({ permission: Permission.AssetFileDownload }) + @Endpoint({ + summary: 'Download an asset file', + description: 'Serve the contents of a specific asset file.', + history: new HistoryBuilder().added('v2.6.0').beta('v2.6.0'), + }) + async downloadAssetFile( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Res() res: Response, + @Next() next: NextFunction, + ) { + await sendFile(res, next, () => this.service.download(auth, id), this.logger); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index dc3754ce24..45b4e7acdd 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -2,6 +2,7 @@ import { ActivityController } from 'src/controllers/activity.controller'; import { AlbumController } from 'src/controllers/album.controller'; import { ApiKeyController } from 'src/controllers/api-key.controller'; import { AppController } from 'src/controllers/app.controller'; +import { AssetFilesController } from 'src/controllers/asset-file.controller'; import { AssetMediaController } from 'src/controllers/asset-media.controller'; import { AssetController } from 'src/controllers/asset.controller'; import { AuthAdminController } from 'src/controllers/auth-admin.controller'; @@ -44,6 +45,7 @@ export const controllers = [ AlbumController, AppController, AssetController, + AssetFilesController, AssetMediaController, AuthController, AuthAdminController, diff --git a/server/src/dtos/asset-file.dto.ts b/server/src/dtos/asset-file.dto.ts new file mode 100644 index 0000000000..34433b04f4 --- /dev/null +++ b/server/src/dtos/asset-file.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Selectable } from 'kysely'; +import { AssetFileType } from 'src/enum'; +import { AssetFileTable } from 'src/schema/tables/asset-file.table'; +import { ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation'; + +export class AssetFileSearchDto { + @ValidateUUID({ description: 'Asset ID to filter files by' }) + assetId!: string; + + @ValidateEnum({ enum: AssetFileType, name: 'AssetFileType', optional: true, description: 'Filter by type of file' }) + type?: AssetFileType; + + @ValidateBoolean({ optional: true, description: 'The file was generated from an edit' }) + isEdited?: boolean; + + @ValidateBoolean({ optional: true, description: 'The file is a progressively encoded JPEG' }) + isProgressive?: boolean; +} + +export class AssetFileResponseDto { + @ApiProperty({ description: 'Asset file ID' }) + id!: string; + @ApiProperty({ description: 'Creation date', format: 'date-time' }) + createdAt!: Date; + + @ApiProperty({ description: 'Update date', format: 'date-time' }) + updatedAt!: Date; + + @ValidateEnum({ enum: AssetFileType, name: 'AssetFileType', description: 'Type of file' }) + type!: AssetFileType; + + @ApiProperty({ description: 'File path' }) + path!: string; + + @ApiProperty({ description: 'The file was generated from an edit' }) + isEdited!: boolean; + + @ApiProperty({ description: 'The file is a progressively encoded JPEG' }) + isProgressive!: boolean; +} + +export const mapAssetFile = (file: Selectable): AssetFileResponseDto => { + return { + id: file.id, + // assetId: file.assetId, + createdAt: file.createdAt, + updatedAt: file.updatedAt, + type: file.type, + path: file.path, + isEdited: file.isEdited, + isProgressive: file.isProgressive, + }; +}; diff --git a/server/src/enum.ts b/server/src/enum.ts index 8f509754da..cd8ff5c854 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -108,6 +108,10 @@ export enum Permission { AssetCopy = 'asset.copy', AssetDerive = 'asset.derive', + AssetFileRead = 'assetFile.read', + AssetFileDelete = 'assetFile.delete', + AssetFileDownload = 'assetFile.download', + AssetEditGet = 'asset.edit.get', AssetEditCreate = 'asset.edit.create', AssetEditDelete = 'asset.edit.delete', @@ -852,6 +856,7 @@ export enum ApiTag { Authentication = 'Authentication', AuthenticationAdmin = 'Authentication (admin)', Assets = 'Assets', + AssetFiles = 'Asset files', DatabaseBackups = 'Database Backups (admin)', Deprecated = 'Deprecated', Download = 'Download', diff --git a/server/src/repositories/access.repository.ts b/server/src/repositories/access.repository.ts index 533e74a311..02ff2d2a15 100644 --- a/server/src/repositories/access.repository.ts +++ b/server/src/repositories/access.repository.ts @@ -265,6 +265,28 @@ class AssetAccess { } } +class AssetFileAccess { + constructor(private db: Kysely) {} + + @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID_SET] }) + @ChunkedSet({ paramIndex: 1 }) + async checkOwnerAccess(userId: string, fileIds: Set, hasElevatedPermission: boolean | undefined) { + if (fileIds.size === 0) { + return new Set(); + } + + return this.db + .selectFrom('asset_file') + .select('asset_file.id') + .innerJoin('asset', 'asset.id', 'asset_file.assetId') + .$if(!hasElevatedPermission, (eb) => eb.where('asset.visibility', '!=', AssetVisibility.Locked)) + .where('asset.ownerId', '=', userId) + .where('asset_file.id', 'in', [...fileIds]) + .execute() + .then((files) => new Set(files.map(({ id }) => id))); + } +} + class AuthDeviceAccess { constructor(private db: Kysely) {} @@ -487,6 +509,7 @@ export class AccessRepository { activity: ActivityAccess; album: AlbumAccess; asset: AssetAccess; + assetFile: AssetFileAccess; authDevice: AuthDeviceAccess; memory: MemoryAccess; notification: NotificationAccess; @@ -502,6 +525,7 @@ export class AccessRepository { this.activity = new ActivityAccess(db); this.album = new AlbumAccess(db); this.asset = new AssetAccess(db); + this.assetFile = new AssetFileAccess(db); this.authDevice = new AuthDeviceAccess(db); this.memory = new MemoryAccess(db); this.notification = new NotificationAccess(db); diff --git a/server/src/repositories/asset-file.repository.ts b/server/src/repositories/asset-file.repository.ts new file mode 100644 index 0000000000..7540fd4398 --- /dev/null +++ b/server/src/repositories/asset-file.repository.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { Kysely } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { AssetFileSearchDto } from 'src/dtos/asset-file.dto'; +import { DB } from 'src/schema'; + +@Injectable() +export class AssetFileRepository { + constructor(@InjectKysely() private db: Kysely) {} + + get(id: string) { + return this.db.selectFrom('asset_file').where('id', '=', id).selectAll().executeTakeFirst(); + } + + getByAssetId(dto: AssetFileSearchDto) { + return this.db + .selectFrom('asset_file') + .where('assetId', '=', dto.assetId) + .$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!)) + .$if(dto.isEdited !== undefined, (qb) => qb.where('isEdited', '=', dto.isEdited!)) + .$if(dto.isProgressive !== undefined, (qb) => qb.where('isProgressive', '=', dto.isProgressive!)) + .selectAll() + .execute(); + } + + async delete(id: string): Promise { + const { numDeletedRows } = await this.db.deleteFrom('asset_file').where('id', '=', id).executeTakeFirst(); + return Number(numDeletedRows) === 1; + } +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 361a2e7179..3cba795046 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -5,6 +5,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; @@ -61,6 +62,7 @@ export const repositories = [ AppRepository, AssetRepository, AssetEditRepository, + AssetFileRepository, AssetJobRepository, ConfigRepository, CronRepository, diff --git a/server/src/services/asset-file.service.ts b/server/src/services/asset-file.service.ts new file mode 100644 index 0000000000..4631dd2cc7 --- /dev/null +++ b/server/src/services/asset-file.service.ts @@ -0,0 +1,48 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { AssetFileResponseDto, AssetFileSearchDto, mapAssetFile } from 'src/dtos/asset-file.dto'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { CacheControl, Permission } from 'src/enum'; +import { BaseService } from 'src/services/base.service'; +import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; +import { mimeTypes } from 'src/utils/mime-types'; + +@Injectable() +export class AssetFileService extends BaseService { + async search(auth: AuthDto, dto: AssetFileSearchDto): Promise { + await this.requireAccess({ auth, permission: Permission.AssetRead, ids: [dto.assetId] }); + const files = await this.assetFileRepository.getByAssetId(dto); + return files.map((file) => mapAssetFile(file)); + } + + async get(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AssetFileRead, ids: [id] }); + const file = await this.findOrFail(id); + return mapAssetFile(file); + } + + async download(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDownload, ids: [id] }); + const file = await this.findOrFail(id); + + return new ImmichFileResponse({ + path: file.path, + fileName: getFileNameWithoutExtension(file.path) + getFilenameExtension(file.path), + contentType: mimeTypes.lookup(file.path), + cacheControl: CacheControl.PrivateWithCache, + }); + } + + async delete(auth: AuthDto, id: string) { + await this.requireAccess({ auth, permission: Permission.AssetFileDelete, ids: [id] }); + + await this.assetFileRepository.delete(id); + } + + private async findOrFail(id: string) { + const file = await this.assetFileRepository.get(id); + if (!file) { + throw new BadRequestException('Asset file not found'); + } + return file; + } +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index b3a50a07ae..e7337a235b 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -12,6 +12,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; @@ -130,6 +131,7 @@ export class BaseService { protected appRepository: AppRepository, protected assetRepository: AssetRepository, protected assetEditRepository: AssetEditRepository, + protected assetFileRepository: AssetFileRepository, protected assetJobRepository: AssetJobRepository, protected auditRepository: AuditRepository, protected configRepository: ConfigRepository, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 2c2fb995c8..714ec26345 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -2,6 +2,7 @@ import { ActivityService } from 'src/services/activity.service'; import { AlbumService } from 'src/services/album.service'; import { ApiKeyService } from 'src/services/api-key.service'; import { ApiService } from 'src/services/api.service'; +import { AssetFileService } from 'src/services/asset-file.service'; import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; @@ -55,6 +56,7 @@ export const services = [ ApiService, AssetMediaService, AssetService, + AssetFileService, AuditService, AuthService, AuthAdminService, diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 7431cb3293..e4f567356f 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -131,6 +131,10 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return setUnion(isOwner, isPartner); } + case Permission.AssetFileDownload: { + return access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + 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)); @@ -169,6 +173,11 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe return await access.asset.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); } + case Permission.AssetFileRead: + case Permission.AssetFileDelete: { + return await access.assetFile.checkOwnerAccess(auth.user.id, ids, auth.session?.hasElevatedPermission); + } + case Permission.AlbumRead: { const isOwner = await access.album.checkOwnerAccess(auth.user.id, ids); const isShared = await access.album.checkSharedAlbumAccess( diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 153b568222..72a6b84add 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -20,6 +20,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { ConfigRepository } from 'src/repositories/config.repository'; @@ -386,6 +387,7 @@ const newRealRepository = (key: ClassConstructor, db: Kysely): T => { case ActivityRepository: case AssetRepository: case AssetEditRepository: + case AssetFileRepository: case AssetJobRepository: case MemoryRepository: case NotificationRepository: diff --git a/server/test/utils.ts b/server/test/utils.ts index cd866994eb..b3aeb2c997 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -21,6 +21,7 @@ import { AlbumRepository } from 'src/repositories/album.repository'; import { ApiKeyRepository } from 'src/repositories/api-key.repository'; import { AppRepository } from 'src/repositories/app.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; +import { AssetFileRepository } from 'src/repositories/asset-file.repository'; import { AssetJobRepository } from 'src/repositories/asset-job.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { AuditRepository } from 'src/repositories/audit.repository'; @@ -218,6 +219,7 @@ export type ServiceOverrides = { audit: AuditRepository; asset: AssetRepository; assetEdit: AssetEditRepository; + assetFile: AssetFileRepository; assetJob: AssetJobRepository; config: ConfigRepository; cron: CronRepository; @@ -292,6 +294,7 @@ export const getMocks = () => { albumUser: automock(AlbumUserRepository), asset: newAssetRepositoryMock(), assetEdit: automock(AssetEditRepository), + assetFile: automock(AssetFileRepository), assetJob: automock(AssetJobRepository), app: automock(AppRepository, { strict: false }), config: newConfigRepositoryMock(), @@ -360,6 +363,7 @@ export const newTestService = ( overrides.app || (mocks.app as As), overrides.asset || (mocks.asset as As), overrides.assetEdit || (mocks.assetEdit as As), + overrides.assetFile || (mocks.assetFile as As), overrides.assetJob || (mocks.assetJob as As), overrides.audit || (mocks.audit as As), overrides.config || (mocks.config as As as ConfigRepository),