mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(server): support for read-only assets and importing existing items in the filesystem (#2715)
* Added read-only flag for assets, endpoint to trigger file import vs upload * updated fixtures with new property * if upload is 'read-only', ensure there is no existing asset at the designated originalPath * added test for file import as well as detecting existing image at read-only destination location * Added storage service test for a case where it should not move read-only assets * upload doesn't need the read-only flag available, just importing * default isReadOnly on import endpoint to true * formatting fixes * create-asset dto needs isReadOnly, so set it to false by default on create, updated api generation * updated code to reflect changes in MR * fixed read stream promise return type * new index for originalPath, check for existing path on import, reglardless of user, to prevent duplicates * refactor: import asset * chore: open api * chore: tests * Added externalPath support for individual users, updated UI to allow this to be set by admin * added missing var for externalPath in ui * chore: open api * fix: compilation issues * fix: server test * built api, fixed user-response dto to include externalPath * reverted accidental commit * bad commit of duplicate externalPath in user response dto * fixed tests to include externalPath on expected result * fix: unit tests * centralized supported filetypes, perform file type checking of asset and sidecar during file import process * centralized supported filetype check method to keep regex DRY * fixed typo * combined migrations into one * update api * Removed externalPath from shared-link code, added column to admin user page whether external paths / import is enabled or not * update mimetype * Fixed detect correct mimetype * revert asset-upload config * reverted domain.constant * refactor * fix mime-type issue * fix format --------- Co-authored-by: Jason Rasmussen <jrasm91@gmail.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
		
							
								
								
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										3
									
								
								mobile/openapi/.openapi-generator/FILES
									
									
									
										generated
									
									
									
								
							| @@ -49,6 +49,7 @@ doc/DownloadFilesDto.md | ||||
| doc/ExifResponseDto.md | ||||
| doc/GetAssetByTimeBucketDto.md | ||||
| doc/GetAssetCountByTimeBucketDto.md | ||||
| doc/ImportAssetDto.md | ||||
| doc/JobApi.md | ||||
| doc/JobCommand.md | ||||
| doc/JobCommandDto.md | ||||
| @@ -181,6 +182,7 @@ lib/model/download_files_dto.dart | ||||
| lib/model/exif_response_dto.dart | ||||
| lib/model/get_asset_by_time_bucket_dto.dart | ||||
| lib/model/get_asset_count_by_time_bucket_dto.dart | ||||
| lib/model/import_asset_dto.dart | ||||
| lib/model/job_command.dart | ||||
| lib/model/job_command_dto.dart | ||||
| lib/model/job_counts_dto.dart | ||||
| @@ -284,6 +286,7 @@ test/download_files_dto_test.dart | ||||
| test/exif_response_dto_test.dart | ||||
| test/get_asset_by_time_bucket_dto_test.dart | ||||
| test/get_asset_count_by_time_bucket_dto_test.dart | ||||
| test/import_asset_dto_test.dart | ||||
| test/job_api_test.dart | ||||
| test/job_command_dto_test.dart | ||||
| test/job_command_test.dart | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/README.md
									
									
									
										generated
									
									
									
								
							| @@ -108,6 +108,7 @@ Class | Method | HTTP request | Description | ||||
| *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker |  | ||||
| *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane |  | ||||
| *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} |  | ||||
| *AssetApi* | [**importFile**](doc//AssetApi.md#importfile) | **POST** /asset/import |  | ||||
| *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search |  | ||||
| *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} |  | ||||
| *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} |  | ||||
| @@ -218,6 +219,7 @@ Class | Method | HTTP request | Description | ||||
|  - [ExifResponseDto](doc//ExifResponseDto.md) | ||||
|  - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md) | ||||
|  - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md) | ||||
|  - [ImportAssetDto](doc//ImportAssetDto.md) | ||||
|  - [JobCommand](doc//JobCommand.md) | ||||
|  - [JobCommandDto](doc//JobCommandDto.md) | ||||
|  - [JobCountsDto](doc//JobCountsDto.md) | ||||
|   | ||||
							
								
								
									
										66
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										66
									
								
								mobile/openapi/doc/AssetApi.md
									
									
									
										generated
									
									
									
								
							| @@ -29,6 +29,7 @@ Method | HTTP request | Description | ||||
| [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker |  | ||||
| [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane |  | ||||
| [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} |  | ||||
| [**importFile**](AssetApi.md#importfile) | **POST** /asset/import |  | ||||
| [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search |  | ||||
| [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} |  | ||||
| [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} |  | ||||
| @@ -1159,6 +1160,61 @@ Name | Type | Description  | Notes | ||||
| 
 | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
| # **importFile** | ||||
| > AssetFileUploadResponseDto importFile(importAssetDto) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| ### Example | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| // TODO Configure API key authorization: cookie | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY'; | ||||
| // uncomment below to setup prefix (e.g. Bearer) for API key, if needed | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer'; | ||||
| // TODO Configure API key authorization: api_key | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY'; | ||||
| // uncomment below to setup prefix (e.g. Bearer) for API key, if needed | ||||
| //defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer'; | ||||
| // TODO Configure HTTP Bearer authorization: bearer | ||||
| // Case 1. Use String Token | ||||
| //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); | ||||
| // Case 2. Use Function which generate token. | ||||
| // String yourTokenGeneratorFunction() { ... } | ||||
| //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction); | ||||
| 
 | ||||
| final api_instance = AssetApi(); | ||||
| final importAssetDto = ImportAssetDto(); // ImportAssetDto |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.importFile(importAssetDto); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling AssetApi->importFile: $e\n'); | ||||
| } | ||||
| ``` | ||||
| 
 | ||||
| ### Parameters | ||||
| 
 | ||||
| Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **importAssetDto** | [**ImportAssetDto**](ImportAssetDto.md)|  |  | ||||
| 
 | ||||
| ### Return type | ||||
| 
 | ||||
| [**AssetFileUploadResponseDto**](AssetFileUploadResponseDto.md) | ||||
| 
 | ||||
| ### Authorization | ||||
| 
 | ||||
| [cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer) | ||||
| 
 | ||||
| ### HTTP request headers | ||||
| 
 | ||||
|  - **Content-Type**: application/json | ||||
|  - **Accept**: application/json | ||||
| 
 | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
| # **searchAsset** | ||||
| > List<AssetResponseDto> searchAsset(searchAssetDto) | ||||
| 
 | ||||
| @@ -1335,7 +1391,7 @@ Name | Type | Description  | Notes | ||||
| [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) | ||||
| 
 | ||||
| # **uploadFile** | ||||
| > AssetFileUploadResponseDto uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration) | ||||
| > AssetFileUploadResponseDto uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration) | ||||
| 
 | ||||
| 
 | ||||
| 
 | ||||
| @@ -1360,21 +1416,22 @@ import 'package:openapi/api.dart'; | ||||
| final api_instance = AssetApi(); | ||||
| final assetType = ; // AssetTypeEnum |  | ||||
| final assetData = BINARY_DATA_HERE; // MultipartFile |  | ||||
| final fileExtension = fileExtension_example; // String |  | ||||
| final deviceAssetId = deviceAssetId_example; // String |  | ||||
| final deviceId = deviceId_example; // String |  | ||||
| final fileCreatedAt = 2013-10-20T19:20:30+01:00; // DateTime |  | ||||
| final fileModifiedAt = 2013-10-20T19:20:30+01:00; // DateTime |  | ||||
| final isFavorite = true; // bool |  | ||||
| final fileExtension = fileExtension_example; // String |  | ||||
| final key = key_example; // String |  | ||||
| final livePhotoData = BINARY_DATA_HERE; // MultipartFile |  | ||||
| final sidecarData = BINARY_DATA_HERE; // MultipartFile |  | ||||
| final isReadOnly = true; // bool |  | ||||
| final isArchived = true; // bool |  | ||||
| final isVisible = true; // bool |  | ||||
| final duration = duration_example; // String |  | ||||
| 
 | ||||
| try { | ||||
|     final result = api_instance.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration); | ||||
|     final result = api_instance.uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration); | ||||
|     print(result); | ||||
| } catch (e) { | ||||
|     print('Exception when calling AssetApi->uploadFile: $e\n'); | ||||
| @@ -1387,15 +1444,16 @@ Name | Type | Description  | Notes | ||||
| ------------- | ------------- | ------------- | ------------- | ||||
|  **assetType** | [**AssetTypeEnum**](AssetTypeEnum.md)|  |  | ||||
|  **assetData** | **MultipartFile**|  |  | ||||
|  **fileExtension** | **String**|  |  | ||||
|  **deviceAssetId** | **String**|  |  | ||||
|  **deviceId** | **String**|  |  | ||||
|  **fileCreatedAt** | **DateTime**|  |  | ||||
|  **fileModifiedAt** | **DateTime**|  |  | ||||
|  **isFavorite** | **bool**|  |  | ||||
|  **fileExtension** | **String**|  |  | ||||
|  **key** | **String**|  | [optional]  | ||||
|  **livePhotoData** | **MultipartFile**|  | [optional]  | ||||
|  **sidecarData** | **MultipartFile**|  | [optional]  | ||||
|  **isReadOnly** | **bool**|  | [optional] [default to false] | ||||
|  **isArchived** | **bool**|  | [optional]  | ||||
|  **isVisible** | **bool**|  | [optional]  | ||||
|  **duration** | **String**|  | [optional]  | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/CreateUserDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/CreateUserDto.md
									
									
									
										generated
									
									
									
								
							| @@ -13,6 +13,7 @@ Name | Type | Description | Notes | ||||
| **firstName** | **String** |  |  | ||||
| **lastName** | **String** |  |  | ||||
| **storageLabel** | **String** |  | [optional]  | ||||
| **externalPath** | **String** |  | [optional]  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										26
									
								
								mobile/openapi/doc/ImportAssetDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								mobile/openapi/doc/ImportAssetDto.md
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,26 @@ | ||||
| # openapi.model.ImportAssetDto | ||||
| 
 | ||||
| ## Load the model package | ||||
| ```dart | ||||
| import 'package:openapi/api.dart'; | ||||
| ``` | ||||
| 
 | ||||
| ## Properties | ||||
| Name | Type | Description | Notes | ||||
| ------------ | ------------- | ------------- | ------------- | ||||
| **assetType** | [**AssetTypeEnum**](AssetTypeEnum.md) |  |  | ||||
| **isReadOnly** | **bool** |  | [optional] [default to true] | ||||
| **assetPath** | **String** |  |  | ||||
| **sidecarPath** | **String** |  | [optional]  | ||||
| **deviceAssetId** | **String** |  |  | ||||
| **deviceId** | **String** |  |  | ||||
| **fileCreatedAt** | [**DateTime**](DateTime.md) |  |  | ||||
| **fileModifiedAt** | [**DateTime**](DateTime.md) |  |  | ||||
| **isFavorite** | **bool** |  |  | ||||
| **isArchived** | **bool** |  | [optional]  | ||||
| **isVisible** | **bool** |  | [optional]  | ||||
| **duration** | **String** |  | [optional]  | ||||
| 
 | ||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||
| 
 | ||||
| 
 | ||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/UpdateUserDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/UpdateUserDto.md
									
									
									
										generated
									
									
									
								
							| @@ -14,6 +14,7 @@ Name | Type | Description | Notes | ||||
| **firstName** | **String** |  | [optional]  | ||||
| **lastName** | **String** |  | [optional]  | ||||
| **storageLabel** | **String** |  | [optional]  | ||||
| **externalPath** | **String** |  | [optional]  | ||||
| **isAdmin** | **bool** |  | [optional]  | ||||
| **shouldChangePassword** | **bool** |  | [optional]  | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							| @@ -13,6 +13,7 @@ Name | Type | Description | Notes | ||||
| **firstName** | **String** |  |  | ||||
| **lastName** | **String** |  |  | ||||
| **storageLabel** | **String** |  |  | ||||
| **externalPath** | **String** |  |  | ||||
| **profileImagePath** | **String** |  |  | ||||
| **shouldChangePassword** | **bool** |  |  | ||||
| **isAdmin** | **bool** |  |  | ||||
|   | ||||
							
								
								
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/lib/api.dart
									
									
									
										generated
									
									
									
								
							| @@ -85,6 +85,7 @@ part 'model/download_files_dto.dart'; | ||||
| part 'model/exif_response_dto.dart'; | ||||
| part 'model/get_asset_by_time_bucket_dto.dart'; | ||||
| part 'model/get_asset_count_by_time_bucket_dto.dart'; | ||||
| part 'model/import_asset_dto.dart'; | ||||
| part 'model/job_command.dart'; | ||||
| part 'model/job_command_dto.dart'; | ||||
| part 'model/job_counts_dto.dart'; | ||||
|   | ||||
							
								
								
									
										77
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										77
									
								
								mobile/openapi/lib/api/asset_api.dart
									
									
									
										generated
									
									
									
								
							| @@ -1123,6 +1123,53 @@ class AssetApi { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'POST /asset/import' operation and returns the [Response]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [ImportAssetDto] importAssetDto (required): | ||||
|   Future<Response> importFileWithHttpInfo(ImportAssetDto importAssetDto,) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/asset/import'; | ||||
| 
 | ||||
|     // ignore: prefer_final_locals | ||||
|     Object? postBody = importAssetDto; | ||||
| 
 | ||||
|     final queryParams = <QueryParam>[]; | ||||
|     final headerParams = <String, String>{}; | ||||
|     final formParams = <String, String>{}; | ||||
| 
 | ||||
|     const contentTypes = <String>['application/json']; | ||||
| 
 | ||||
| 
 | ||||
|     return apiClient.invokeAPI( | ||||
|       path, | ||||
|       'POST', | ||||
|       queryParams, | ||||
|       postBody, | ||||
|       headerParams, | ||||
|       formParams, | ||||
|       contentTypes.isEmpty ? null : contentTypes.first, | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   /// Parameters: | ||||
|   /// | ||||
|   /// * [ImportAssetDto] importAssetDto (required): | ||||
|   Future<AssetFileUploadResponseDto?> importFile(ImportAssetDto importAssetDto,) async { | ||||
|     final response = await importFileWithHttpInfo(importAssetDto,); | ||||
|     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), 'AssetFileUploadResponseDto',) as AssetFileUploadResponseDto; | ||||
|      | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   /// Performs an HTTP 'POST /asset/search' operation and returns the [Response]. | ||||
|   /// Parameters: | ||||
|   /// | ||||
| @@ -1307,6 +1354,8 @@ class AssetApi { | ||||
|   /// | ||||
|   /// * [MultipartFile] assetData (required): | ||||
|   /// | ||||
|   /// * [String] fileExtension (required): | ||||
|   /// | ||||
|   /// * [String] deviceAssetId (required): | ||||
|   /// | ||||
|   /// * [String] deviceId (required): | ||||
| @@ -1317,20 +1366,20 @@ class AssetApi { | ||||
|   /// | ||||
|   /// * [bool] isFavorite (required): | ||||
|   /// | ||||
|   /// * [String] fileExtension (required): | ||||
|   /// | ||||
|   /// * [String] key: | ||||
|   /// | ||||
|   /// * [MultipartFile] livePhotoData: | ||||
|   /// | ||||
|   /// * [MultipartFile] sidecarData: | ||||
|   /// | ||||
|   /// * [bool] isReadOnly: | ||||
|   /// | ||||
|   /// * [bool] isArchived: | ||||
|   /// | ||||
|   /// * [bool] isVisible: | ||||
|   /// | ||||
|   /// * [String] duration: | ||||
|   Future<Response> uploadFileWithHttpInfo(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, String fileExtension, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isArchived, bool? isVisible, String? duration, }) async { | ||||
|   Future<Response> uploadFileWithHttpInfo(AssetTypeEnum assetType, MultipartFile assetData, String fileExtension, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isReadOnly, bool? isArchived, bool? isVisible, String? duration, }) async { | ||||
|     // ignore: prefer_const_declarations | ||||
|     final path = r'/asset/upload'; | ||||
| 
 | ||||
| @@ -1368,6 +1417,14 @@ class AssetApi { | ||||
|       mp.fields[r'sidecarData'] = sidecarData.field; | ||||
|       mp.files.add(sidecarData); | ||||
|     } | ||||
|     if (isReadOnly != null) { | ||||
|       hasFields = true; | ||||
|       mp.fields[r'isReadOnly'] = parameterToString(isReadOnly); | ||||
|     } | ||||
|     if (fileExtension != null) { | ||||
|       hasFields = true; | ||||
|       mp.fields[r'fileExtension'] = parameterToString(fileExtension); | ||||
|     } | ||||
|     if (deviceAssetId != null) { | ||||
|       hasFields = true; | ||||
|       mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId); | ||||
| @@ -1396,10 +1453,6 @@ class AssetApi { | ||||
|       hasFields = true; | ||||
|       mp.fields[r'isVisible'] = parameterToString(isVisible); | ||||
|     } | ||||
|     if (fileExtension != null) { | ||||
|       hasFields = true; | ||||
|       mp.fields[r'fileExtension'] = parameterToString(fileExtension); | ||||
|     } | ||||
|     if (duration != null) { | ||||
|       hasFields = true; | ||||
|       mp.fields[r'duration'] = parameterToString(duration); | ||||
| @@ -1425,6 +1478,8 @@ class AssetApi { | ||||
|   /// | ||||
|   /// * [MultipartFile] assetData (required): | ||||
|   /// | ||||
|   /// * [String] fileExtension (required): | ||||
|   /// | ||||
|   /// * [String] deviceAssetId (required): | ||||
|   /// | ||||
|   /// * [String] deviceId (required): | ||||
| @@ -1435,21 +1490,21 @@ class AssetApi { | ||||
|   /// | ||||
|   /// * [bool] isFavorite (required): | ||||
|   /// | ||||
|   /// * [String] fileExtension (required): | ||||
|   /// | ||||
|   /// * [String] key: | ||||
|   /// | ||||
|   /// * [MultipartFile] livePhotoData: | ||||
|   /// | ||||
|   /// * [MultipartFile] sidecarData: | ||||
|   /// | ||||
|   /// * [bool] isReadOnly: | ||||
|   /// | ||||
|   /// * [bool] isArchived: | ||||
|   /// | ||||
|   /// * [bool] isVisible: | ||||
|   /// | ||||
|   /// * [String] duration: | ||||
|   Future<AssetFileUploadResponseDto?> uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, String fileExtension, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isArchived, bool? isVisible, String? duration, }) async { | ||||
|     final response = await uploadFileWithHttpInfo(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension,  key: key, livePhotoData: livePhotoData, sidecarData: sidecarData, isArchived: isArchived, isVisible: isVisible, duration: duration, ); | ||||
|   Future<AssetFileUploadResponseDto?> uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String fileExtension, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isReadOnly, bool? isArchived, bool? isVisible, String? duration, }) async { | ||||
|     final response = await uploadFileWithHttpInfo(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite,  key: key, livePhotoData: livePhotoData, sidecarData: sidecarData, isReadOnly: isReadOnly, isArchived: isArchived, isVisible: isVisible, duration: duration, ); | ||||
|     if (response.statusCode >= HttpStatus.badRequest) { | ||||
|       throw ApiException(response.statusCode, await _decodeBodyBytes(response)); | ||||
|     } | ||||
|   | ||||
							
								
								
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								mobile/openapi/lib/api_client.dart
									
									
									
										generated
									
									
									
								
							| @@ -265,6 +265,8 @@ class ApiClient { | ||||
|           return GetAssetByTimeBucketDto.fromJson(value); | ||||
|         case 'GetAssetCountByTimeBucketDto': | ||||
|           return GetAssetCountByTimeBucketDto.fromJson(value); | ||||
|         case 'ImportAssetDto': | ||||
|           return ImportAssetDto.fromJson(value); | ||||
|         case 'JobCommand': | ||||
|           return JobCommandTypeTransformer().decode(value); | ||||
|         case 'JobCommandDto': | ||||
|   | ||||
							
								
								
									
										17
									
								
								mobile/openapi/lib/model/create_user_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										17
									
								
								mobile/openapi/lib/model/create_user_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -18,6 +18,7 @@ class CreateUserDto { | ||||
|     required this.firstName, | ||||
|     required this.lastName, | ||||
|     this.storageLabel, | ||||
|     this.externalPath, | ||||
|   }); | ||||
| 
 | ||||
|   String email; | ||||
| @@ -30,13 +31,16 @@ class CreateUserDto { | ||||
| 
 | ||||
|   String? storageLabel; | ||||
| 
 | ||||
|   String? externalPath; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is CreateUserDto && | ||||
|      other.email == email && | ||||
|      other.password == password && | ||||
|      other.firstName == firstName && | ||||
|      other.lastName == lastName && | ||||
|      other.storageLabel == storageLabel; | ||||
|      other.storageLabel == storageLabel && | ||||
|      other.externalPath == externalPath; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
| @@ -45,10 +49,11 @@ class CreateUserDto { | ||||
|     (password.hashCode) + | ||||
|     (firstName.hashCode) + | ||||
|     (lastName.hashCode) + | ||||
|     (storageLabel == null ? 0 : storageLabel!.hashCode); | ||||
|     (storageLabel == null ? 0 : storageLabel!.hashCode) + | ||||
|     (externalPath == null ? 0 : externalPath!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel]'; | ||||
|   String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, externalPath=$externalPath]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -61,6 +66,11 @@ class CreateUserDto { | ||||
|     } else { | ||||
|       // json[r'storageLabel'] = null; | ||||
|     } | ||||
|     if (this.externalPath != null) { | ||||
|       json[r'externalPath'] = this.externalPath; | ||||
|     } else { | ||||
|       // json[r'externalPath'] = null; | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
| @@ -88,6 +98,7 @@ class CreateUserDto { | ||||
|         firstName: mapValueOfType<String>(json, r'firstName')!, | ||||
|         lastName: mapValueOfType<String>(json, r'lastName')!, | ||||
|         storageLabel: mapValueOfType<String>(json, r'storageLabel'), | ||||
|         externalPath: mapValueOfType<String>(json, r'externalPath'), | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   | ||||
							
								
								
									
										232
									
								
								mobile/openapi/lib/model/import_asset_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								mobile/openapi/lib/model/import_asset_dto.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,232 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| part of openapi.api; | ||||
| 
 | ||||
| class ImportAssetDto { | ||||
|   /// Returns a new [ImportAssetDto] instance. | ||||
|   ImportAssetDto({ | ||||
|     required this.assetType, | ||||
|     this.isReadOnly = true, | ||||
|     required this.assetPath, | ||||
|     this.sidecarPath, | ||||
|     required this.deviceAssetId, | ||||
|     required this.deviceId, | ||||
|     required this.fileCreatedAt, | ||||
|     required this.fileModifiedAt, | ||||
|     required this.isFavorite, | ||||
|     this.isArchived, | ||||
|     this.isVisible, | ||||
|     this.duration, | ||||
|   }); | ||||
| 
 | ||||
|   AssetTypeEnum assetType; | ||||
| 
 | ||||
|   bool isReadOnly; | ||||
| 
 | ||||
|   String assetPath; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? sidecarPath; | ||||
| 
 | ||||
|   String deviceAssetId; | ||||
| 
 | ||||
|   String deviceId; | ||||
| 
 | ||||
|   DateTime fileCreatedAt; | ||||
| 
 | ||||
|   DateTime fileModifiedAt; | ||||
| 
 | ||||
|   bool isFavorite; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   bool? isArchived; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   bool? isVisible; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? duration; | ||||
| 
 | ||||
|   @override | ||||
|   bool operator ==(Object other) => identical(this, other) || other is ImportAssetDto && | ||||
|      other.assetType == assetType && | ||||
|      other.isReadOnly == isReadOnly && | ||||
|      other.assetPath == assetPath && | ||||
|      other.sidecarPath == sidecarPath && | ||||
|      other.deviceAssetId == deviceAssetId && | ||||
|      other.deviceId == deviceId && | ||||
|      other.fileCreatedAt == fileCreatedAt && | ||||
|      other.fileModifiedAt == fileModifiedAt && | ||||
|      other.isFavorite == isFavorite && | ||||
|      other.isArchived == isArchived && | ||||
|      other.isVisible == isVisible && | ||||
|      other.duration == duration; | ||||
| 
 | ||||
|   @override | ||||
|   int get hashCode => | ||||
|     // ignore: unnecessary_parenthesis | ||||
|     (assetType.hashCode) + | ||||
|     (isReadOnly.hashCode) + | ||||
|     (assetPath.hashCode) + | ||||
|     (sidecarPath == null ? 0 : sidecarPath!.hashCode) + | ||||
|     (deviceAssetId.hashCode) + | ||||
|     (deviceId.hashCode) + | ||||
|     (fileCreatedAt.hashCode) + | ||||
|     (fileModifiedAt.hashCode) + | ||||
|     (isFavorite.hashCode) + | ||||
|     (isArchived == null ? 0 : isArchived!.hashCode) + | ||||
|     (isVisible == null ? 0 : isVisible!.hashCode) + | ||||
|     (duration == null ? 0 : duration!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'ImportAssetDto[assetType=$assetType, isReadOnly=$isReadOnly, assetPath=$assetPath, sidecarPath=$sidecarPath, deviceAssetId=$deviceAssetId, deviceId=$deviceId, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, isFavorite=$isFavorite, isArchived=$isArchived, isVisible=$isVisible, duration=$duration]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|       json[r'assetType'] = this.assetType; | ||||
|       json[r'isReadOnly'] = this.isReadOnly; | ||||
|       json[r'assetPath'] = this.assetPath; | ||||
|     if (this.sidecarPath != null) { | ||||
|       json[r'sidecarPath'] = this.sidecarPath; | ||||
|     } else { | ||||
|       // json[r'sidecarPath'] = null; | ||||
|     } | ||||
|       json[r'deviceAssetId'] = this.deviceAssetId; | ||||
|       json[r'deviceId'] = this.deviceId; | ||||
|       json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String(); | ||||
|       json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String(); | ||||
|       json[r'isFavorite'] = this.isFavorite; | ||||
|     if (this.isArchived != null) { | ||||
|       json[r'isArchived'] = this.isArchived; | ||||
|     } else { | ||||
|       // json[r'isArchived'] = null; | ||||
|     } | ||||
|     if (this.isVisible != null) { | ||||
|       json[r'isVisible'] = this.isVisible; | ||||
|     } else { | ||||
|       // json[r'isVisible'] = null; | ||||
|     } | ||||
|     if (this.duration != null) { | ||||
|       json[r'duration'] = this.duration; | ||||
|     } else { | ||||
|       // json[r'duration'] = null; | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
| 
 | ||||
|   /// Returns a new [ImportAssetDto] instance and imports its values from | ||||
|   /// [value] if it's a [Map], null otherwise. | ||||
|   // ignore: prefer_constructors_over_static_methods | ||||
|   static ImportAssetDto? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
| 
 | ||||
|       // Ensure that the map contains the required keys. | ||||
|       // Note 1: the values aren't checked for validity beyond being non-null. | ||||
|       // Note 2: this code is stripped in release mode! | ||||
|       assert(() { | ||||
|         requiredKeys.forEach((key) { | ||||
|           assert(json.containsKey(key), 'Required key "ImportAssetDto[$key]" is missing from JSON.'); | ||||
|           assert(json[key] != null, 'Required key "ImportAssetDto[$key]" has a null value in JSON.'); | ||||
|         }); | ||||
|         return true; | ||||
|       }()); | ||||
| 
 | ||||
|       return ImportAssetDto( | ||||
|         assetType: AssetTypeEnum.fromJson(json[r'assetType'])!, | ||||
|         isReadOnly: mapValueOfType<bool>(json, r'isReadOnly') ?? true, | ||||
|         assetPath: mapValueOfType<String>(json, r'assetPath')!, | ||||
|         sidecarPath: mapValueOfType<String>(json, r'sidecarPath'), | ||||
|         deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!, | ||||
|         deviceId: mapValueOfType<String>(json, r'deviceId')!, | ||||
|         fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!, | ||||
|         fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!, | ||||
|         isFavorite: mapValueOfType<bool>(json, r'isFavorite')!, | ||||
|         isArchived: mapValueOfType<bool>(json, r'isArchived'), | ||||
|         isVisible: mapValueOfType<bool>(json, r'isVisible'), | ||||
|         duration: mapValueOfType<String>(json, r'duration'), | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   static List<ImportAssetDto> listFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final result = <ImportAssetDto>[]; | ||||
|     if (json is List && json.isNotEmpty) { | ||||
|       for (final row in json) { | ||||
|         final value = ImportAssetDto.fromJson(row); | ||||
|         if (value != null) { | ||||
|           result.add(value); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return result.toList(growable: growable); | ||||
|   } | ||||
| 
 | ||||
|   static Map<String, ImportAssetDto> mapFromJson(dynamic json) { | ||||
|     final map = <String, ImportAssetDto>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       json = json.cast<String, dynamic>(); // ignore: parameter_assignments | ||||
|       for (final entry in json.entries) { | ||||
|         final value = ImportAssetDto.fromJson(entry.value); | ||||
|         if (value != null) { | ||||
|           map[entry.key] = value; | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   // maps a json object with a list of ImportAssetDto-objects as value to a dart map | ||||
|   static Map<String, List<ImportAssetDto>> mapListFromJson(dynamic json, {bool growable = false,}) { | ||||
|     final map = <String, List<ImportAssetDto>>{}; | ||||
|     if (json is Map && json.isNotEmpty) { | ||||
|       // ignore: parameter_assignments | ||||
|       json = json.cast<String, dynamic>(); | ||||
|       for (final entry in json.entries) { | ||||
|         map[entry.key] = ImportAssetDto.listFromJson(entry.value, growable: growable,); | ||||
|       } | ||||
|     } | ||||
|     return map; | ||||
|   } | ||||
| 
 | ||||
|   /// The list of required keys that must be present in a JSON. | ||||
|   static const requiredKeys = <String>{ | ||||
|     'assetType', | ||||
|     'assetPath', | ||||
|     'deviceAssetId', | ||||
|     'deviceId', | ||||
|     'fileCreatedAt', | ||||
|     'fileModifiedAt', | ||||
|     'isFavorite', | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
							
								
								
									
										19
									
								
								mobile/openapi/lib/model/update_user_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								mobile/openapi/lib/model/update_user_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -19,6 +19,7 @@ class UpdateUserDto { | ||||
|     this.firstName, | ||||
|     this.lastName, | ||||
|     this.storageLabel, | ||||
|     this.externalPath, | ||||
|     this.isAdmin, | ||||
|     this.shouldChangePassword, | ||||
|   }); | ||||
| @@ -65,6 +66,14 @@ class UpdateUserDto { | ||||
|   /// | ||||
|   String? storageLabel; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
|   /// source code must fall back to having a nullable type. | ||||
|   /// Consider adding a "default:" property in the specification file to hide this note. | ||||
|   /// | ||||
|   String? externalPath; | ||||
| 
 | ||||
|   /// | ||||
|   /// Please note: This property should have been non-nullable! Since the specification file | ||||
|   /// does not include a default value (using the "default:" property), however, the generated | ||||
| @@ -89,6 +98,7 @@ class UpdateUserDto { | ||||
|      other.firstName == firstName && | ||||
|      other.lastName == lastName && | ||||
|      other.storageLabel == storageLabel && | ||||
|      other.externalPath == externalPath && | ||||
|      other.isAdmin == isAdmin && | ||||
|      other.shouldChangePassword == shouldChangePassword; | ||||
| 
 | ||||
| @@ -101,11 +111,12 @@ class UpdateUserDto { | ||||
|     (firstName == null ? 0 : firstName!.hashCode) + | ||||
|     (lastName == null ? 0 : lastName!.hashCode) + | ||||
|     (storageLabel == null ? 0 : storageLabel!.hashCode) + | ||||
|     (externalPath == null ? 0 : externalPath!.hashCode) + | ||||
|     (isAdmin == null ? 0 : isAdmin!.hashCode) + | ||||
|     (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'UpdateUserDto[id=$id, email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]'; | ||||
|   String toString() => 'UpdateUserDto[id=$id, email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, externalPath=$externalPath, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -135,6 +146,11 @@ class UpdateUserDto { | ||||
|     } else { | ||||
|       // json[r'storageLabel'] = null; | ||||
|     } | ||||
|     if (this.externalPath != null) { | ||||
|       json[r'externalPath'] = this.externalPath; | ||||
|     } else { | ||||
|       // json[r'externalPath'] = null; | ||||
|     } | ||||
|     if (this.isAdmin != null) { | ||||
|       json[r'isAdmin'] = this.isAdmin; | ||||
|     } else { | ||||
| @@ -173,6 +189,7 @@ class UpdateUserDto { | ||||
|         firstName: mapValueOfType<String>(json, r'firstName'), | ||||
|         lastName: mapValueOfType<String>(json, r'lastName'), | ||||
|         storageLabel: mapValueOfType<String>(json, r'storageLabel'), | ||||
|         externalPath: mapValueOfType<String>(json, r'externalPath'), | ||||
|         isAdmin: mapValueOfType<bool>(json, r'isAdmin'), | ||||
|         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'), | ||||
|       ); | ||||
|   | ||||
							
								
								
									
										14
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										14
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -18,6 +18,7 @@ class UserResponseDto { | ||||
|     required this.firstName, | ||||
|     required this.lastName, | ||||
|     required this.storageLabel, | ||||
|     required this.externalPath, | ||||
|     required this.profileImagePath, | ||||
|     required this.shouldChangePassword, | ||||
|     required this.isAdmin, | ||||
| @@ -37,6 +38,8 @@ class UserResponseDto { | ||||
| 
 | ||||
|   String? storageLabel; | ||||
| 
 | ||||
|   String? externalPath; | ||||
| 
 | ||||
|   String profileImagePath; | ||||
| 
 | ||||
|   bool shouldChangePassword; | ||||
| @@ -58,6 +61,7 @@ class UserResponseDto { | ||||
|      other.firstName == firstName && | ||||
|      other.lastName == lastName && | ||||
|      other.storageLabel == storageLabel && | ||||
|      other.externalPath == externalPath && | ||||
|      other.profileImagePath == profileImagePath && | ||||
|      other.shouldChangePassword == shouldChangePassword && | ||||
|      other.isAdmin == isAdmin && | ||||
| @@ -74,6 +78,7 @@ class UserResponseDto { | ||||
|     (firstName.hashCode) + | ||||
|     (lastName.hashCode) + | ||||
|     (storageLabel == null ? 0 : storageLabel!.hashCode) + | ||||
|     (externalPath == null ? 0 : externalPath!.hashCode) + | ||||
|     (profileImagePath.hashCode) + | ||||
|     (shouldChangePassword.hashCode) + | ||||
|     (isAdmin.hashCode) + | ||||
| @@ -83,7 +88,7 @@ class UserResponseDto { | ||||
|     (oauthId.hashCode); | ||||
| 
 | ||||
|   @override | ||||
|   String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, createdAt=$createdAt, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]'; | ||||
|   String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, externalPath=$externalPath, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, createdAt=$createdAt, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]'; | ||||
| 
 | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
| @@ -95,6 +100,11 @@ class UserResponseDto { | ||||
|       json[r'storageLabel'] = this.storageLabel; | ||||
|     } else { | ||||
|       // json[r'storageLabel'] = null; | ||||
|     } | ||||
|     if (this.externalPath != null) { | ||||
|       json[r'externalPath'] = this.externalPath; | ||||
|     } else { | ||||
|       // json[r'externalPath'] = null; | ||||
|     } | ||||
|       json[r'profileImagePath'] = this.profileImagePath; | ||||
|       json[r'shouldChangePassword'] = this.shouldChangePassword; | ||||
| @@ -134,6 +144,7 @@ class UserResponseDto { | ||||
|         firstName: mapValueOfType<String>(json, r'firstName')!, | ||||
|         lastName: mapValueOfType<String>(json, r'lastName')!, | ||||
|         storageLabel: mapValueOfType<String>(json, r'storageLabel'), | ||||
|         externalPath: mapValueOfType<String>(json, r'externalPath'), | ||||
|         profileImagePath: mapValueOfType<String>(json, r'profileImagePath')!, | ||||
|         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!, | ||||
|         isAdmin: mapValueOfType<bool>(json, r'isAdmin')!, | ||||
| @@ -193,6 +204,7 @@ class UserResponseDto { | ||||
|     'firstName', | ||||
|     'lastName', | ||||
|     'storageLabel', | ||||
|     'externalPath', | ||||
|     'profileImagePath', | ||||
|     'shouldChangePassword', | ||||
|     'isAdmin', | ||||
|   | ||||
							
								
								
									
										7
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										7
									
								
								mobile/openapi/test/asset_api_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -131,6 +131,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<AssetFileUploadResponseDto> importFile(ImportAssetDto importAssetDto) async | ||||
|     test('test importFile', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<List<AssetResponseDto>> searchAsset(SearchAssetDto searchAssetDto) async | ||||
|     test('test searchAsset', () async { | ||||
|       // TODO | ||||
| @@ -148,7 +153,7 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     //Future<AssetFileUploadResponseDto> uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, String fileExtension, { String key, MultipartFile livePhotoData, MultipartFile sidecarData, bool isArchived, bool isVisible, String duration }) async | ||||
|     //Future<AssetFileUploadResponseDto> uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String fileExtension, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, MultipartFile livePhotoData, MultipartFile sidecarData, bool isReadOnly, bool isArchived, bool isVisible, String duration }) async | ||||
|     test('test uploadFile', () async { | ||||
|       // TODO | ||||
|     }); | ||||
|   | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/create_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/create_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -41,6 +41,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String externalPath | ||||
|     test('to test the property `externalPath`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
|   | ||||
							
								
								
									
										82
									
								
								mobile/openapi/test/import_asset_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								mobile/openapi/test/import_asset_dto_test.dart
									
									
									
										generated
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| // | ||||
| // AUTO-GENERATED FILE, DO NOT MODIFY! | ||||
| // | ||||
| // @dart=2.12 | ||||
| 
 | ||||
| // ignore_for_file: unused_element, unused_import | ||||
| // ignore_for_file: always_put_required_named_parameters_first | ||||
| // ignore_for_file: constant_identifier_names | ||||
| // ignore_for_file: lines_longer_than_80_chars | ||||
| 
 | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:test/test.dart'; | ||||
| 
 | ||||
| // tests for ImportAssetDto | ||||
| void main() { | ||||
|   // final instance = ImportAssetDto(); | ||||
| 
 | ||||
|   group('test ImportAssetDto', () { | ||||
|     // AssetTypeEnum assetType | ||||
|     test('to test the property `assetType`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // bool isReadOnly (default value: true) | ||||
|     test('to test the property `isReadOnly`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String assetPath | ||||
|     test('to test the property `assetPath`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String sidecarPath | ||||
|     test('to test the property `sidecarPath`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String deviceAssetId | ||||
|     test('to test the property `deviceAssetId`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String deviceId | ||||
|     test('to test the property `deviceId`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // DateTime fileCreatedAt | ||||
|     test('to test the property `fileCreatedAt`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // DateTime fileModifiedAt | ||||
|     test('to test the property `fileModifiedAt`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // bool isFavorite | ||||
|     test('to test the property `isFavorite`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // bool isArchived | ||||
|     test('to test the property `isArchived`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // bool isVisible | ||||
|     test('to test the property `isVisible`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String duration | ||||
|     test('to test the property `duration`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
| 
 | ||||
|   }); | ||||
| 
 | ||||
| } | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/update_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/update_user_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -46,6 +46,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String externalPath | ||||
|     test('to test the property `externalPath`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // bool isAdmin | ||||
|     test('to test the property `isAdmin`', () async { | ||||
|       // TODO | ||||
|   | ||||
							
								
								
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -41,6 +41,11 @@ void main() { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String externalPath | ||||
|     test('to test the property `externalPath`', () async { | ||||
|       // TODO | ||||
|     }); | ||||
| 
 | ||||
|     // String profileImagePath | ||||
|     test('to test the property `profileImagePath`', () async { | ||||
|       // TODO | ||||
|   | ||||
| @@ -105,6 +105,7 @@ describe('User', () => { | ||||
|               updatedAt: expect.anything(), | ||||
|               oauthId: '', | ||||
|               storageLabel: null, | ||||
|               externalPath: null, | ||||
|             }, | ||||
|             { | ||||
|               email: userTwoEmail, | ||||
| @@ -119,6 +120,7 @@ describe('User', () => { | ||||
|               updatedAt: expect.anything(), | ||||
|               oauthId: '', | ||||
|               storageLabel: null, | ||||
|               externalPath: null, | ||||
|             }, | ||||
|             { | ||||
|               email: authUserEmail, | ||||
| @@ -133,6 +135,7 @@ describe('User', () => { | ||||
|               updatedAt: expect.anything(), | ||||
|               oauthId: '', | ||||
|               storageLabel: 'admin', | ||||
|               externalPath: null, | ||||
|             }, | ||||
|           ]), | ||||
|         ); | ||||
|   | ||||
| @@ -1430,6 +1430,48 @@ | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/asset/import": { | ||||
|       "post": { | ||||
|         "operationId": "importFile", | ||||
|         "parameters": [], | ||||
|         "requestBody": { | ||||
|           "required": true, | ||||
|           "content": { | ||||
|             "application/json": { | ||||
|               "schema": { | ||||
|                 "$ref": "#/components/schemas/ImportAssetDto" | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "responses": { | ||||
|           "201": { | ||||
|             "description": "", | ||||
|             "content": { | ||||
|               "application/json": { | ||||
|                 "schema": { | ||||
|                   "$ref": "#/components/schemas/AssetFileUploadResponseDto" | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|           } | ||||
|         }, | ||||
|         "tags": [ | ||||
|           "Asset" | ||||
|         ], | ||||
|         "security": [ | ||||
|           { | ||||
|             "bearer": [] | ||||
|           }, | ||||
|           { | ||||
|             "cookie": [] | ||||
|           }, | ||||
|           { | ||||
|             "api_key": [] | ||||
|           } | ||||
|         ] | ||||
|       } | ||||
|     }, | ||||
|     "/asset/map-marker": { | ||||
|       "get": { | ||||
|         "operationId": "getMapMarkers", | ||||
| @@ -5085,6 +5127,13 @@ | ||||
|             "type": "string", | ||||
|             "format": "binary" | ||||
|           }, | ||||
|           "isReadOnly": { | ||||
|             "type": "boolean", | ||||
|             "default": false | ||||
|           }, | ||||
|           "fileExtension": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "deviceAssetId": { | ||||
|             "type": "string" | ||||
|           }, | ||||
| @@ -5108,9 +5157,6 @@ | ||||
|           "isVisible": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "fileExtension": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "duration": { | ||||
|             "type": "string" | ||||
|           } | ||||
| @@ -5118,12 +5164,12 @@ | ||||
|         "required": [ | ||||
|           "assetType", | ||||
|           "assetData", | ||||
|           "fileExtension", | ||||
|           "deviceAssetId", | ||||
|           "deviceId", | ||||
|           "fileCreatedAt", | ||||
|           "fileModifiedAt", | ||||
|           "isFavorite", | ||||
|           "fileExtension" | ||||
|           "isFavorite" | ||||
|         ] | ||||
|       }, | ||||
|       "CreateProfileImageDto": { | ||||
| @@ -5186,6 +5232,10 @@ | ||||
|           "storageLabel": { | ||||
|             "type": "string", | ||||
|             "nullable": true | ||||
|           }, | ||||
|           "externalPath": { | ||||
|             "type": "string", | ||||
|             "nullable": true | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
| @@ -5461,6 +5511,59 @@ | ||||
|           "timeGroup" | ||||
|         ] | ||||
|       }, | ||||
|       "ImportAssetDto": { | ||||
|         "type": "object", | ||||
|         "properties": { | ||||
|           "assetType": { | ||||
|             "$ref": "#/components/schemas/AssetTypeEnum" | ||||
|           }, | ||||
|           "isReadOnly": { | ||||
|             "type": "boolean", | ||||
|             "default": true | ||||
|           }, | ||||
|           "assetPath": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "sidecarPath": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "deviceAssetId": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "deviceId": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "fileCreatedAt": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "fileModifiedAt": { | ||||
|             "format": "date-time", | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "isFavorite": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "isArchived": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "isVisible": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
|           "duration": { | ||||
|             "type": "string" | ||||
|           } | ||||
|         }, | ||||
|         "required": [ | ||||
|           "assetType", | ||||
|           "assetPath", | ||||
|           "deviceAssetId", | ||||
|           "deviceId", | ||||
|           "fileCreatedAt", | ||||
|           "fileModifiedAt", | ||||
|           "isFavorite" | ||||
|         ] | ||||
|       }, | ||||
|       "JobCommand": { | ||||
|         "type": "string", | ||||
|         "enum": [ | ||||
| @@ -6592,6 +6695,9 @@ | ||||
|           "storageLabel": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "externalPath": { | ||||
|             "type": "string" | ||||
|           }, | ||||
|           "isAdmin": { | ||||
|             "type": "boolean" | ||||
|           }, | ||||
| @@ -6665,6 +6771,10 @@ | ||||
|             "type": "string", | ||||
|             "nullable": true | ||||
|           }, | ||||
|           "externalPath": { | ||||
|             "type": "string", | ||||
|             "nullable": true | ||||
|           }, | ||||
|           "profileImagePath": { | ||||
|             "type": "string" | ||||
|           }, | ||||
| @@ -6697,6 +6807,7 @@ | ||||
|           "firstName", | ||||
|           "lastName", | ||||
|           "storageLabel", | ||||
|           "externalPath", | ||||
|           "profileImagePath", | ||||
|           "shouldChangePassword", | ||||
|           "isAdmin", | ||||
|   | ||||
							
								
								
									
										12
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										12
									
								
								server/package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -21,6 +21,7 @@ | ||||
|         "@nestjs/typeorm": "^9.0.1", | ||||
|         "@nestjs/websockets": "^9.2.1", | ||||
|         "@socket.io/redis-adapter": "^8.0.1", | ||||
|         "@types/mime-types": "^2.1.1", | ||||
|         "archiver": "^5.3.1", | ||||
|         "axios": "^0.26.0", | ||||
|         "bcrypt": "^5.0.1", | ||||
| @@ -38,6 +39,7 @@ | ||||
|         "local-reverse-geocoder": "0.12.5", | ||||
|         "lodash": "^4.17.21", | ||||
|         "luxon": "^3.0.3", | ||||
|         "mime-types": "^2.1.35", | ||||
|         "mv": "^2.1.1", | ||||
|         "nest-commander": "^3.3.0", | ||||
|         "openid-client": "^5.2.1", | ||||
| @@ -3018,6 +3020,11 @@ | ||||
|       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "node_modules/@types/mime-types": { | ||||
|       "version": "2.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz", | ||||
|       "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==" | ||||
|     }, | ||||
|     "node_modules/@types/multer": { | ||||
|       "version": "1.4.7", | ||||
|       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", | ||||
| @@ -14296,6 +14303,11 @@ | ||||
|       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==", | ||||
|       "dev": true | ||||
|     }, | ||||
|     "@types/mime-types": { | ||||
|       "version": "2.1.1", | ||||
|       "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz", | ||||
|       "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==" | ||||
|     }, | ||||
|     "@types/multer": { | ||||
|       "version": "1.4.7", | ||||
|       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz", | ||||
|   | ||||
| @@ -50,6 +50,7 @@ | ||||
|     "@nestjs/typeorm": "^9.0.1", | ||||
|     "@nestjs/websockets": "^9.2.1", | ||||
|     "@socket.io/redis-adapter": "^8.0.1", | ||||
|     "@types/mime-types": "^2.1.1", | ||||
|     "archiver": "^5.3.1", | ||||
|     "axios": "^0.26.0", | ||||
|     "bcrypt": "^5.0.1", | ||||
| @@ -67,6 +68,7 @@ | ||||
|     "local-reverse-geocoder": "0.12.5", | ||||
|     "lodash": "^4.17.21", | ||||
|     "luxon": "^3.0.3", | ||||
|     "mime-types": "^2.1.35", | ||||
|     "mv": "^2.1.1", | ||||
|     "nest-commander": "^3.3.0", | ||||
|     "openid-client": "^5.2.1", | ||||
|   | ||||
| @@ -169,6 +169,7 @@ describe(AlbumService.name, () => { | ||||
|           createdAt: new Date('2021-01-01'), | ||||
|           deletedAt: null, | ||||
|           updatedAt: new Date('2021-01-01'), | ||||
|           externalPath: null, | ||||
|         }, | ||||
|         ownerId: 'admin_id', | ||||
|         shared: false, | ||||
|   | ||||
| @@ -19,6 +19,7 @@ export class APIKeyCore { | ||||
|         isAdmin: user.isAdmin, | ||||
|         isPublicUser: false, | ||||
|         isAllowUpload: true, | ||||
|         externalPath: user.externalPath, | ||||
|       }; | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -8,4 +8,5 @@ export class AuthUserDto { | ||||
|   isAllowDownload?: boolean; | ||||
|   isShowExif?: boolean; | ||||
|   accessTokenId?: string; | ||||
|   externalPath?: string | null; | ||||
| } | ||||
|   | ||||
| @@ -2,6 +2,7 @@ export const ICryptoRepository = 'ICryptoRepository'; | ||||
|  | ||||
| export interface ICryptoRepository { | ||||
|   randomBytes(size: number): Buffer; | ||||
|   hashFile(filePath: string): Promise<Buffer>; | ||||
|   hashSha256(data: string): string; | ||||
|   hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>; | ||||
|   compareBcrypt(data: string | Buffer, encrypted: string): boolean; | ||||
|   | ||||
| @@ -27,3 +27,60 @@ export function assertMachineLearningEnabled() { | ||||
|     throw new BadRequestException('Machine learning is not enabled.'); | ||||
|   } | ||||
| } | ||||
|  | ||||
| const validMimeTypes = [ | ||||
|   'image/avif', | ||||
|   'image/gif', | ||||
|   'image/heic', | ||||
|   'image/heif', | ||||
|   'image/jpeg', | ||||
|   'image/jxl', | ||||
|   'image/png', | ||||
|   'image/tiff', | ||||
|   'image/webp', | ||||
|   'image/x-adobe-dng', | ||||
|   'image/x-arriflex-ari', | ||||
|   'image/x-canon-cr2', | ||||
|   'image/x-canon-cr3', | ||||
|   'image/x-canon-crw', | ||||
|   'image/x-epson-erf', | ||||
|   'image/x-fuji-raf', | ||||
|   'image/x-hasselblad-3fr', | ||||
|   'image/x-hasselblad-fff', | ||||
|   'image/x-kodak-dcr', | ||||
|   'image/x-kodak-k25', | ||||
|   'image/x-kodak-kdc', | ||||
|   'image/x-leica-rwl', | ||||
|   'image/x-minolta-mrw', | ||||
|   'image/x-nikon-nef', | ||||
|   'image/x-olympus-orf', | ||||
|   'image/x-olympus-ori', | ||||
|   'image/x-panasonic-raw', | ||||
|   'image/x-pentax-pef', | ||||
|   'image/x-phantom-cin', | ||||
|   'image/x-phaseone-cap', | ||||
|   'image/x-phaseone-iiq', | ||||
|   'image/x-samsung-srw', | ||||
|   'image/x-sigma-x3f', | ||||
|   'image/x-sony-arw', | ||||
|   'image/x-sony-sr2', | ||||
|   'image/x-sony-srf', | ||||
|   'video/3gpp', | ||||
|   'video/mp2t', | ||||
|   'video/mp4', | ||||
|   'video/mpeg', | ||||
|   'video/quicktime', | ||||
|   'video/webm', | ||||
|   'video/x-flv', | ||||
|   'video/x-matroska', | ||||
|   'video/x-ms-wmv', | ||||
|   'video/x-msvideo', | ||||
| ]; | ||||
|  | ||||
| export function isSupportedFileType(mimetype: string): boolean { | ||||
|   return validMimeTypes.includes(mimetype); | ||||
| } | ||||
|  | ||||
| export function isSidecarFileType(mimeType: string): boolean { | ||||
|   return ['application/xml', 'text/xml'].includes(mimeType); | ||||
| } | ||||
|   | ||||
| @@ -17,6 +17,7 @@ const responseDto = { | ||||
|     createdAt: new Date('2021-01-01'), | ||||
|     deletedAt: null, | ||||
|     updatedAt: new Date('2021-01-01'), | ||||
|     externalPath: null, | ||||
|   }, | ||||
|   user1: { | ||||
|     email: 'immich@test.com', | ||||
| @@ -31,6 +32,7 @@ const responseDto = { | ||||
|     createdAt: new Date('2021-01-01'), | ||||
|     deletedAt: null, | ||||
|     updatedAt: new Date('2021-01-01'), | ||||
|     externalPath: null, | ||||
|   }, | ||||
| }; | ||||
|  | ||||
|   | ||||
| @@ -194,5 +194,26 @@ describe(StorageTemplateService.name, () => { | ||||
|         ['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'], | ||||
|       ]); | ||||
|     }); | ||||
|  | ||||
|     it('should not move read-only asset', async () => { | ||||
|       assetMock.getAll.mockResolvedValue({ | ||||
|         items: [ | ||||
|           { | ||||
|             ...assetEntityStub.image, | ||||
|             originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', | ||||
|             isReadOnly: true, | ||||
|           }, | ||||
|         ], | ||||
|         hasNextPage: false, | ||||
|       }); | ||||
|       assetMock.save.mockResolvedValue(assetEntityStub.image); | ||||
|       userMock.getList.mockResolvedValue([userEntityStub.user1]); | ||||
|  | ||||
|       await sut.handleMigration(); | ||||
|  | ||||
|       expect(assetMock.getAll).toHaveBeenCalled(); | ||||
|       expect(storageMock.moveFile).not.toHaveBeenCalled(); | ||||
|       expect(assetMock.save).not.toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
|   | ||||
| @@ -76,6 +76,11 @@ export class StorageTemplateService { | ||||
|  | ||||
|   // TODO: use asset core (once in domain) | ||||
|   async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) { | ||||
|     if (asset.isReadOnly) { | ||||
|       this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     const destination = await this.core.getTemplatePath(asset, metadata); | ||||
|     if (asset.originalPath !== destination) { | ||||
|       const source = asset.originalPath; | ||||
|   | ||||
| @@ -23,6 +23,10 @@ export class CreateUserDto { | ||||
|   @IsString() | ||||
|   @Transform(toSanitized) | ||||
|   storageLabel?: string | null; | ||||
|  | ||||
|   @IsOptional() | ||||
|   @IsString() | ||||
|   externalPath?: string | null; | ||||
| } | ||||
|  | ||||
| export class CreateAdminDto { | ||||
|   | ||||
| @@ -29,6 +29,10 @@ export class UpdateUserDto { | ||||
|   @Transform(toSanitized) | ||||
|   storageLabel?: string; | ||||
|  | ||||
|   @IsOptional() | ||||
|   @IsString() | ||||
|   externalPath?: string; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   @IsUUID('4') | ||||
|   @ApiProperty({ format: 'uuid' }) | ||||
|   | ||||
| @@ -6,6 +6,7 @@ export class UserResponseDto { | ||||
|   firstName!: string; | ||||
|   lastName!: string; | ||||
|   storageLabel!: string | null; | ||||
|   externalPath!: string | null; | ||||
|   profileImagePath!: string; | ||||
|   shouldChangePassword!: boolean; | ||||
|   isAdmin!: boolean; | ||||
| @@ -22,6 +23,7 @@ export function mapUser(entity: UserEntity): UserResponseDto { | ||||
|     firstName: entity.firstName, | ||||
|     lastName: entity.lastName, | ||||
|     storageLabel: entity.storageLabel, | ||||
|     externalPath: entity.externalPath, | ||||
|     profileImagePath: entity.profileImagePath, | ||||
|     shouldChangePassword: entity.shouldChangePassword, | ||||
|     isAdmin: entity.isAdmin, | ||||
|   | ||||
| @@ -6,7 +6,6 @@ import { | ||||
|   Logger, | ||||
|   NotFoundException, | ||||
| } from '@nestjs/common'; | ||||
| import { hash } from 'bcrypt'; | ||||
| import { constants, createReadStream, ReadStream } from 'fs'; | ||||
| import fs from 'fs/promises'; | ||||
| import { AuthUserDto } from '../auth'; | ||||
| @@ -28,6 +27,7 @@ export class UserCore { | ||||
|       // Users can never update the isAdmin property. | ||||
|       delete dto.isAdmin; | ||||
|       delete dto.storageLabel; | ||||
|       delete dto.externalPath; | ||||
|     } else if (dto.isAdmin && authUser.id !== id) { | ||||
|       // Admin cannot create another admin. | ||||
|       throw new BadRequestException('The server already has an admin'); | ||||
| @@ -56,6 +56,10 @@ export class UserCore { | ||||
|         dto.storageLabel = null; | ||||
|       } | ||||
|  | ||||
|       if (dto.externalPath === '') { | ||||
|         dto.externalPath = null; | ||||
|       } | ||||
|  | ||||
|       return this.userRepository.update(id, dto); | ||||
|     } catch (e) { | ||||
|       Logger.error(e, 'Failed to update user info'); | ||||
| @@ -79,7 +83,7 @@ export class UserCore { | ||||
|     try { | ||||
|       const payload: Partial<UserEntity> = { ...createUserDto }; | ||||
|       if (payload.password) { | ||||
|         payload.password = await hash(payload.password, SALT_ROUNDS); | ||||
|         payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); | ||||
|       } | ||||
|       return this.userRepository.create(payload); | ||||
|     } catch (e) { | ||||
|   | ||||
| @@ -53,6 +53,7 @@ const adminUser: UserEntity = Object.freeze({ | ||||
|   tags: [], | ||||
|   assets: [], | ||||
|   storageLabel: 'admin', | ||||
|   externalPath: null, | ||||
| }); | ||||
|  | ||||
| const immichUser: UserEntity = Object.freeze({ | ||||
| @@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({ | ||||
|   tags: [], | ||||
|   assets: [], | ||||
|   storageLabel: null, | ||||
|   externalPath: null, | ||||
| }); | ||||
|  | ||||
| const updatedImmichUser: UserEntity = Object.freeze({ | ||||
| @@ -89,6 +91,7 @@ const updatedImmichUser: UserEntity = Object.freeze({ | ||||
|   tags: [], | ||||
|   assets: [], | ||||
|   storageLabel: null, | ||||
|   externalPath: null, | ||||
| }); | ||||
|  | ||||
| const adminUserResponse = Object.freeze({ | ||||
| @@ -104,6 +107,7 @@ const adminUserResponse = Object.freeze({ | ||||
|   deletedAt: null, | ||||
|   updatedAt: new Date('2021-01-01'), | ||||
|   storageLabel: 'admin', | ||||
|   externalPath: null, | ||||
| }); | ||||
|  | ||||
| describe(UserService.name, () => { | ||||
| @@ -153,6 +157,7 @@ describe(UserService.name, () => { | ||||
|           deletedAt: null, | ||||
|           updatedAt: new Date('2021-01-01'), | ||||
|           storageLabel: 'admin', | ||||
|           externalPath: null, | ||||
|         }, | ||||
|       ]); | ||||
|     }); | ||||
|   | ||||
| @@ -32,6 +32,7 @@ describe('Album service', () => { | ||||
|     tags: [], | ||||
|     assets: [], | ||||
|     storageLabel: null, | ||||
|     externalPath: null, | ||||
|   }); | ||||
|   const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; | ||||
|   const sharedAlbumOwnerId = '2222'; | ||||
|   | ||||
| @@ -20,6 +20,10 @@ export interface AssetCheck { | ||||
|   checksum: Buffer; | ||||
| } | ||||
|  | ||||
| export interface AssetOwnerCheck extends AssetCheck { | ||||
|   ownerId: string; | ||||
| } | ||||
|  | ||||
| export interface IAssetRepository { | ||||
|   get(id: string): Promise<AssetEntity | null>; | ||||
|   create( | ||||
| @@ -39,6 +43,7 @@ export interface IAssetRepository { | ||||
|   getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>; | ||||
|   getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>; | ||||
|   getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>; | ||||
|   getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>; | ||||
| } | ||||
|  | ||||
| export const IAssetRepository = 'IAssetRepository'; | ||||
| @@ -350,4 +355,17 @@ export class AssetRepository implements IAssetRepository { | ||||
|  | ||||
|     return assetCountByUserId; | ||||
|   } | ||||
|  | ||||
|   getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null> { | ||||
|     return this.assetRepository.findOne({ | ||||
|       select: { | ||||
|         id: true, | ||||
|         ownerId: true, | ||||
|         checksum: true, | ||||
|       }, | ||||
|       where: { | ||||
|         originalPath, | ||||
|       }, | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -33,7 +33,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; | ||||
| import { AssetSearchDto } from './dto/asset-search.dto'; | ||||
| import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; | ||||
| import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; | ||||
| import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto'; | ||||
| import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto'; | ||||
| import { DeleteAssetDto } from './dto/delete-asset.dto'; | ||||
| import { DeviceIdDto } from './dto/device-id.dto'; | ||||
| import { DownloadFilesDto } from './dto/download-files.dto'; | ||||
| @@ -114,6 +114,20 @@ export class AssetController { | ||||
|     return responseDto; | ||||
|   } | ||||
|  | ||||
|   @Post('import') | ||||
|   async importFile( | ||||
|     @AuthUser() authUser: AuthUserDto, | ||||
|     @Body(new ValidationPipe()) dto: ImportAssetDto, | ||||
|     @Response({ passthrough: true }) res: Res, | ||||
|   ): Promise<AssetFileUploadResponseDto> { | ||||
|     const responseDto = await this.assetService.importFile(authUser, dto); | ||||
|     if (responseDto.duplicate) { | ||||
|       res.status(200); | ||||
|     } | ||||
|  | ||||
|     return responseDto; | ||||
|   } | ||||
|  | ||||
|   @SharedLinkRoute() | ||||
|   @Get('/download/:id') | ||||
|   @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } }) | ||||
|   | ||||
| @@ -2,17 +2,17 @@ import { AuthUserDto, IJobRepository, JobName } from '@app/domain'; | ||||
| import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities'; | ||||
| import { parse } from 'node:path'; | ||||
| import { IAssetRepository } from './asset-repository'; | ||||
| import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; | ||||
| import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto'; | ||||
|  | ||||
| export class AssetCore { | ||||
|   constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {} | ||||
|  | ||||
|   async create( | ||||
|     authUser: AuthUserDto, | ||||
|     dto: CreateAssetDto, | ||||
|     dto: CreateAssetDto | ImportAssetDto, | ||||
|     file: UploadFile, | ||||
|     livePhotoAssetId?: string, | ||||
|     sidecarFile?: UploadFile, | ||||
|     sidecarPath?: string, | ||||
|   ): Promise<AssetEntity> { | ||||
|     const asset = await this.repository.create({ | ||||
|       owner: { id: authUser.id } as UserEntity, | ||||
| @@ -41,7 +41,8 @@ export class AssetCore { | ||||
|       sharedLinks: [], | ||||
|       originalFileName: parse(file.originalName).name, | ||||
|       faces: [], | ||||
|       sidecarPath: sidecarFile?.originalPath || null, | ||||
|       sidecarPath: sidecarPath || null, | ||||
|       isReadOnly: dto.isReadOnly ?? false, | ||||
|     }); | ||||
|  | ||||
|     await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } }); | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { IAccessRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain'; | ||||
| import { IAccessRepository, ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain'; | ||||
| import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities'; | ||||
| import { ForbiddenException } from '@nestjs/common'; | ||||
| import { | ||||
| @@ -6,6 +6,7 @@ import { | ||||
|   authStub, | ||||
|   fileStub, | ||||
|   newAccessRepositoryMock, | ||||
|   newCryptoRepositoryMock, | ||||
|   newJobRepositoryMock, | ||||
|   newStorageRepositoryMock, | ||||
| } from '@test'; | ||||
| @@ -121,6 +122,7 @@ describe('AssetService', () => { | ||||
|   let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING | ||||
|   let accessMock: jest.Mocked<IAccessRepository>; | ||||
|   let assetRepositoryMock: jest.Mocked<IAssetRepository>; | ||||
|   let cryptoMock: jest.Mocked<ICryptoRepository>; | ||||
|   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>; | ||||
|   let jobMock: jest.Mocked<IJobRepository>; | ||||
|   let storageMock: jest.Mocked<IStorageRepository>; | ||||
| @@ -144,13 +146,17 @@ describe('AssetService', () => { | ||||
|       getAssetCountByUserId: jest.fn(), | ||||
|       getArchivedAssetCountByUserId: jest.fn(), | ||||
|       getExistingAssets: jest.fn(), | ||||
|       getByOriginalPath: jest.fn(), | ||||
|     }; | ||||
|  | ||||
|     cryptoMock = newCryptoRepositoryMock(); | ||||
|  | ||||
|     downloadServiceMock = { | ||||
|       downloadArchive: jest.fn(), | ||||
|     }; | ||||
|  | ||||
|     accessMock = newAccessRepositoryMock(); | ||||
|     cryptoMock = newCryptoRepositoryMock(); | ||||
|     jobMock = newJobRepositoryMock(); | ||||
|     storageMock = newStorageRepositoryMock(); | ||||
|  | ||||
| @@ -158,6 +164,7 @@ describe('AssetService', () => { | ||||
|       accessMock, | ||||
|       assetRepositoryMock, | ||||
|       a, | ||||
|       cryptoMock, | ||||
|       downloadServiceMock as DownloadService, | ||||
|       jobMock, | ||||
|       storageMock, | ||||
| @@ -439,6 +446,43 @@ describe('AssetService', () => { | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('importFile', () => { | ||||
|     it('should handle a file import', async () => { | ||||
|       assetRepositoryMock.create.mockResolvedValue(assetEntityStub.image); | ||||
|       storageMock.checkFileExists.mockResolvedValue(true); | ||||
|  | ||||
|       await expect( | ||||
|         sut.importFile(authStub.external1, { | ||||
|           ..._getCreateAssetDto(), | ||||
|           assetPath: '/data/user1/fake_path/asset_1.jpeg', | ||||
|           isReadOnly: true, | ||||
|         }), | ||||
|       ).resolves.toEqual({ duplicate: false, id: 'asset-id' }); | ||||
|  | ||||
|       expect(assetRepositoryMock.create).toHaveBeenCalled(); | ||||
|     }); | ||||
|  | ||||
|     it('should handle a duplicate if originalPath already exists', async () => { | ||||
|       const error = new QueryFailedError('', [], ''); | ||||
|       (error as any).constraint = 'UQ_userid_checksum'; | ||||
|  | ||||
|       assetRepositoryMock.create.mockRejectedValue(error); | ||||
|       assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetEntityStub.image]); | ||||
|       storageMock.checkFileExists.mockResolvedValue(true); | ||||
|       cryptoMock.hashFile.mockResolvedValue(Buffer.from('file hash', 'utf8')); | ||||
|  | ||||
|       await expect( | ||||
|         sut.importFile(authStub.external1, { | ||||
|           ..._getCreateAssetDto(), | ||||
|           assetPath: '/data/user1/fake_path/asset_1.jpeg', | ||||
|           isReadOnly: true, | ||||
|         }), | ||||
|       ).resolves.toEqual({ duplicate: true, id: 'asset-id' }); | ||||
|  | ||||
|       expect(assetRepositoryMock.create).toHaveBeenCalled(); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   describe('getAssetById', () => { | ||||
|     it('should allow owner access', async () => { | ||||
|       accessMock.hasOwnerAssetAccess.mockResolvedValue(true); | ||||
|   | ||||
| @@ -1,9 +1,13 @@ | ||||
| import { | ||||
|   AssetResponseDto, | ||||
|   AuthUserDto, | ||||
|   getLivePhotoMotionFilename, | ||||
|   IAccessRepository, | ||||
|   ICryptoRepository, | ||||
|   IJobRepository, | ||||
|   ImmichReadStream, | ||||
|   isSidecarFileType, | ||||
|   isSupportedFileType, | ||||
|   IStorageRepository, | ||||
|   JobName, | ||||
|   mapAsset, | ||||
| @@ -21,12 +25,14 @@ import { | ||||
|   StreamableFile, | ||||
| } from '@nestjs/common'; | ||||
| import { InjectRepository } from '@nestjs/typeorm'; | ||||
| import { R_OK, W_OK } from 'constants'; | ||||
| import { Response as Res } from 'express'; | ||||
| import { constants, createReadStream, stat } from 'fs'; | ||||
| import { createReadStream, stat } from 'fs'; | ||||
| import fs from 'fs/promises'; | ||||
| import mime from 'mime-types'; | ||||
| import path from 'path'; | ||||
| import { QueryFailedError, Repository } from 'typeorm'; | ||||
| import { promisify } from 'util'; | ||||
| import { AuthUserDto } from '../../decorators/auth-user.decorator'; | ||||
| import { DownloadService } from '../../modules/download/download.service'; | ||||
| import { IAssetRepository } from './asset-repository'; | ||||
| import { AssetCore } from './asset.core'; | ||||
| @@ -34,7 +40,7 @@ import { AssetBulkUploadCheckDto } from './dto/asset-check.dto'; | ||||
| import { AssetSearchDto } from './dto/asset-search.dto'; | ||||
| import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto'; | ||||
| import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto'; | ||||
| import { CreateAssetDto, UploadFile } from './dto/create-asset.dto'; | ||||
| import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto'; | ||||
| import { DeleteAssetDto } from './dto/delete-asset.dto'; | ||||
| import { DownloadFilesDto } from './dto/download-files.dto'; | ||||
| import { DownloadDto } from './dto/download-library.dto'; | ||||
| @@ -78,6 +84,7 @@ export class AssetService { | ||||
|     @Inject(IAccessRepository) private accessRepository: IAccessRepository, | ||||
|     @Inject(IAssetRepository) private _assetRepository: IAssetRepository, | ||||
|     @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>, | ||||
|     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, | ||||
|     private downloadService: DownloadService, | ||||
|     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||
|     @Inject(IStorageRepository) private storageRepository: IStorageRepository, | ||||
| @@ -107,7 +114,7 @@ export class AssetService { | ||||
|         livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile); | ||||
|       } | ||||
|  | ||||
|       const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile); | ||||
|       const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath); | ||||
|  | ||||
|       return { id: asset.id, duplicate: false }; | ||||
|     } catch (error: any) { | ||||
| @@ -129,6 +136,73 @@ export class AssetService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async importFile(authUser: AuthUserDto, dto: ImportAssetDto): Promise<AssetFileUploadResponseDto> { | ||||
|     dto = { | ||||
|       ...dto, | ||||
|       assetPath: path.resolve(dto.assetPath), | ||||
|       sidecarPath: dto.sidecarPath ? path.resolve(dto.sidecarPath) : undefined, | ||||
|     }; | ||||
|  | ||||
|     const assetPathType = mime.lookup(dto.assetPath) as string; | ||||
|     if (!isSupportedFileType(assetPathType)) { | ||||
|       throw new BadRequestException(`Unsupported file type ${assetPathType}`); | ||||
|     } | ||||
|  | ||||
|     if (dto.sidecarPath) { | ||||
|       const sidecarType = mime.lookup(dto.sidecarPath) as string; | ||||
|       if (!isSidecarFileType(sidecarType)) { | ||||
|         throw new BadRequestException(`Unsupported sidecar file type ${assetPathType}`); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     for (const filepath of [dto.assetPath, dto.sidecarPath]) { | ||||
|       if (!filepath) { | ||||
|         continue; | ||||
|       } | ||||
|  | ||||
|       const exists = await this.storageRepository.checkFileExists(filepath, R_OK); | ||||
|       if (!exists) { | ||||
|         throw new BadRequestException('File does not exist'); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     if (!authUser.externalPath || !dto.assetPath.match(new RegExp(`^${authUser.externalPath}`))) { | ||||
|       throw new BadRequestException("File does not exist within user's external path"); | ||||
|     } | ||||
|  | ||||
|     const assetFile: UploadFile = { | ||||
|       checksum: await this.cryptoRepository.hashFile(dto.assetPath), | ||||
|       mimeType: assetPathType, | ||||
|       originalPath: dto.assetPath, | ||||
|       originalName: path.parse(dto.assetPath).name, | ||||
|     }; | ||||
|  | ||||
|     try { | ||||
|       const asset = await this.assetCore.create(authUser, dto, assetFile, undefined, dto.sidecarPath); | ||||
|       return { id: asset.id, duplicate: false }; | ||||
|     } catch (error: QueryFailedError | Error | any) { | ||||
|       // handle duplicates with a success response | ||||
|       if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') { | ||||
|         const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, [assetFile.checksum]); | ||||
|         return { id: duplicate.id, duplicate: true }; | ||||
|       } | ||||
|  | ||||
|       if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_4ed4f8052685ff5b1e7ca1058ba') { | ||||
|         const duplicate = await this._assetRepository.getByOriginalPath(dto.assetPath); | ||||
|         if (duplicate) { | ||||
|           if (duplicate.ownerId === authUser.id) { | ||||
|             return { id: duplicate.id, duplicate: true }; | ||||
|           } | ||||
|  | ||||
|           throw new BadRequestException('Path in use by another user'); | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       this.logger.error(`Error importing file ${error}`, error?.stack); | ||||
|       throw new BadRequestException(`Error importing file`, `${error}`); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) { | ||||
|     return this._assetRepository.getAllByDeviceId(authUser.id, deviceId); | ||||
|   } | ||||
| @@ -291,7 +365,7 @@ export class AssetService { | ||||
|         let videoPath = asset.originalPath; | ||||
|         let mimeType = asset.mimeType; | ||||
|  | ||||
|         await fs.access(videoPath, constants.R_OK | constants.W_OK); | ||||
|         await fs.access(videoPath, R_OK | W_OK); | ||||
|  | ||||
|         if (asset.encodedVideoPath) { | ||||
|           videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath); | ||||
| @@ -373,13 +447,16 @@ export class AssetService { | ||||
|         await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } }); | ||||
|  | ||||
|         result.push({ id, status: DeleteAssetStatusEnum.SUCCESS }); | ||||
|         deleteQueue.push( | ||||
|           asset.originalPath, | ||||
|           asset.webpPath, | ||||
|           asset.resizePath, | ||||
|           asset.encodedVideoPath, | ||||
|           asset.sidecarPath, | ||||
|         ); | ||||
|  | ||||
|         if (!asset.isReadOnly) { | ||||
|           deleteQueue.push( | ||||
|             asset.originalPath, | ||||
|             asset.webpPath, | ||||
|             asset.resizePath, | ||||
|             asset.encodedVideoPath, | ||||
|             asset.sidecarPath, | ||||
|           ); | ||||
|         } | ||||
|  | ||||
|         // TODO refactor this to use cascades | ||||
|         if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) { | ||||
| @@ -665,7 +742,7 @@ export class AssetService { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     await fs.access(filepath, constants.R_OK); | ||||
|     await fs.access(filepath, R_OK); | ||||
|  | ||||
|     return new StreamableFile(createReadStream(filepath)); | ||||
|   } | ||||
|   | ||||
| @@ -1,9 +1,11 @@ | ||||
| import { AssetType } from '@app/infra/entities'; | ||||
| import { ApiProperty } from '@nestjs/swagger'; | ||||
| import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator'; | ||||
| import { Transform } from 'class-transformer'; | ||||
| import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; | ||||
| import { ImmichFile } from '../../../config/asset-upload.config'; | ||||
| import { toSanitized } from '../../../utils/transform.util'; | ||||
|  | ||||
| export class CreateAssetDto { | ||||
| export class CreateAssetBase { | ||||
|   @IsNotEmpty() | ||||
|   deviceAssetId!: string; | ||||
|  | ||||
| @@ -32,11 +34,17 @@ export class CreateAssetDto { | ||||
|   @IsBoolean() | ||||
|   isVisible?: boolean; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   fileExtension!: string; | ||||
|  | ||||
|   @IsOptional() | ||||
|   duration?: string; | ||||
| } | ||||
|  | ||||
| export class CreateAssetDto extends CreateAssetBase { | ||||
|   @IsOptional() | ||||
|   @IsBoolean() | ||||
|   isReadOnly?: boolean = false; | ||||
|  | ||||
|   @IsNotEmpty() | ||||
|   fileExtension!: string; | ||||
|  | ||||
|   // The properties below are added to correctly generate the API docs | ||||
|   // and client SDKs. Validation should be handled in the controller. | ||||
| @@ -50,6 +58,23 @@ export class CreateAssetDto { | ||||
|   sidecarData?: any; | ||||
| } | ||||
|  | ||||
| export class ImportAssetDto extends CreateAssetBase { | ||||
|   @IsOptional() | ||||
|   @IsBoolean() | ||||
|   isReadOnly?: boolean = true; | ||||
|  | ||||
|   @IsString() | ||||
|   @IsNotEmpty() | ||||
|   @Transform(toSanitized) | ||||
|   assetPath!: string; | ||||
|  | ||||
|   @IsString() | ||||
|   @IsOptional() | ||||
|   @IsNotEmpty() | ||||
|   @Transform(toSanitized) | ||||
|   sidecarPath?: string; | ||||
| } | ||||
|  | ||||
| export interface UploadFile { | ||||
|   mimeType: string; | ||||
|   checksum: Buffer; | ||||
|   | ||||
| @@ -1,3 +1,4 @@ | ||||
| import { isSidecarFileType, isSupportedFileType } from '@app/domain'; | ||||
| import { StorageCore, StorageFolder } from '@app/domain/storage'; | ||||
| import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common'; | ||||
| import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; | ||||
| @@ -49,67 +50,18 @@ export const multerUtils = { fileFilter, filename, destination }; | ||||
|  | ||||
| const logger = new Logger('AssetUploadConfig'); | ||||
|  | ||||
| const validMimeTypes = [ | ||||
|   'image/avif', | ||||
|   'image/gif', | ||||
|   'image/heic', | ||||
|   'image/heif', | ||||
|   'image/jpeg', | ||||
|   'image/jxl', | ||||
|   'image/png', | ||||
|   'image/tiff', | ||||
|   'image/webp', | ||||
|   'image/x-adobe-dng', | ||||
|   'image/x-arriflex-ari', | ||||
|   'image/x-canon-cr2', | ||||
|   'image/x-canon-cr3', | ||||
|   'image/x-canon-crw', | ||||
|   'image/x-epson-erf', | ||||
|   'image/x-fuji-raf', | ||||
|   'image/x-hasselblad-3fr', | ||||
|   'image/x-hasselblad-fff', | ||||
|   'image/x-kodak-dcr', | ||||
|   'image/x-kodak-k25', | ||||
|   'image/x-kodak-kdc', | ||||
|   'image/x-leica-rwl', | ||||
|   'image/x-minolta-mrw', | ||||
|   'image/x-nikon-nef', | ||||
|   'image/x-olympus-orf', | ||||
|   'image/x-olympus-ori', | ||||
|   'image/x-panasonic-raw', | ||||
|   'image/x-pentax-pef', | ||||
|   'image/x-phantom-cin', | ||||
|   'image/x-phaseone-cap', | ||||
|   'image/x-phaseone-iiq', | ||||
|   'image/x-samsung-srw', | ||||
|   'image/x-sigma-x3f', | ||||
|   'image/x-sony-arw', | ||||
|   'image/x-sony-sr2', | ||||
|   'image/x-sony-srf', | ||||
|   'video/3gpp', | ||||
|   'video/mp2t', | ||||
|   'video/mp4', | ||||
|   'video/mpeg', | ||||
|   'video/quicktime', | ||||
|   'video/webm', | ||||
|   'video/x-flv', | ||||
|   'video/x-matroska', | ||||
|   'video/x-ms-wmv', | ||||
|   'video/x-msvideo', | ||||
| ]; | ||||
|  | ||||
| function fileFilter(req: AuthRequest, file: any, cb: any) { | ||||
|   if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) { | ||||
|     return cb(new UnauthorizedException()); | ||||
|   } | ||||
|  | ||||
|   if (validMimeTypes.includes(file.mimetype)) { | ||||
|   if (isSupportedFileType(file.mimetype)) { | ||||
|     cb(null, true); | ||||
|     return; | ||||
|   } | ||||
|  | ||||
|   // Additionally support XML but only for sidecar files. | ||||
|   if (file.fieldname === 'sidecarData' && ['application/xml', 'text/xml'].includes(file.mimetype)) { | ||||
|   if (file.fieldname === 'sidecarData' && isSidecarFileType(file.mimetype)) { | ||||
|     return cb(null, true); | ||||
|   } | ||||
|  | ||||
|   | ||||
| @@ -42,7 +42,7 @@ export class AssetEntity { | ||||
|   @Column() | ||||
|   type!: AssetType; | ||||
|  | ||||
|   @Column() | ||||
|   @Column({ unique: true }) | ||||
|   originalPath!: string; | ||||
|  | ||||
|   @Column({ type: 'varchar', nullable: true }) | ||||
| @@ -75,6 +75,9 @@ export class AssetEntity { | ||||
|   @Column({ type: 'boolean', default: false }) | ||||
|   isArchived!: boolean; | ||||
|  | ||||
|   @Column({ type: 'boolean', default: false }) | ||||
|   isReadOnly!: boolean; | ||||
|  | ||||
|   @Column({ type: 'varchar', nullable: true }) | ||||
|   mimeType!: string | null; | ||||
|  | ||||
|   | ||||
| @@ -30,6 +30,9 @@ export class UserEntity { | ||||
|   @Column({ type: 'varchar', unique: true, default: null }) | ||||
|   storageLabel!: string | null; | ||||
|  | ||||
|   @Column({ type: 'varchar', default: null }) | ||||
|   externalPath!: string | null; | ||||
|  | ||||
|   @Column({ default: '', select: false }) | ||||
|   password?: string; | ||||
|  | ||||
|   | ||||
							
								
								
									
										18
									
								
								server/src/infra/migrations/1686584273471-ImportAsset.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								server/src/infra/migrations/1686584273471-ImportAsset.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| import { MigrationInterface, QueryRunner } from "typeorm"; | ||||
|  | ||||
| export class ImportAsset1686584273471 implements MigrationInterface { | ||||
|     name = 'ImportAsset1686584273471' | ||||
|  | ||||
|     public async up(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`ALTER TABLE "assets" ADD "isReadOnly" boolean NOT NULL DEFAULT false`); | ||||
|         await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba" UNIQUE ("originalPath")`); | ||||
|         await queryRunner.query(`ALTER TABLE "users" ADD "externalPath" character varying`); | ||||
|     } | ||||
|  | ||||
|     public async down(queryRunner: QueryRunner): Promise<void> { | ||||
|         await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba"`); | ||||
|         await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isReadOnly"`); | ||||
|         await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "externalPath"`); | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -2,6 +2,7 @@ import { ICryptoRepository } from '@app/domain'; | ||||
| import { Injectable } from '@nestjs/common'; | ||||
| import { compareSync, hash } from 'bcrypt'; | ||||
| import { createHash, randomBytes } from 'crypto'; | ||||
| import { createReadStream } from 'fs'; | ||||
|  | ||||
| @Injectable() | ||||
| export class CryptoRepository implements ICryptoRepository { | ||||
| @@ -13,4 +14,14 @@ export class CryptoRepository implements ICryptoRepository { | ||||
|   hashSha256(value: string) { | ||||
|     return createHash('sha256').update(value).digest('base64'); | ||||
|   } | ||||
|  | ||||
|   hashFile(filepath: string): Promise<Buffer> { | ||||
|     return new Promise<Buffer>((resolve, reject) => { | ||||
|       const hash = createHash('sha1'); | ||||
|       const stream = createReadStream(filepath); | ||||
|       stream.on('error', (err) => reject(err)); | ||||
|       stream.on('data', (chunk) => hash.update(chunk)); | ||||
|       stream.on('end', () => resolve(hash.digest())); | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -50,6 +50,7 @@ export const authStub = { | ||||
|     isAdmin: true, | ||||
|     isPublicUser: false, | ||||
|     isAllowUpload: true, | ||||
|     externalPath: null, | ||||
|   }), | ||||
|   user1: Object.freeze<AuthUserDto>({ | ||||
|     id: 'user-id', | ||||
| @@ -60,6 +61,7 @@ export const authStub = { | ||||
|     isAllowDownload: true, | ||||
|     isShowExif: true, | ||||
|     accessTokenId: 'token-id', | ||||
|     externalPath: null, | ||||
|   }), | ||||
|   user2: Object.freeze<AuthUserDto>({ | ||||
|     id: 'user-2', | ||||
| @@ -70,6 +72,18 @@ export const authStub = { | ||||
|     isAllowDownload: true, | ||||
|     isShowExif: true, | ||||
|     accessTokenId: 'token-id', | ||||
|     externalPath: null, | ||||
|   }), | ||||
|   external1: Object.freeze<AuthUserDto>({ | ||||
|     id: 'user-id', | ||||
|     email: 'immich@test.com', | ||||
|     isAdmin: false, | ||||
|     isPublicUser: false, | ||||
|     isAllowUpload: true, | ||||
|     isAllowDownload: true, | ||||
|     isShowExif: true, | ||||
|     accessTokenId: 'token-id', | ||||
|     externalPath: '/data/user1', | ||||
|   }), | ||||
|   adminSharedLink: Object.freeze<AuthUserDto>({ | ||||
|     id: 'admin_id', | ||||
| @@ -111,6 +125,7 @@ export const userEntityStub = { | ||||
|     firstName: 'admin_first_name', | ||||
|     lastName: 'admin_last_name', | ||||
|     storageLabel: 'admin', | ||||
|     externalPath: null, | ||||
|     oauthId: '', | ||||
|     shouldChangePassword: false, | ||||
|     profileImagePath: '', | ||||
| @@ -126,6 +141,7 @@ export const userEntityStub = { | ||||
|     firstName: 'immich_first_name', | ||||
|     lastName: 'immich_last_name', | ||||
|     storageLabel: null, | ||||
|     externalPath: null, | ||||
|     oauthId: '', | ||||
|     shouldChangePassword: false, | ||||
|     profileImagePath: '', | ||||
| @@ -141,6 +157,7 @@ export const userEntityStub = { | ||||
|     firstName: 'immich_first_name', | ||||
|     lastName: 'immich_last_name', | ||||
|     storageLabel: null, | ||||
|     externalPath: null, | ||||
|     oauthId: '', | ||||
|     shouldChangePassword: false, | ||||
|     profileImagePath: '', | ||||
| @@ -156,6 +173,7 @@ export const userEntityStub = { | ||||
|     firstName: 'immich_first_name', | ||||
|     lastName: 'immich_last_name', | ||||
|     storageLabel: 'label-1', | ||||
|     externalPath: null, | ||||
|     oauthId: '', | ||||
|     shouldChangePassword: false, | ||||
|     profileImagePath: '', | ||||
| @@ -212,6 +230,7 @@ export const assetEntityStub = { | ||||
|     sharedLinks: [], | ||||
|     faces: [], | ||||
|     sidecarPath: null, | ||||
|     isReadOnly: false, | ||||
|   }), | ||||
|   noWebpPath: Object.freeze<AssetEntity>({ | ||||
|     id: 'asset-id', | ||||
| @@ -242,6 +261,7 @@ export const assetEntityStub = { | ||||
|     originalFileName: 'asset-id.ext', | ||||
|     faces: [], | ||||
|     sidecarPath: null, | ||||
|     isReadOnly: false, | ||||
|   }), | ||||
|   noThumbhash: Object.freeze<AssetEntity>({ | ||||
|     id: 'asset-id', | ||||
| @@ -263,6 +283,7 @@ export const assetEntityStub = { | ||||
|     mimeType: null, | ||||
|     isFavorite: true, | ||||
|     isArchived: false, | ||||
|     isReadOnly: false, | ||||
|     duration: null, | ||||
|     isVisible: true, | ||||
|     livePhotoVideo: null, | ||||
| @@ -293,6 +314,7 @@ export const assetEntityStub = { | ||||
|     mimeType: null, | ||||
|     isFavorite: true, | ||||
|     isArchived: false, | ||||
|     isReadOnly: false, | ||||
|     duration: null, | ||||
|     isVisible: true, | ||||
|     livePhotoVideo: null, | ||||
| @@ -324,6 +346,7 @@ export const assetEntityStub = { | ||||
|     mimeType: null, | ||||
|     isFavorite: true, | ||||
|     isArchived: false, | ||||
|     isReadOnly: false, | ||||
|     duration: null, | ||||
|     isVisible: true, | ||||
|     livePhotoVideo: null, | ||||
| @@ -375,6 +398,7 @@ export const assetEntityStub = { | ||||
|     mimeType: null, | ||||
|     isFavorite: false, | ||||
|     isArchived: false, | ||||
|     isReadOnly: false, | ||||
|     duration: null, | ||||
|     isVisible: true, | ||||
|     livePhotoVideo: null, | ||||
| @@ -408,6 +432,7 @@ export const assetEntityStub = { | ||||
|     mimeType: null, | ||||
|     isFavorite: true, | ||||
|     isArchived: false, | ||||
|     isReadOnly: false, | ||||
|     duration: null, | ||||
|     isVisible: true, | ||||
|     livePhotoVideo: null, | ||||
| @@ -865,6 +890,7 @@ export const sharedLinkStub = { | ||||
|           updatedAt: today, | ||||
|           isFavorite: false, | ||||
|           isArchived: false, | ||||
|           isReadOnly: false, | ||||
|           mimeType: 'image/jpeg', | ||||
|           smartInfo: { | ||||
|             assetId: 'id_1', | ||||
|   | ||||
| @@ -6,5 +6,6 @@ export const newCryptoRepositoryMock = (): jest.Mocked<ICryptoRepository> => { | ||||
|     compareBcrypt: jest.fn().mockReturnValue(true), | ||||
|     hashBcrypt: jest.fn().mockImplementation((input) => Promise.resolve(`${input} (hashed)`)), | ||||
|     hashSha256: jest.fn().mockImplementation((input) => `${input} (hashed)`), | ||||
|     hashFile: jest.fn().mockImplementation((input) => `${input} (file-hashed)`), | ||||
|   }; | ||||
| }; | ||||
|   | ||||
							
								
								
									
										245
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										245
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -979,6 +979,12 @@ export interface CreateUserDto { | ||||
|      * @memberof CreateUserDto | ||||
|      */ | ||||
|     'storageLabel'?: string | null; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof CreateUserDto | ||||
|      */ | ||||
|     'externalPath'?: string | null; | ||||
| } | ||||
| /** | ||||
|  *  | ||||
| @@ -1294,6 +1300,87 @@ export interface GetAssetCountByTimeBucketDto { | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
|  * @interface ImportAssetDto | ||||
|  */ | ||||
| export interface ImportAssetDto { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {AssetTypeEnum} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'assetType': AssetTypeEnum; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'isReadOnly'?: boolean; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'assetPath': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'sidecarPath'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'deviceAssetId': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'deviceId': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'fileCreatedAt': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'fileModifiedAt': string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'isFavorite': boolean; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'isArchived'?: boolean; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'isVisible'?: boolean; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof ImportAssetDto | ||||
|      */ | ||||
|     'duration'?: string; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| /** | ||||
|  *  | ||||
|  * @export | ||||
| @@ -2736,6 +2823,12 @@ export interface UpdateUserDto { | ||||
|      * @memberof UpdateUserDto | ||||
|      */ | ||||
|     'storageLabel'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof UpdateUserDto | ||||
|      */ | ||||
|     'externalPath'?: string; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
| @@ -2841,6 +2934,12 @@ export interface UserResponseDto { | ||||
|      * @memberof UserResponseDto | ||||
|      */ | ||||
|     'storageLabel': string | null; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof UserResponseDto | ||||
|      */ | ||||
|     'externalPath': string | null; | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -5412,6 +5511,50 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|                 options: localVarRequestOptions, | ||||
|             }; | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {ImportAssetDto} importAssetDto  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         importFile: async (importAssetDto: ImportAssetDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'importAssetDto' is not null or undefined
 | ||||
|             assertParamExists('importFile', 'importAssetDto', importAssetDto) | ||||
|             const localVarPath = `/asset/import`; | ||||
|             // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | ||||
|             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||
|             let baseOptions; | ||||
|             if (configuration) { | ||||
|                 baseOptions = configuration.baseOptions; | ||||
|             } | ||||
| 
 | ||||
|             const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; | ||||
|             const localVarHeaderParameter = {} as any; | ||||
|             const localVarQueryParameter = {} as any; | ||||
| 
 | ||||
|             // authentication cookie required
 | ||||
| 
 | ||||
|             // authentication api_key required
 | ||||
|             await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration) | ||||
| 
 | ||||
|             // authentication bearer required
 | ||||
|             // http bearer authentication required
 | ||||
|             await setBearerAuthToObject(localVarHeaderParameter, configuration) | ||||
| 
 | ||||
| 
 | ||||
|      | ||||
|             localVarHeaderParameter['Content-Type'] = 'application/json'; | ||||
| 
 | ||||
|             setSearchParams(localVarUrlObj, localVarQueryParameter); | ||||
|             let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; | ||||
|             localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; | ||||
|             localVarRequestOptions.data = serializeDataIfNeeded(importAssetDto, localVarRequestOptions, configuration) | ||||
| 
 | ||||
|             return { | ||||
|                 url: toPathString(localVarUrlObj), | ||||
|                 options: localVarRequestOptions, | ||||
|             }; | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {SearchAssetDto} searchAssetDto  | ||||
| @@ -5565,26 +5708,29 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|          *  | ||||
|          * @param {AssetTypeEnum} assetType  | ||||
|          * @param {File} assetData  | ||||
|          * @param {string} fileExtension  | ||||
|          * @param {string} deviceAssetId  | ||||
|          * @param {string} deviceId  | ||||
|          * @param {string} fileCreatedAt  | ||||
|          * @param {string} fileModifiedAt  | ||||
|          * @param {boolean} isFavorite  | ||||
|          * @param {string} fileExtension  | ||||
|          * @param {string} [key]  | ||||
|          * @param {File} [livePhotoData]  | ||||
|          * @param {File} [sidecarData]  | ||||
|          * @param {boolean} [isReadOnly]  | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isVisible]  | ||||
|          * @param {string} [duration]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         uploadFile: async (assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|         uploadFile: async (assetType: AssetTypeEnum, assetData: File, fileExtension: string, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, livePhotoData?: File, sidecarData?: File, isReadOnly?: boolean, isArchived?: boolean, isVisible?: boolean, duration?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { | ||||
|             // verify required parameter 'assetType' is not null or undefined
 | ||||
|             assertParamExists('uploadFile', 'assetType', assetType) | ||||
|             // verify required parameter 'assetData' is not null or undefined
 | ||||
|             assertParamExists('uploadFile', 'assetData', assetData) | ||||
|             // verify required parameter 'fileExtension' is not null or undefined
 | ||||
|             assertParamExists('uploadFile', 'fileExtension', fileExtension) | ||||
|             // verify required parameter 'deviceAssetId' is not null or undefined
 | ||||
|             assertParamExists('uploadFile', 'deviceAssetId', deviceAssetId) | ||||
|             // verify required parameter 'deviceId' is not null or undefined
 | ||||
| @@ -5595,8 +5741,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|             assertParamExists('uploadFile', 'fileModifiedAt', fileModifiedAt) | ||||
|             // verify required parameter 'isFavorite' is not null or undefined
 | ||||
|             assertParamExists('uploadFile', 'isFavorite', isFavorite) | ||||
|             // verify required parameter 'fileExtension' is not null or undefined
 | ||||
|             assertParamExists('uploadFile', 'fileExtension', fileExtension) | ||||
|             const localVarPath = `/asset/upload`; | ||||
|             // use dummy base URL string because the URL constructor only accepts absolute URLs.
 | ||||
|             const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); | ||||
| @@ -5640,6 +5784,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|                 localVarFormParams.append('sidecarData', sidecarData as any); | ||||
|             } | ||||
|      | ||||
|             if (isReadOnly !== undefined) {  | ||||
|                 localVarFormParams.append('isReadOnly', isReadOnly as any); | ||||
|             } | ||||
|      | ||||
|             if (fileExtension !== undefined) {  | ||||
|                 localVarFormParams.append('fileExtension', fileExtension as any); | ||||
|             } | ||||
|      | ||||
|             if (deviceAssetId !== undefined) {  | ||||
|                 localVarFormParams.append('deviceAssetId', deviceAssetId as any); | ||||
|             } | ||||
| @@ -5668,10 +5820,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration | ||||
|                 localVarFormParams.append('isVisible', isVisible as any); | ||||
|             } | ||||
|      | ||||
|             if (fileExtension !== undefined) {  | ||||
|                 localVarFormParams.append('fileExtension', fileExtension as any); | ||||
|             } | ||||
|      | ||||
|             if (duration !== undefined) {  | ||||
|                 localVarFormParams.append('duration', duration as any); | ||||
|             } | ||||
| @@ -5909,6 +6057,16 @@ export const AssetApiFp = function(configuration?: Configuration) { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.getUserAssetsByDeviceId(deviceId, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {ImportAssetDto} importAssetDto  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async importFile(importAssetDto: ImportAssetDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFileUploadResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.importFile(importAssetDto, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {SearchAssetDto} searchAssetDto  | ||||
| @@ -5947,23 +6105,24 @@ export const AssetApiFp = function(configuration?: Configuration) { | ||||
|          *  | ||||
|          * @param {AssetTypeEnum} assetType  | ||||
|          * @param {File} assetData  | ||||
|          * @param {string} fileExtension  | ||||
|          * @param {string} deviceAssetId  | ||||
|          * @param {string} deviceId  | ||||
|          * @param {string} fileCreatedAt  | ||||
|          * @param {string} fileModifiedAt  | ||||
|          * @param {boolean} isFavorite  | ||||
|          * @param {string} fileExtension  | ||||
|          * @param {string} [key]  | ||||
|          * @param {File} [livePhotoData]  | ||||
|          * @param {File} [sidecarData]  | ||||
|          * @param {boolean} [isReadOnly]  | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isVisible]  | ||||
|          * @param {string} [duration]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         async uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFileUploadResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration, options); | ||||
|         async uploadFile(assetType: AssetTypeEnum, assetData: File, fileExtension: string, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, livePhotoData?: File, sidecarData?: File, isReadOnly?: boolean, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetFileUploadResponseDto>> { | ||||
|             const localVarAxiosArgs = await localVarAxiosParamCreator.uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration, options); | ||||
|             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); | ||||
|         }, | ||||
|     } | ||||
| @@ -6166,6 +6325,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath | ||||
|         getUserAssetsByDeviceId(deviceId: string, options?: any): AxiosPromise<Array<string>> { | ||||
|             return localVarFp.getUserAssetsByDeviceId(deviceId, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {ImportAssetDto} importAssetDto  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         importFile(importAssetDto: ImportAssetDto, options?: any): AxiosPromise<AssetFileUploadResponseDto> { | ||||
|             return localVarFp.importFile(importAssetDto, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|         /** | ||||
|          *  | ||||
|          * @param {SearchAssetDto} searchAssetDto  | ||||
| @@ -6201,23 +6369,24 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath | ||||
|          *  | ||||
|          * @param {AssetTypeEnum} assetType  | ||||
|          * @param {File} assetData  | ||||
|          * @param {string} fileExtension  | ||||
|          * @param {string} deviceAssetId  | ||||
|          * @param {string} deviceId  | ||||
|          * @param {string} fileCreatedAt  | ||||
|          * @param {string} fileModifiedAt  | ||||
|          * @param {boolean} isFavorite  | ||||
|          * @param {string} fileExtension  | ||||
|          * @param {string} [key]  | ||||
|          * @param {File} [livePhotoData]  | ||||
|          * @param {File} [sidecarData]  | ||||
|          * @param {boolean} [isReadOnly]  | ||||
|          * @param {boolean} [isArchived]  | ||||
|          * @param {boolean} [isVisible]  | ||||
|          * @param {string} [duration]  | ||||
|          * @param {*} [options] Override http request option. | ||||
|          * @throws {RequiredError} | ||||
|          */ | ||||
|         uploadFile(assetType: AssetTypeEnum, assetData: File, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, fileExtension: string, key?: string, livePhotoData?: File, sidecarData?: File, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: any): AxiosPromise<AssetFileUploadResponseDto> { | ||||
|             return localVarFp.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration, options).then((request) => request(axios, basePath)); | ||||
|         uploadFile(assetType: AssetTypeEnum, assetData: File, fileExtension: string, deviceAssetId: string, deviceId: string, fileCreatedAt: string, fileModifiedAt: string, isFavorite: boolean, key?: string, livePhotoData?: File, sidecarData?: File, isReadOnly?: boolean, isArchived?: boolean, isVisible?: boolean, duration?: string, options?: any): AxiosPromise<AssetFileUploadResponseDto> { | ||||
|             return localVarFp.uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration, options).then((request) => request(axios, basePath)); | ||||
|         }, | ||||
|     }; | ||||
| }; | ||||
| @@ -6537,6 +6706,20 @@ export interface AssetApiGetUserAssetsByDeviceIdRequest { | ||||
|     readonly deviceId: string | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Request parameters for importFile operation in AssetApi. | ||||
|  * @export | ||||
|  * @interface AssetApiImportFileRequest | ||||
|  */ | ||||
| export interface AssetApiImportFileRequest { | ||||
|     /** | ||||
|      *  | ||||
|      * @type {ImportAssetDto} | ||||
|      * @memberof AssetApiImportFile | ||||
|      */ | ||||
|     readonly importAssetDto: ImportAssetDto | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Request parameters for searchAsset operation in AssetApi. | ||||
|  * @export | ||||
| @@ -6627,6 +6810,13 @@ export interface AssetApiUploadFileRequest { | ||||
|      */ | ||||
|     readonly assetData: File | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AssetApiUploadFile | ||||
|      */ | ||||
|     readonly fileExtension: string | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -6662,13 +6852,6 @@ export interface AssetApiUploadFileRequest { | ||||
|      */ | ||||
|     readonly isFavorite: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
|      * @memberof AssetApiUploadFile | ||||
|      */ | ||||
|     readonly fileExtension: string | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {string} | ||||
| @@ -6690,6 +6873,13 @@ export interface AssetApiUploadFileRequest { | ||||
|      */ | ||||
|     readonly sidecarData?: File | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
|      * @memberof AssetApiUploadFile | ||||
|      */ | ||||
|     readonly isReadOnly?: boolean | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @type {boolean} | ||||
| @@ -6934,6 +7124,17 @@ export class AssetApi extends BaseAPI { | ||||
|         return AssetApiFp(this.configuration).getUserAssetsByDeviceId(requestParameters.deviceId, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @param {AssetApiImportFileRequest} requestParameters Request parameters. | ||||
|      * @param {*} [options] Override http request option. | ||||
|      * @throws {RequiredError} | ||||
|      * @memberof AssetApi | ||||
|      */ | ||||
|     public importFile(requestParameters: AssetApiImportFileRequest, options?: AxiosRequestConfig) { | ||||
|         return AssetApiFp(this.configuration).importFile(requestParameters.importAssetDto, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      *  | ||||
|      * @param {AssetApiSearchAssetRequest} requestParameters Request parameters. | ||||
| @@ -6975,7 +7176,7 @@ export class AssetApi extends BaseAPI { | ||||
|      * @memberof AssetApi | ||||
|      */ | ||||
|     public uploadFile(requestParameters: AssetApiUploadFileRequest, options?: AxiosRequestConfig) { | ||||
|         return AssetApiFp(this.configuration).uploadFile(requestParameters.assetType, requestParameters.assetData, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.fileExtension, requestParameters.key, requestParameters.livePhotoData, requestParameters.sidecarData, requestParameters.isArchived, requestParameters.isVisible, requestParameters.duration, options).then((request) => request(this.axios, this.basePath)); | ||||
|         return AssetApiFp(this.configuration).uploadFile(requestParameters.assetType, requestParameters.assetData, requestParameters.fileExtension, requestParameters.deviceAssetId, requestParameters.deviceId, requestParameters.fileCreatedAt, requestParameters.fileModifiedAt, requestParameters.isFavorite, requestParameters.key, requestParameters.livePhotoData, requestParameters.sidecarData, requestParameters.isReadOnly, requestParameters.isArchived, requestParameters.isVisible, requestParameters.duration, options).then((request) => request(this.axios, this.basePath)); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
|   | ||||
| @@ -19,14 +19,15 @@ | ||||
|  | ||||
| 	const editUser = async () => { | ||||
| 		try { | ||||
| 			const { id, email, firstName, lastName, storageLabel } = user; | ||||
| 			const { id, email, firstName, lastName, storageLabel, externalPath } = user; | ||||
| 			const { status } = await api.userApi.updateUser({ | ||||
| 				updateUserDto: { | ||||
| 					id, | ||||
| 					email, | ||||
| 					firstName, | ||||
| 					lastName, | ||||
| 					storageLabel: storageLabel || '' | ||||
| 					storageLabel: storageLabel || '', | ||||
| 					externalPath: externalPath || '' | ||||
| 				} | ||||
| 			}); | ||||
|  | ||||
| @@ -131,6 +132,22 @@ | ||||
| 			</p> | ||||
| 		</div> | ||||
|  | ||||
| 		<div class="m-4 flex flex-col gap-2"> | ||||
| 			<label class="immich-form-label" for="external-path">External Path</label> | ||||
| 			<input | ||||
| 				class="immich-form-input" | ||||
| 				id="external-path" | ||||
| 				name="external-path" | ||||
| 				type="text" | ||||
| 				bind:value={user.externalPath} | ||||
| 			/> | ||||
|  | ||||
| 			<p> | ||||
| 				Note: Absolute path of parent import directory. A user can only import files if they exist | ||||
| 				at or under this path. | ||||
| 			</p> | ||||
| 		</div> | ||||
|  | ||||
| 		{#if error} | ||||
| 			<p class="text-red-400 ml-4 text-sm">{error}</p> | ||||
| 		{/if} | ||||
|   | ||||
| @@ -75,6 +75,14 @@ | ||||
| 					required={false} | ||||
| 				/> | ||||
|  | ||||
| 				<SettingInputField | ||||
| 					inputType={SettingInputFieldType.TEXT} | ||||
| 					label="EXTERNAL PATH" | ||||
| 					disabled={true} | ||||
| 					value={user.externalPath || ''} | ||||
| 					required={false} | ||||
| 				/> | ||||
|  | ||||
| 				<div class="flex justify-end"> | ||||
| 					<Button type="submit" size="sm" on:click={() => handleSaveProfile()}>Save</Button> | ||||
| 				</div> | ||||
|   | ||||
| @@ -5,6 +5,8 @@ | ||||
| 	import PencilOutline from 'svelte-material-icons/PencilOutline.svelte'; | ||||
| 	import TrashCanOutline from 'svelte-material-icons/TrashCanOutline.svelte'; | ||||
| 	import DeleteRestore from 'svelte-material-icons/DeleteRestore.svelte'; | ||||
| 	import Check from 'svelte-material-icons/Check.svelte'; | ||||
| 	import Close from 'svelte-material-icons/Close.svelte'; | ||||
| 	import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte'; | ||||
| 	import CreateUserForm from '$lib/components/forms/create-user-form.svelte'; | ||||
| 	import EditUserForm from '$lib/components/forms/edit-user-form.svelte'; | ||||
| @@ -171,6 +173,7 @@ | ||||
| 				<th class="text-center w-1/4 font-medium text-sm">Email</th> | ||||
| 				<th class="text-center w-1/4 font-medium text-sm">First name</th> | ||||
| 				<th class="text-center w-1/4 font-medium text-sm">Last name</th> | ||||
| 				<th class="text-center w-1/4 font-medium text-sm">Can import</th> | ||||
| 				<th class="text-center w-1/4 font-medium text-sm">Action</th> | ||||
| 			</tr> | ||||
| 		</thead> | ||||
| @@ -191,6 +194,15 @@ | ||||
| 						<td class="text-sm px-4 w-1/4 text-ellipsis">{user.email}</td> | ||||
| 						<td class="text-sm px-4 w-1/4 text-ellipsis">{user.firstName}</td> | ||||
| 						<td class="text-sm px-4 w-1/4 text-ellipsis">{user.lastName}</td> | ||||
| 						<td class="text-sm px-4 w-1/4 text-ellipsis"> | ||||
| 							<div class="container flex flex-wrap mx-auto justify-center"> | ||||
| 								{#if user.externalPath} | ||||
| 									<Check size="16" /> | ||||
| 								{:else} | ||||
| 									<Close size="16" /> | ||||
| 								{/if} | ||||
| 							</div> | ||||
| 						</td> | ||||
| 						<td class="text-sm px-4 w-1/4 text-ellipsis"> | ||||
| 							{#if !isDeleted(user)} | ||||
| 								<button | ||||
|   | ||||
| @@ -8,6 +8,7 @@ export const userFactory = Sync.makeFactory<UserResponseDto>({ | ||||
| 	firstName: Sync.each(() => faker.name.firstName()), | ||||
| 	lastName: Sync.each(() => faker.name.lastName()), | ||||
| 	storageLabel: Sync.each(() => faker.random.alphaNumeric()), | ||||
| 	externalPath: Sync.each(() => faker.random.alphaNumeric()), | ||||
| 	profileImagePath: '', | ||||
| 	shouldChangePassword: Sync.each(() => faker.datatype.boolean()), | ||||
| 	isAdmin: true, | ||||
|   | ||||
		Reference in New Issue
	
	Block a user