diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index 6ec8dd5643..e351e3c652 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -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 diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 1ae93da9ae..1e78809c3b 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -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) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index d691591813..ef3610b004 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -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('cookie').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('cookie').apiKeyPrefix = 'Bearer'; +// TODO Configure API key authorization: api_key +//defaultApiClient.getAuthentication('api_key').apiKey = 'YOUR_API_KEY'; +// uncomment below to setup prefix (e.g. Bearer) for API key, if needed +//defaultApiClient.getAuthentication('api_key').apiKeyPrefix = 'Bearer'; +// TODO Configure HTTP Bearer authorization: bearer +// Case 1. Use String Token +//defaultApiClient.getAuthentication('bearer').setAccessToken('YOUR_ACCESS_TOKEN'); +// Case 2. Use Function which generate token. +// String yourTokenGeneratorFunction() { ... } +//defaultApiClient.getAuthentication('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 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] diff --git a/mobile/openapi/doc/CreateUserDto.md b/mobile/openapi/doc/CreateUserDto.md index 647d7f0ff0..09b963b928 100644 --- a/mobile/openapi/doc/CreateUserDto.md +++ b/mobile/openapi/doc/CreateUserDto.md @@ -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) diff --git a/mobile/openapi/doc/ImportAssetDto.md b/mobile/openapi/doc/ImportAssetDto.md new file mode 100644 index 0000000000..da612b0abc --- /dev/null +++ b/mobile/openapi/doc/ImportAssetDto.md @@ -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) + + diff --git a/mobile/openapi/doc/UpdateUserDto.md b/mobile/openapi/doc/UpdateUserDto.md index a7422c53b8..6376c18b10 100644 --- a/mobile/openapi/doc/UpdateUserDto.md +++ b/mobile/openapi/doc/UpdateUserDto.md @@ -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] diff --git a/mobile/openapi/doc/UserResponseDto.md b/mobile/openapi/doc/UserResponseDto.md index c551dd5c77..1c7557b96e 100644 --- a/mobile/openapi/doc/UserResponseDto.md +++ b/mobile/openapi/doc/UserResponseDto.md @@ -13,6 +13,7 @@ Name | Type | Description | Notes **firstName** | **String** | | **lastName** | **String** | | **storageLabel** | **String** | | +**externalPath** | **String** | | **profileImagePath** | **String** | | **shouldChangePassword** | **bool** | | **isAdmin** | **bool** | | diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 613084b09f..9363e99b1d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -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'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 9155c55b34..d8d03ca533 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -1123,6 +1123,53 @@ class AssetApi { return null; } + /// Performs an HTTP 'POST /asset/import' operation and returns the [Response]. + /// Parameters: + /// + /// * [ImportAssetDto] importAssetDto (required): + Future importFileWithHttpInfo(ImportAssetDto importAssetDto,) async { + // ignore: prefer_const_declarations + final path = r'/asset/import'; + + // ignore: prefer_final_locals + Object? postBody = importAssetDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'POST', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [ImportAssetDto] importAssetDto (required): + Future 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 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 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 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 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)); } diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index 65f3bb544f..9deee81b7c 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -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': diff --git a/mobile/openapi/lib/model/create_user_dto.dart b/mobile/openapi/lib/model/create_user_dto.dart index cd5c2a2d61..870847aea5 100644 --- a/mobile/openapi/lib/model/create_user_dto.dart +++ b/mobile/openapi/lib/model/create_user_dto.dart @@ -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 toJson() { final json = {}; @@ -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(json, r'firstName')!, lastName: mapValueOfType(json, r'lastName')!, storageLabel: mapValueOfType(json, r'storageLabel'), + externalPath: mapValueOfType(json, r'externalPath'), ); } return null; diff --git a/mobile/openapi/lib/model/import_asset_dto.dart b/mobile/openapi/lib/model/import_asset_dto.dart new file mode 100644 index 0000000000..bb322ba816 --- /dev/null +++ b/mobile/openapi/lib/model/import_asset_dto.dart @@ -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 toJson() { + final json = {}; + 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(); + + // 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(json, r'isReadOnly') ?? true, + assetPath: mapValueOfType(json, r'assetPath')!, + sidecarPath: mapValueOfType(json, r'sidecarPath'), + deviceAssetId: mapValueOfType(json, r'deviceAssetId')!, + deviceId: mapValueOfType(json, r'deviceId')!, + fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!, + fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!, + isFavorite: mapValueOfType(json, r'isFavorite')!, + isArchived: mapValueOfType(json, r'isArchived'), + isVisible: mapValueOfType(json, r'isVisible'), + duration: mapValueOfType(json, r'duration'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = ImportAssetDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = 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> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = ImportAssetDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetType', + 'assetPath', + 'deviceAssetId', + 'deviceId', + 'fileCreatedAt', + 'fileModifiedAt', + 'isFavorite', + }; +} + diff --git a/mobile/openapi/lib/model/update_user_dto.dart b/mobile/openapi/lib/model/update_user_dto.dart index 570eaaa7c3..1a77bd9092 100644 --- a/mobile/openapi/lib/model/update_user_dto.dart +++ b/mobile/openapi/lib/model/update_user_dto.dart @@ -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 toJson() { final json = {}; @@ -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(json, r'firstName'), lastName: mapValueOfType(json, r'lastName'), storageLabel: mapValueOfType(json, r'storageLabel'), + externalPath: mapValueOfType(json, r'externalPath'), isAdmin: mapValueOfType(json, r'isAdmin'), shouldChangePassword: mapValueOfType(json, r'shouldChangePassword'), ); diff --git a/mobile/openapi/lib/model/user_response_dto.dart b/mobile/openapi/lib/model/user_response_dto.dart index f6af4a2519..8765dc528d 100644 --- a/mobile/openapi/lib/model/user_response_dto.dart +++ b/mobile/openapi/lib/model/user_response_dto.dart @@ -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 toJson() { final json = {}; @@ -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(json, r'firstName')!, lastName: mapValueOfType(json, r'lastName')!, storageLabel: mapValueOfType(json, r'storageLabel'), + externalPath: mapValueOfType(json, r'externalPath'), profileImagePath: mapValueOfType(json, r'profileImagePath')!, shouldChangePassword: mapValueOfType(json, r'shouldChangePassword')!, isAdmin: mapValueOfType(json, r'isAdmin')!, @@ -193,6 +204,7 @@ class UserResponseDto { 'firstName', 'lastName', 'storageLabel', + 'externalPath', 'profileImagePath', 'shouldChangePassword', 'isAdmin', diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index e404fd846b..1a2e510cf9 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -131,6 +131,11 @@ void main() { // TODO }); + //Future importFile(ImportAssetDto importAssetDto) async + test('test importFile', () async { + // TODO + }); + //Future> searchAsset(SearchAssetDto searchAssetDto) async test('test searchAsset', () async { // TODO @@ -148,7 +153,7 @@ void main() { // TODO }); - //Future 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 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 }); diff --git a/mobile/openapi/test/create_user_dto_test.dart b/mobile/openapi/test/create_user_dto_test.dart index b38665fd3a..327acbbe38 100644 --- a/mobile/openapi/test/create_user_dto_test.dart +++ b/mobile/openapi/test/create_user_dto_test.dart @@ -41,6 +41,11 @@ void main() { // TODO }); + // String externalPath + test('to test the property `externalPath`', () async { + // TODO + }); + }); diff --git a/mobile/openapi/test/import_asset_dto_test.dart b/mobile/openapi/test/import_asset_dto_test.dart new file mode 100644 index 0000000000..ca7526cc24 --- /dev/null +++ b/mobile/openapi/test/import_asset_dto_test.dart @@ -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 + }); + + + }); + +} diff --git a/mobile/openapi/test/update_user_dto_test.dart b/mobile/openapi/test/update_user_dto_test.dart index 4ff0bc8b63..5e89eca18f 100644 --- a/mobile/openapi/test/update_user_dto_test.dart +++ b/mobile/openapi/test/update_user_dto_test.dart @@ -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 diff --git a/mobile/openapi/test/user_response_dto_test.dart b/mobile/openapi/test/user_response_dto_test.dart index ae05daf608..f5c70f21ff 100644 --- a/mobile/openapi/test/user_response_dto_test.dart +++ b/mobile/openapi/test/user_response_dto_test.dart @@ -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 diff --git a/server/e2e/user.e2e-spec.ts b/server/e2e/user.e2e-spec.ts index d74626cb3a..2f3ead59a3 100644 --- a/server/e2e/user.e2e-spec.ts +++ b/server/e2e/user.e2e-spec.ts @@ -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, }, ]), ); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index cdf814eec9..bd87580b23 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -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", diff --git a/server/package-lock.json b/server/package-lock.json index 664345d087..2bb83a4221 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -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", diff --git a/server/package.json b/server/package.json index de89b46ef7..235caffdfe 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/domain/album/album.service.spec.ts b/server/src/domain/album/album.service.spec.ts index 67e031db55..0b4f42a43a 100644 --- a/server/src/domain/album/album.service.spec.ts +++ b/server/src/domain/album/album.service.spec.ts @@ -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, diff --git a/server/src/domain/api-key/api-key.core.ts b/server/src/domain/api-key/api-key.core.ts index f70b3fee57..1b075a9c09 100644 --- a/server/src/domain/api-key/api-key.core.ts +++ b/server/src/domain/api-key/api-key.core.ts @@ -19,6 +19,7 @@ export class APIKeyCore { isAdmin: user.isAdmin, isPublicUser: false, isAllowUpload: true, + externalPath: user.externalPath, }; } diff --git a/server/src/domain/auth/dto/auth-user.dto.ts b/server/src/domain/auth/dto/auth-user.dto.ts index 9af777e7b0..0f2c9e41d3 100644 --- a/server/src/domain/auth/dto/auth-user.dto.ts +++ b/server/src/domain/auth/dto/auth-user.dto.ts @@ -8,4 +8,5 @@ export class AuthUserDto { isAllowDownload?: boolean; isShowExif?: boolean; accessTokenId?: string; + externalPath?: string | null; } diff --git a/server/src/domain/crypto/crypto.repository.ts b/server/src/domain/crypto/crypto.repository.ts index d400b017da..67bacfb1e1 100644 --- a/server/src/domain/crypto/crypto.repository.ts +++ b/server/src/domain/crypto/crypto.repository.ts @@ -2,6 +2,7 @@ export const ICryptoRepository = 'ICryptoRepository'; export interface ICryptoRepository { randomBytes(size: number): Buffer; + hashFile(filePath: string): Promise; hashSha256(data: string): string; hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise; compareBcrypt(data: string | Buffer, encrypted: string): boolean; diff --git a/server/src/domain/domain.constant.ts b/server/src/domain/domain.constant.ts index 3aec785d7e..a2bb8238a1 100644 --- a/server/src/domain/domain.constant.ts +++ b/server/src/domain/domain.constant.ts @@ -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); +} diff --git a/server/src/domain/partner/partner.service.spec.ts b/server/src/domain/partner/partner.service.spec.ts index 2422d20ce8..c8e0489695 100644 --- a/server/src/domain/partner/partner.service.spec.ts +++ b/server/src/domain/partner/partner.service.spec.ts @@ -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, }, }; diff --git a/server/src/domain/storage-template/storage-template.service.spec.ts b/server/src/domain/storage-template/storage-template.service.spec.ts index 812ccc7e36..8c6a8ebc5f 100644 --- a/server/src/domain/storage-template/storage-template.service.spec.ts +++ b/server/src/domain/storage-template/storage-template.service.spec.ts @@ -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(); + }); }); }); diff --git a/server/src/domain/storage-template/storage-template.service.ts b/server/src/domain/storage-template/storage-template.service.ts index 3b7df4d98e..a38dbb633e 100644 --- a/server/src/domain/storage-template/storage-template.service.ts +++ b/server/src/domain/storage-template/storage-template.service.ts @@ -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; diff --git a/server/src/domain/user/dto/create-user.dto.ts b/server/src/domain/user/dto/create-user.dto.ts index 3927ffe2e6..5951be8312 100644 --- a/server/src/domain/user/dto/create-user.dto.ts +++ b/server/src/domain/user/dto/create-user.dto.ts @@ -23,6 +23,10 @@ export class CreateUserDto { @IsString() @Transform(toSanitized) storageLabel?: string | null; + + @IsOptional() + @IsString() + externalPath?: string | null; } export class CreateAdminDto { diff --git a/server/src/domain/user/dto/update-user.dto.ts b/server/src/domain/user/dto/update-user.dto.ts index 14c16acf1b..fca200ab28 100644 --- a/server/src/domain/user/dto/update-user.dto.ts +++ b/server/src/domain/user/dto/update-user.dto.ts @@ -29,6 +29,10 @@ export class UpdateUserDto { @Transform(toSanitized) storageLabel?: string; + @IsOptional() + @IsString() + externalPath?: string; + @IsNotEmpty() @IsUUID('4') @ApiProperty({ format: 'uuid' }) diff --git a/server/src/domain/user/response-dto/user-response.dto.ts b/server/src/domain/user/response-dto/user-response.dto.ts index 6ad8e848c4..a2bd508837 100644 --- a/server/src/domain/user/response-dto/user-response.dto.ts +++ b/server/src/domain/user/response-dto/user-response.dto.ts @@ -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, diff --git a/server/src/domain/user/user.core.ts b/server/src/domain/user/user.core.ts index 6a986e4cb0..2b3ed40743 100644 --- a/server/src/domain/user/user.core.ts +++ b/server/src/domain/user/user.core.ts @@ -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 = { ...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) { diff --git a/server/src/domain/user/user.service.spec.ts b/server/src/domain/user/user.service.spec.ts index d4229847dd..efb5e3d5d8 100644 --- a/server/src/domain/user/user.service.spec.ts +++ b/server/src/domain/user/user.service.spec.ts @@ -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, }, ]); }); diff --git a/server/src/immich/api-v1/album/album.service.spec.ts b/server/src/immich/api-v1/album/album.service.spec.ts index 4b5a74b5eb..77ccbb67ae 100644 --- a/server/src/immich/api-v1/album/album.service.spec.ts +++ b/server/src/immich/api-v1/album/album.service.spec.ts @@ -32,6 +32,7 @@ describe('Album service', () => { tags: [], assets: [], storageLabel: null, + externalPath: null, }); const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58'; const sharedAlbumOwnerId = '2222'; diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index ec3e39cf88..4fd2f3c064 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -20,6 +20,10 @@ export interface AssetCheck { checksum: Buffer; } +export interface AssetOwnerCheck extends AssetCheck { + ownerId: string; +} + export interface IAssetRepository { get(id: string): Promise; create( @@ -39,6 +43,7 @@ export interface IAssetRepository { getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise; getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise; getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise; + getByOriginalPath(originalPath: string): Promise; } export const IAssetRepository = 'IAssetRepository'; @@ -350,4 +355,17 @@ export class AssetRepository implements IAssetRepository { return assetCountByUserId; } + + getByOriginalPath(originalPath: string): Promise { + return this.assetRepository.findOne({ + select: { + id: true, + ownerId: true, + checksum: true, + }, + where: { + originalPath, + }, + }); + } } diff --git a/server/src/immich/api-v1/asset/asset.controller.ts b/server/src/immich/api-v1/asset/asset.controller.ts index 1d4228c28b..6b5fc488a0 100644 --- a/server/src/immich/api-v1/asset/asset.controller.ts +++ b/server/src/immich/api-v1/asset/asset.controller.ts @@ -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 { + 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' } } } }) diff --git a/server/src/immich/api-v1/asset/asset.core.ts b/server/src/immich/api-v1/asset/asset.core.ts index 031ab58d43..b68f6234cb 100644 --- a/server/src/immich/api-v1/asset/asset.core.ts +++ b/server/src/immich/api-v1/asset/asset.core.ts @@ -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 { 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' } }); diff --git a/server/src/immich/api-v1/asset/asset.service.spec.ts b/server/src/immich/api-v1/asset/asset.service.spec.ts index ddfc74463a..4f51ad23aa 100644 --- a/server/src/immich/api-v1/asset/asset.service.spec.ts +++ b/server/src/immich/api-v1/asset/asset.service.spec.ts @@ -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; // TO BE DELETED AFTER FINISHED REFACTORING let accessMock: jest.Mocked; let assetRepositoryMock: jest.Mocked; + let cryptoMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; let jobMock: jest.Mocked; let storageMock: jest.Mocked; @@ -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); diff --git a/server/src/immich/api-v1/asset/asset.service.ts b/server/src/immich/api-v1/asset/asset.service.ts index 0234f10efc..671ab74b1e 100644 --- a/server/src/immich/api-v1/asset/asset.service.ts +++ b/server/src/immich/api-v1/asset/asset.service.ts @@ -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, + @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 { + 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)); } diff --git a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts index 1c4880fc80..946c0544e8 100644 --- a/server/src/immich/api-v1/asset/dto/create-asset.dto.ts +++ b/server/src/immich/api-v1/asset/dto/create-asset.dto.ts @@ -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; diff --git a/server/src/immich/config/asset-upload.config.ts b/server/src/immich/config/asset-upload.config.ts index 889fabe593..714aca0d35 100644 --- a/server/src/immich/config/asset-upload.config.ts +++ b/server/src/immich/config/asset-upload.config.ts @@ -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); } diff --git a/server/src/infra/entities/asset.entity.ts b/server/src/infra/entities/asset.entity.ts index 82172bcfac..c070b5cd10 100644 --- a/server/src/infra/entities/asset.entity.ts +++ b/server/src/infra/entities/asset.entity.ts @@ -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; diff --git a/server/src/infra/entities/user.entity.ts b/server/src/infra/entities/user.entity.ts index f175a603c3..7cdac1f824 100644 --- a/server/src/infra/entities/user.entity.ts +++ b/server/src/infra/entities/user.entity.ts @@ -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; diff --git a/server/src/infra/migrations/1686584273471-ImportAsset.ts b/server/src/infra/migrations/1686584273471-ImportAsset.ts new file mode 100644 index 0000000000..d9f5819a8d --- /dev/null +++ b/server/src/infra/migrations/1686584273471-ImportAsset.ts @@ -0,0 +1,18 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class ImportAsset1686584273471 implements MigrationInterface { + name = 'ImportAsset1686584273471' + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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"`); + } + +} diff --git a/server/src/infra/repositories/crypto.repository.ts b/server/src/infra/repositories/crypto.repository.ts index 6ded5b0201..af76d46a71 100644 --- a/server/src/infra/repositories/crypto.repository.ts +++ b/server/src/infra/repositories/crypto.repository.ts @@ -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 { + return new Promise((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())); + }); + } } diff --git a/server/test/fixtures.ts b/server/test/fixtures.ts index 5003c45d53..970f152829 100644 --- a/server/test/fixtures.ts +++ b/server/test/fixtures.ts @@ -50,6 +50,7 @@ export const authStub = { isAdmin: true, isPublicUser: false, isAllowUpload: true, + externalPath: null, }), user1: Object.freeze({ id: 'user-id', @@ -60,6 +61,7 @@ export const authStub = { isAllowDownload: true, isShowExif: true, accessTokenId: 'token-id', + externalPath: null, }), user2: Object.freeze({ id: 'user-2', @@ -70,6 +72,18 @@ export const authStub = { isAllowDownload: true, isShowExif: true, accessTokenId: 'token-id', + externalPath: null, + }), + external1: Object.freeze({ + 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({ 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({ id: 'asset-id', @@ -242,6 +261,7 @@ export const assetEntityStub = { originalFileName: 'asset-id.ext', faces: [], sidecarPath: null, + isReadOnly: false, }), noThumbhash: Object.freeze({ 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', diff --git a/server/test/repositories/crypto.repository.mock.ts b/server/test/repositories/crypto.repository.mock.ts index bbf3154448..b2f159c1e4 100644 --- a/server/test/repositories/crypto.repository.mock.ts +++ b/server/test/repositories/crypto.repository.mock.ts @@ -6,5 +6,6 @@ export const newCryptoRepositoryMock = (): jest.Mocked => { 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)`), }; }; diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 41264aafc4..0c73255795 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -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 => { + // 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 => { + 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 => { // 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> { + 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> { - 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> { + 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> { 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 { + 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 { - 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 { + 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)); } } diff --git a/web/src/lib/components/forms/edit-user-form.svelte b/web/src/lib/components/forms/edit-user-form.svelte index 7ec5f05700..3aedf0f28d 100644 --- a/web/src/lib/components/forms/edit-user-form.svelte +++ b/web/src/lib/components/forms/edit-user-form.svelte @@ -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 @@

+
+ + + +

+ Note: Absolute path of parent import directory. A user can only import files if they exist + at or under this path. +

+
+ {#if error}

{error}

{/if} diff --git a/web/src/lib/components/user-settings-page/user-profile-settings.svelte b/web/src/lib/components/user-settings-page/user-profile-settings.svelte index 3fc770de36..847e517dbf 100644 --- a/web/src/lib/components/user-settings-page/user-profile-settings.svelte +++ b/web/src/lib/components/user-settings-page/user-profile-settings.svelte @@ -75,6 +75,14 @@ required={false} /> + +
diff --git a/web/src/routes/admin/user-management/+page.svelte b/web/src/routes/admin/user-management/+page.svelte index b5a3624bb8..5dc4b82601 100644 --- a/web/src/routes/admin/user-management/+page.svelte +++ b/web/src/routes/admin/user-management/+page.svelte @@ -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 @@ Email First name Last name + Can import Action @@ -191,6 +194,15 @@ {user.email} {user.firstName} {user.lastName} + +
+ {#if user.externalPath} + + {:else} + + {/if} +
+ {#if !isDeleted(user)}