diff --git a/mobile/openapi/.openapi-generator/FILES b/mobile/openapi/.openapi-generator/FILES index a34d0d4830b6a..76099cd88308d 100644 --- a/mobile/openapi/.openapi-generator/FILES +++ b/mobile/openapi/.openapi-generator/FILES @@ -30,6 +30,7 @@ doc/CheckDuplicateAssetResponseDto.md doc/CheckExistingAssetsDto.md doc/CheckExistingAssetsResponseDto.md doc/CreateAlbumDto.md +doc/CreateAlbumShareLinkDto.md doc/CreateProfileImageResponseDto.md doc/CreateTagDto.md doc/CreateUserDto.md @@ -41,6 +42,8 @@ doc/DeleteAssetStatus.md doc/DeviceInfoApi.md doc/DeviceInfoResponseDto.md doc/DeviceTypeEnum.md +doc/DownloadFilesDto.md +doc/EditSharedLinkDto.md doc/ExifResponseDto.md doc/GetAssetByTimeBucketDto.md doc/GetAssetCountByTimeBucketDto.md @@ -64,6 +67,9 @@ doc/ServerInfoResponseDto.md doc/ServerPingResponse.md doc/ServerStatsResponseDto.md doc/ServerVersionReponseDto.md +doc/ShareApi.md +doc/SharedLinkResponseDto.md +doc/SharedLinkType.md doc/SignUpDto.md doc/SmartInfoResponseDto.md doc/SystemConfigApi.md @@ -97,6 +103,7 @@ lib/api/device_info_api.dart lib/api/job_api.dart lib/api/o_auth_api.dart lib/api/server_info_api.dart +lib/api/share_api.dart lib/api/system_config_api.dart lib/api/tag_api.dart lib/api/user_api.dart @@ -131,6 +138,7 @@ lib/model/check_duplicate_asset_response_dto.dart lib/model/check_existing_assets_dto.dart lib/model/check_existing_assets_response_dto.dart lib/model/create_album_dto.dart +lib/model/create_album_share_link_dto.dart lib/model/create_profile_image_response_dto.dart lib/model/create_tag_dto.dart lib/model/create_user_dto.dart @@ -141,6 +149,8 @@ lib/model/delete_asset_response_dto.dart lib/model/delete_asset_status.dart lib/model/device_info_response_dto.dart lib/model/device_type_enum.dart +lib/model/download_files_dto.dart +lib/model/edit_shared_link_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 @@ -161,6 +171,8 @@ lib/model/server_info_response_dto.dart lib/model/server_ping_response.dart lib/model/server_stats_response_dto.dart lib/model/server_version_reponse_dto.dart +lib/model/shared_link_response_dto.dart +lib/model/shared_link_type.dart lib/model/sign_up_dto.dart lib/model/smart_info_response_dto.dart lib/model/system_config_dto.dart @@ -209,6 +221,7 @@ test/check_duplicate_asset_response_dto_test.dart test/check_existing_assets_dto_test.dart test/check_existing_assets_response_dto_test.dart test/create_album_dto_test.dart +test/create_album_share_link_dto_test.dart test/create_profile_image_response_dto_test.dart test/create_tag_dto_test.dart test/create_user_dto_test.dart @@ -220,6 +233,8 @@ test/delete_asset_status_test.dart test/device_info_api_test.dart test/device_info_response_dto_test.dart test/device_type_enum_test.dart +test/download_files_dto_test.dart +test/edit_shared_link_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 @@ -243,6 +258,9 @@ test/server_info_response_dto_test.dart test/server_ping_response_test.dart test/server_stats_response_dto_test.dart test/server_version_reponse_dto_test.dart +test/share_api_test.dart +test/shared_link_response_dto_test.dart +test/shared_link_type_test.dart test/sign_up_dto_test.dart test/smart_info_response_dto_test.dart test/system_config_api_test.dart diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 27387bfe0f633..786043e174b11 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -66,6 +66,7 @@ Class | Method | HTTP request | Description *AlbumApi* | [**addAssetsToAlbum**](doc//AlbumApi.md#addassetstoalbum) | **PUT** /album/{albumId}/assets | *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users | *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | +*AlbumApi* | [**createAlbumSharedLink**](doc//AlbumApi.md#createalbumsharedlink) | **POST** /album/create-shared-link | *AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} | *AlbumApi* | [**downloadArchive**](doc//AlbumApi.md#downloadarchive) | **GET** /album/{albumId}/download | *AlbumApi* | [**getAlbumCountByUserId**](doc//AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id | @@ -78,6 +79,7 @@ Class | Method | HTTP request | Description *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | *AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset | *AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **GET** /asset/download/{assetId} | +*AssetApi* | [**downloadFiles**](doc//AssetApi.md#downloadfiles) | **POST** /asset/download-files | *AssetApi* | [**downloadLibrary**](doc//AssetApi.md#downloadlibrary) | **GET** /asset/download-library | *AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset | *AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} | @@ -113,6 +115,11 @@ Class | Method | HTTP request | Description *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | +*ShareApi* | [**editSharedLink**](doc//ShareApi.md#editsharedlink) | **PATCH** /share/{id} | +*ShareApi* | [**getAllSharedLinks**](doc//ShareApi.md#getallsharedlinks) | **GET** /share | +*ShareApi* | [**getMySharedLink**](doc//ShareApi.md#getmysharedlink) | **GET** /share/me | +*ShareApi* | [**getSharedLinkById**](doc//ShareApi.md#getsharedlinkbyid) | **GET** /share/{id} | +*ShareApi* | [**removeSharedLink**](doc//ShareApi.md#removesharedlink) | **DELETE** /share/{id} | *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | *SystemConfigApi* | [**getDefaults**](doc//SystemConfigApi.md#getdefaults) | **GET** /system-config/defaults | *SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | @@ -159,6 +166,7 @@ Class | Method | HTTP request | Description - [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md) - [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md) - [CreateAlbumDto](doc//CreateAlbumDto.md) + - [CreateAlbumShareLinkDto](doc//CreateAlbumShareLinkDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) - [CreateTagDto](doc//CreateTagDto.md) - [CreateUserDto](doc//CreateUserDto.md) @@ -169,6 +177,8 @@ Class | Method | HTTP request | Description - [DeleteAssetStatus](doc//DeleteAssetStatus.md) - [DeviceInfoResponseDto](doc//DeviceInfoResponseDto.md) - [DeviceTypeEnum](doc//DeviceTypeEnum.md) + - [DownloadFilesDto](doc//DownloadFilesDto.md) + - [EditSharedLinkDto](doc//EditSharedLinkDto.md) - [ExifResponseDto](doc//ExifResponseDto.md) - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md) - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md) @@ -189,6 +199,8 @@ Class | Method | HTTP request | Description - [ServerPingResponse](doc//ServerPingResponse.md) - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md) - [ServerVersionReponseDto](doc//ServerVersionReponseDto.md) + - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md) + - [SharedLinkType](doc//SharedLinkType.md) - [SignUpDto](doc//SignUpDto.md) - [SmartInfoResponseDto](doc//SmartInfoResponseDto.md) - [SystemConfigDto](doc//SystemConfigDto.md) diff --git a/mobile/openapi/doc/AlbumApi.md b/mobile/openapi/doc/AlbumApi.md index fbc0cfa8c1aaa..7f57c201a9b64 100644 --- a/mobile/openapi/doc/AlbumApi.md +++ b/mobile/openapi/doc/AlbumApi.md @@ -12,6 +12,7 @@ Method | HTTP request | Description [**addAssetsToAlbum**](AlbumApi.md#addassetstoalbum) | **PUT** /album/{albumId}/assets | [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{albumId}/users | [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | +[**createAlbumSharedLink**](AlbumApi.md#createalbumsharedlink) | **POST** /album/create-shared-link | [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{albumId} | [**downloadArchive**](AlbumApi.md#downloadarchive) | **GET** /album/{albumId}/download | [**getAlbumCountByUserId**](AlbumApi.md#getalbumcountbyuserid) | **GET** /album/count-by-user-id | @@ -167,6 +168,53 @@ 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) +# **createAlbumSharedLink** +> SharedLinkResponseDto createAlbumSharedLink(createAlbumShareLinkDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// 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 = AlbumApi(); +final createAlbumShareLinkDto = CreateAlbumShareLinkDto(); // CreateAlbumShareLinkDto | + +try { + final result = api_instance.createAlbumSharedLink(createAlbumShareLinkDto); + print(result); +} catch (e) { + print('Exception when calling AlbumApi->createAlbumSharedLink: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **createAlbumShareLinkDto** | [**CreateAlbumShareLinkDto**](CreateAlbumShareLinkDto.md)| | + +### Return type + +[**SharedLinkResponseDto**](SharedLinkResponseDto.md) + +### Authorization + +[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) + # **deleteAlbum** > deleteAlbum(albumId) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index d6139bd58b00c..f8fafd51d25b0 100644 --- a/mobile/openapi/doc/AssetApi.md +++ b/mobile/openapi/doc/AssetApi.md @@ -13,6 +13,7 @@ Method | HTTP request | Description [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | [**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset | [**downloadFile**](AssetApi.md#downloadfile) | **GET** /asset/download/{assetId} | +[**downloadFiles**](AssetApi.md#downloadfiles) | **POST** /asset/download-files | [**downloadLibrary**](AssetApi.md#downloadlibrary) | **GET** /asset/download-library | [**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset | [**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{assetId} | @@ -226,6 +227,53 @@ 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) +# **downloadFiles** +> Object downloadFiles(downloadFilesDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; +// 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 downloadFilesDto = DownloadFilesDto(); // DownloadFilesDto | + +try { + final result = api_instance.downloadFiles(downloadFilesDto); + print(result); +} catch (e) { + print('Exception when calling AssetApi->downloadFiles: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **downloadFilesDto** | [**DownloadFilesDto**](DownloadFilesDto.md)| | + +### Return type + +[**Object**](Object.md) + +### Authorization + +[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) + # **downloadLibrary** > Object downloadLibrary(skip) diff --git a/mobile/openapi/doc/CreateAlbumShareLinkDto.md b/mobile/openapi/doc/CreateAlbumShareLinkDto.md new file mode 100644 index 0000000000000..dd305b4ff72ec --- /dev/null +++ b/mobile/openapi/doc/CreateAlbumShareLinkDto.md @@ -0,0 +1,18 @@ +# openapi.model.CreateAlbumShareLinkDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**albumId** | **String** | | +**expiredAt** | **String** | | [optional] +**allowUpload** | **bool** | | [optional] +**description** | **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/DownloadFilesDto.md b/mobile/openapi/doc/DownloadFilesDto.md new file mode 100644 index 0000000000000..6b44eef05dcc2 --- /dev/null +++ b/mobile/openapi/doc/DownloadFilesDto.md @@ -0,0 +1,15 @@ +# openapi.model.DownloadFilesDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**assetIds** | **List** | | [default to const []] + +[[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/EditSharedLinkDto.md b/mobile/openapi/doc/EditSharedLinkDto.md new file mode 100644 index 0000000000000..cc94fc5b62fa6 --- /dev/null +++ b/mobile/openapi/doc/EditSharedLinkDto.md @@ -0,0 +1,18 @@ +# openapi.model.EditSharedLinkDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**description** | **String** | | [optional] +**expiredAt** | **String** | | [optional] +**allowUpload** | **bool** | | [optional] +**isEditExpireTime** | **bool** | | [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/ShareApi.md b/mobile/openapi/doc/ShareApi.md new file mode 100644 index 0000000000000..419f115fb261d --- /dev/null +++ b/mobile/openapi/doc/ShareApi.md @@ -0,0 +1,217 @@ +# openapi.api.ShareApi + +## Load the API package +```dart +import 'package:openapi/api.dart'; +``` + +All URIs are relative to */api* + +Method | HTTP request | Description +------------- | ------------- | ------------- +[**editSharedLink**](ShareApi.md#editsharedlink) | **PATCH** /share/{id} | +[**getAllSharedLinks**](ShareApi.md#getallsharedlinks) | **GET** /share | +[**getMySharedLink**](ShareApi.md#getmysharedlink) | **GET** /share/me | +[**getSharedLinkById**](ShareApi.md#getsharedlinkbyid) | **GET** /share/{id} | +[**removeSharedLink**](ShareApi.md#removesharedlink) | **DELETE** /share/{id} | + + +# **editSharedLink** +> SharedLinkResponseDto editSharedLink(id, editSharedLinkDto) + + + +### Example +```dart +import 'package:openapi/api.dart'; + +final api_instance = ShareApi(); +final id = id_example; // String | +final editSharedLinkDto = EditSharedLinkDto(); // EditSharedLinkDto | + +try { + final result = api_instance.editSharedLink(id, editSharedLinkDto); + print(result); +} catch (e) { + print('Exception when calling ShareApi->editSharedLink: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + **editSharedLinkDto** | [**EditSharedLinkDto**](EditSharedLinkDto.md)| | + +### Return type + +[**SharedLinkResponseDto**](SharedLinkResponseDto.md) + +### Authorization + +No authorization required + +### 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) + +# **getAllSharedLinks** +> List getAllSharedLinks() + + + +### Example +```dart +import 'package:openapi/api.dart'; + +final api_instance = ShareApi(); + +try { + final result = api_instance.getAllSharedLinks(); + print(result); +} catch (e) { + print('Exception when calling ShareApi->getAllSharedLinks: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**List**](SharedLinkResponseDto.md) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **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) + +# **getMySharedLink** +> SharedLinkResponseDto getMySharedLink() + + + +### Example +```dart +import 'package:openapi/api.dart'; + +final api_instance = ShareApi(); + +try { + final result = api_instance.getMySharedLink(); + print(result); +} catch (e) { + print('Exception when calling ShareApi->getMySharedLink: $e\n'); +} +``` + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**SharedLinkResponseDto**](SharedLinkResponseDto.md) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **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) + +# **getSharedLinkById** +> SharedLinkResponseDto getSharedLinkById(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; + +final api_instance = ShareApi(); +final id = id_example; // String | + +try { + final result = api_instance.getSharedLinkById(id); + print(result); +} catch (e) { + print('Exception when calling ShareApi->getSharedLinkById: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +[**SharedLinkResponseDto**](SharedLinkResponseDto.md) + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **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) + +# **removeSharedLink** +> String removeSharedLink(id) + + + +### Example +```dart +import 'package:openapi/api.dart'; + +final api_instance = ShareApi(); +final id = id_example; // String | + +try { + final result = api_instance.removeSharedLink(id); + print(result); +} catch (e) { + print('Exception when calling ShareApi->removeSharedLink: $e\n'); +} +``` + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **id** | **String**| | + +### Return type + +**String** + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **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) + diff --git a/mobile/openapi/doc/SharedLinkResponseDto.md b/mobile/openapi/doc/SharedLinkResponseDto.md new file mode 100644 index 0000000000000..b27cc6dbc219d --- /dev/null +++ b/mobile/openapi/doc/SharedLinkResponseDto.md @@ -0,0 +1,24 @@ +# openapi.model.SharedLinkResponseDto + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- +**type** | [**SharedLinkType**](SharedLinkType.md) | | +**id** | **String** | | +**description** | **String** | | [optional] +**userId** | **String** | | +**key** | **String** | | +**createdAt** | **String** | | +**expiresAt** | **String** | | +**assets** | **List** | | [default to const []] +**album** | [**AlbumResponseDto**](AlbumResponseDto.md) | | [optional] +**allowUpload** | **bool** | | + +[[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/SharedLinkType.md b/mobile/openapi/doc/SharedLinkType.md new file mode 100644 index 0000000000000..78d7604682d6a --- /dev/null +++ b/mobile/openapi/doc/SharedLinkType.md @@ -0,0 +1,14 @@ +# openapi.model.SharedLinkType + +## Load the model package +```dart +import 'package:openapi/api.dart'; +``` + +## Properties +Name | Type | Description | Notes +------------ | ------------- | ------------- | ------------- + +[[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/lib/api.dart b/mobile/openapi/lib/api.dart index a7f434972b6af..e2ea9592a8cf7 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -35,6 +35,7 @@ part 'api/device_info_api.dart'; part 'api/job_api.dart'; part 'api/o_auth_api.dart'; part 'api/server_info_api.dart'; +part 'api/share_api.dart'; part 'api/system_config_api.dart'; part 'api/tag_api.dart'; part 'api/user_api.dart'; @@ -62,6 +63,7 @@ part 'model/check_duplicate_asset_response_dto.dart'; part 'model/check_existing_assets_dto.dart'; part 'model/check_existing_assets_response_dto.dart'; part 'model/create_album_dto.dart'; +part 'model/create_album_share_link_dto.dart'; part 'model/create_profile_image_response_dto.dart'; part 'model/create_tag_dto.dart'; part 'model/create_user_dto.dart'; @@ -72,6 +74,8 @@ part 'model/delete_asset_response_dto.dart'; part 'model/delete_asset_status.dart'; part 'model/device_info_response_dto.dart'; part 'model/device_type_enum.dart'; +part 'model/download_files_dto.dart'; +part 'model/edit_shared_link_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'; @@ -92,6 +96,8 @@ part 'model/server_info_response_dto.dart'; part 'model/server_ping_response.dart'; part 'model/server_stats_response_dto.dart'; part 'model/server_version_reponse_dto.dart'; +part 'model/shared_link_response_dto.dart'; +part 'model/shared_link_type.dart'; part 'model/sign_up_dto.dart'; part 'model/smart_info_response_dto.dart'; part 'model/system_config_dto.dart'; diff --git a/mobile/openapi/lib/api/album_api.dart b/mobile/openapi/lib/api/album_api.dart index 7a0d91ae6f004..614aad807abc6 100644 --- a/mobile/openapi/lib/api/album_api.dart +++ b/mobile/openapi/lib/api/album_api.dart @@ -167,6 +167,53 @@ class AlbumApi { return null; } + /// Performs an HTTP 'POST /album/create-shared-link' operation and returns the [Response]. + /// Parameters: + /// + /// * [CreateAlbumShareLinkDto] createAlbumShareLinkDto (required): + Future createAlbumSharedLinkWithHttpInfo(CreateAlbumShareLinkDto createAlbumShareLinkDto,) async { + // ignore: prefer_const_declarations + final path = r'/album/create-shared-link'; + + // ignore: prefer_final_locals + Object? postBody = createAlbumShareLinkDto; + + 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: + /// + /// * [CreateAlbumShareLinkDto] createAlbumShareLinkDto (required): + Future createAlbumSharedLink(CreateAlbumShareLinkDto createAlbumShareLinkDto,) async { + final response = await createAlbumSharedLinkWithHttpInfo(createAlbumShareLinkDto,); + 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), 'SharedLinkResponseDto',) as SharedLinkResponseDto; + + } + return null; + } + /// Performs an HTTP 'DELETE /album/{albumId}' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index f8609a66e6926..fb0e2b7190a96 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -233,6 +233,53 @@ class AssetApi { return null; } + /// Performs an HTTP 'POST /asset/download-files' operation and returns the [Response]. + /// Parameters: + /// + /// * [DownloadFilesDto] downloadFilesDto (required): + Future downloadFilesWithHttpInfo(DownloadFilesDto downloadFilesDto,) async { + // ignore: prefer_const_declarations + final path = r'/asset/download-files'; + + // ignore: prefer_final_locals + Object? postBody = downloadFilesDto; + + 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: + /// + /// * [DownloadFilesDto] downloadFilesDto (required): + Future downloadFiles(DownloadFilesDto downloadFilesDto,) async { + final response = await downloadFilesWithHttpInfo(downloadFilesDto,); + 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), 'Object',) as Object; + + } + return null; + } + /// Performs an HTTP 'GET /asset/download-library' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/share_api.dart b/mobile/openapi/lib/api/share_api.dart new file mode 100644 index 0000000000000..6695e6aa0afb6 --- /dev/null +++ b/mobile/openapi/lib/api/share_api.dart @@ -0,0 +1,251 @@ +// +// 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 ShareApi { + ShareApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'PATCH /share/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [EditSharedLinkDto] editSharedLinkDto (required): + Future editSharedLinkWithHttpInfo(String id, EditSharedLinkDto editSharedLinkDto,) async { + // ignore: prefer_const_declarations + final path = r'/share/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody = editSharedLinkDto; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = ['application/json']; + + + return apiClient.invokeAPI( + path, + 'PATCH', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [EditSharedLinkDto] editSharedLinkDto (required): + Future editSharedLink(String id, EditSharedLinkDto editSharedLinkDto,) async { + final response = await editSharedLinkWithHttpInfo(id, editSharedLinkDto,); + 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), 'SharedLinkResponseDto',) as SharedLinkResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /share' operation and returns the [Response]. + Future getAllSharedLinksWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/share'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future?> getAllSharedLinks() async { + final response = await getAllSharedLinksWithHttpInfo(); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(); + + } + return null; + } + + /// Performs an HTTP 'GET /share/me' operation and returns the [Response]. + Future getMySharedLinkWithHttpInfo() async { + // ignore: prefer_const_declarations + final path = r'/share/me'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + Future getMySharedLink() async { + final response = await getMySharedLinkWithHttpInfo(); + 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), 'SharedLinkResponseDto',) as SharedLinkResponseDto; + + } + return null; + } + + /// Performs an HTTP 'GET /share/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future getSharedLinkByIdWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/share/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future getSharedLinkById(String id,) async { + final response = await getSharedLinkByIdWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SharedLinkResponseDto',) as SharedLinkResponseDto; + + } + return null; + } + + /// Performs an HTTP 'DELETE /share/{id}' operation and returns the [Response]. + /// Parameters: + /// + /// * [String] id (required): + Future removeSharedLinkWithHttpInfo(String id,) async { + // ignore: prefer_const_declarations + final path = r'/share/{id}' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'DELETE', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [String] id (required): + Future removeSharedLink(String id,) async { + final response = await removeSharedLinkWithHttpInfo(id,); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'String',) as String; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index f3d0604b72474..1e2ef461b29ca 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -238,6 +238,8 @@ class ApiClient { return CheckExistingAssetsResponseDto.fromJson(value); case 'CreateAlbumDto': return CreateAlbumDto.fromJson(value); + case 'CreateAlbumShareLinkDto': + return CreateAlbumShareLinkDto.fromJson(value); case 'CreateProfileImageResponseDto': return CreateProfileImageResponseDto.fromJson(value); case 'CreateTagDto': @@ -258,6 +260,10 @@ class ApiClient { return DeviceInfoResponseDto.fromJson(value); case 'DeviceTypeEnum': return DeviceTypeEnumTypeTransformer().decode(value); + case 'DownloadFilesDto': + return DownloadFilesDto.fromJson(value); + case 'EditSharedLinkDto': + return EditSharedLinkDto.fromJson(value); case 'ExifResponseDto': return ExifResponseDto.fromJson(value); case 'GetAssetByTimeBucketDto': @@ -298,6 +304,10 @@ class ApiClient { return ServerStatsResponseDto.fromJson(value); case 'ServerVersionReponseDto': return ServerVersionReponseDto.fromJson(value); + case 'SharedLinkResponseDto': + return SharedLinkResponseDto.fromJson(value); + case 'SharedLinkType': + return SharedLinkTypeTypeTransformer().decode(value); case 'SignUpDto': return SignUpDto.fromJson(value); case 'SmartInfoResponseDto': diff --git a/mobile/openapi/lib/api_helper.dart b/mobile/openapi/lib/api_helper.dart index c59dc0f913d9e..ba9f94ccdcdad 100644 --- a/mobile/openapi/lib/api_helper.dart +++ b/mobile/openapi/lib/api_helper.dart @@ -70,6 +70,9 @@ String parameterToString(dynamic value) { if (value is JobId) { return JobIdTypeTransformer().encode(value).toString(); } + if (value is SharedLinkType) { + return SharedLinkTypeTypeTransformer().encode(value).toString(); + } if (value is TagTypeEnum) { return TagTypeEnumTypeTransformer().encode(value).toString(); } diff --git a/mobile/openapi/lib/model/create_album_share_link_dto.dart b/mobile/openapi/lib/model/create_album_share_link_dto.dart new file mode 100644 index 0000000000000..e021ecde61133 --- /dev/null +++ b/mobile/openapi/lib/model/create_album_share_link_dto.dart @@ -0,0 +1,162 @@ +// +// 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 CreateAlbumShareLinkDto { + /// Returns a new [CreateAlbumShareLinkDto] instance. + CreateAlbumShareLinkDto({ + required this.albumId, + this.expiredAt, + this.allowUpload, + this.description, + }); + + String albumId; + + /// + /// 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? expiredAt; + + /// + /// 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? allowUpload; + + /// + /// 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? description; + + @override + bool operator ==(Object other) => identical(this, other) || other is CreateAlbumShareLinkDto && + other.albumId == albumId && + other.expiredAt == expiredAt && + other.allowUpload == allowUpload && + other.description == description; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (albumId.hashCode) + + (expiredAt == null ? 0 : expiredAt!.hashCode) + + (allowUpload == null ? 0 : allowUpload!.hashCode) + + (description == null ? 0 : description!.hashCode); + + @override + String toString() => 'CreateAlbumShareLinkDto[albumId=$albumId, expiredAt=$expiredAt, allowUpload=$allowUpload, description=$description]'; + + Map toJson() { + final _json = {}; + _json[r'albumId'] = albumId; + if (expiredAt != null) { + _json[r'expiredAt'] = expiredAt; + } else { + _json[r'expiredAt'] = null; + } + if (allowUpload != null) { + _json[r'allowUpload'] = allowUpload; + } else { + _json[r'allowUpload'] = null; + } + if (description != null) { + _json[r'description'] = description; + } else { + _json[r'description'] = null; + } + return _json; + } + + /// Returns a new [CreateAlbumShareLinkDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static CreateAlbumShareLinkDto? 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 "CreateAlbumShareLinkDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "CreateAlbumShareLinkDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return CreateAlbumShareLinkDto( + albumId: mapValueOfType(json, r'albumId')!, + expiredAt: mapValueOfType(json, r'expiredAt'), + allowUpload: mapValueOfType(json, r'allowUpload'), + description: mapValueOfType(json, r'description'), + ); + } + 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 = CreateAlbumShareLinkDto.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 = CreateAlbumShareLinkDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of CreateAlbumShareLinkDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = CreateAlbumShareLinkDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'albumId', + }; +} + diff --git a/mobile/openapi/lib/model/download_files_dto.dart b/mobile/openapi/lib/model/download_files_dto.dart new file mode 100644 index 0000000000000..bef626560eee4 --- /dev/null +++ b/mobile/openapi/lib/model/download_files_dto.dart @@ -0,0 +1,113 @@ +// +// 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 DownloadFilesDto { + /// Returns a new [DownloadFilesDto] instance. + DownloadFilesDto({ + this.assetIds = const [], + }); + + List assetIds; + + @override + bool operator ==(Object other) => identical(this, other) || other is DownloadFilesDto && + other.assetIds == assetIds; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (assetIds.hashCode); + + @override + String toString() => 'DownloadFilesDto[assetIds=$assetIds]'; + + Map toJson() { + final _json = {}; + _json[r'assetIds'] = assetIds; + return _json; + } + + /// Returns a new [DownloadFilesDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DownloadFilesDto? 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 "DownloadFilesDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "DownloadFilesDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return DownloadFilesDto( + assetIds: json[r'assetIds'] is List + ? (json[r'assetIds'] as List).cast() + : const [], + ); + } + 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 = DownloadFilesDto.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 = DownloadFilesDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DownloadFilesDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = DownloadFilesDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'assetIds', + }; +} + diff --git a/mobile/openapi/lib/model/edit_shared_link_dto.dart b/mobile/openapi/lib/model/edit_shared_link_dto.dart new file mode 100644 index 0000000000000..458ce2f7597e0 --- /dev/null +++ b/mobile/openapi/lib/model/edit_shared_link_dto.dart @@ -0,0 +1,171 @@ +// +// 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 EditSharedLinkDto { + /// Returns a new [EditSharedLinkDto] instance. + EditSharedLinkDto({ + this.description, + this.expiredAt, + this.allowUpload, + this.isEditExpireTime, + }); + + /// + /// 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? description; + + /// + /// 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? expiredAt; + + /// + /// 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? allowUpload; + + /// + /// 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? isEditExpireTime; + + @override + bool operator ==(Object other) => identical(this, other) || other is EditSharedLinkDto && + other.description == description && + other.expiredAt == expiredAt && + other.allowUpload == allowUpload && + other.isEditExpireTime == isEditExpireTime; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (description == null ? 0 : description!.hashCode) + + (expiredAt == null ? 0 : expiredAt!.hashCode) + + (allowUpload == null ? 0 : allowUpload!.hashCode) + + (isEditExpireTime == null ? 0 : isEditExpireTime!.hashCode); + + @override + String toString() => 'EditSharedLinkDto[description=$description, expiredAt=$expiredAt, allowUpload=$allowUpload, isEditExpireTime=$isEditExpireTime]'; + + Map toJson() { + final _json = {}; + if (description != null) { + _json[r'description'] = description; + } else { + _json[r'description'] = null; + } + if (expiredAt != null) { + _json[r'expiredAt'] = expiredAt; + } else { + _json[r'expiredAt'] = null; + } + if (allowUpload != null) { + _json[r'allowUpload'] = allowUpload; + } else { + _json[r'allowUpload'] = null; + } + if (isEditExpireTime != null) { + _json[r'isEditExpireTime'] = isEditExpireTime; + } else { + _json[r'isEditExpireTime'] = null; + } + return _json; + } + + /// Returns a new [EditSharedLinkDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static EditSharedLinkDto? 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 "EditSharedLinkDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "EditSharedLinkDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return EditSharedLinkDto( + description: mapValueOfType(json, r'description'), + expiredAt: mapValueOfType(json, r'expiredAt'), + allowUpload: mapValueOfType(json, r'allowUpload'), + isEditExpireTime: mapValueOfType(json, r'isEditExpireTime'), + ); + } + 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 = EditSharedLinkDto.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 = EditSharedLinkDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of EditSharedLinkDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = EditSharedLinkDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + }; +} + diff --git a/mobile/openapi/lib/model/shared_link_response_dto.dart b/mobile/openapi/lib/model/shared_link_response_dto.dart new file mode 100644 index 0000000000000..3c51d90d2d2be --- /dev/null +++ b/mobile/openapi/lib/model/shared_link_response_dto.dart @@ -0,0 +1,207 @@ +// +// 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 SharedLinkResponseDto { + /// Returns a new [SharedLinkResponseDto] instance. + SharedLinkResponseDto({ + required this.type, + required this.id, + this.description, + required this.userId, + required this.key, + required this.createdAt, + required this.expiresAt, + this.assets = const [], + this.album, + required this.allowUpload, + }); + + SharedLinkType type; + + String id; + + /// + /// 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? description; + + String userId; + + String key; + + String createdAt; + + String? expiresAt; + + List assets; + + /// + /// 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. + /// + AlbumResponseDto? album; + + bool allowUpload; + + @override + bool operator ==(Object other) => identical(this, other) || other is SharedLinkResponseDto && + other.type == type && + other.id == id && + other.description == description && + other.userId == userId && + other.key == key && + other.createdAt == createdAt && + other.expiresAt == expiresAt && + other.assets == assets && + other.album == album && + other.allowUpload == allowUpload; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (type.hashCode) + + (id.hashCode) + + (description == null ? 0 : description!.hashCode) + + (userId.hashCode) + + (key.hashCode) + + (createdAt.hashCode) + + (expiresAt == null ? 0 : expiresAt!.hashCode) + + (assets.hashCode) + + (album == null ? 0 : album!.hashCode) + + (allowUpload.hashCode); + + @override + String toString() => 'SharedLinkResponseDto[type=$type, id=$id, description=$description, userId=$userId, key=$key, createdAt=$createdAt, expiresAt=$expiresAt, assets=$assets, album=$album, allowUpload=$allowUpload]'; + + Map toJson() { + final _json = {}; + _json[r'type'] = type; + _json[r'id'] = id; + if (description != null) { + _json[r'description'] = description; + } else { + _json[r'description'] = null; + } + _json[r'userId'] = userId; + _json[r'key'] = key; + _json[r'createdAt'] = createdAt; + if (expiresAt != null) { + _json[r'expiresAt'] = expiresAt; + } else { + _json[r'expiresAt'] = null; + } + _json[r'assets'] = assets; + if (album != null) { + _json[r'album'] = album; + } else { + _json[r'album'] = null; + } + _json[r'allowUpload'] = allowUpload; + return _json; + } + + /// Returns a new [SharedLinkResponseDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SharedLinkResponseDto? 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 "SharedLinkResponseDto[$key]" is missing from JSON.'); + assert(json[key] != null, 'Required key "SharedLinkResponseDto[$key]" has a null value in JSON.'); + }); + return true; + }()); + + return SharedLinkResponseDto( + type: SharedLinkType.fromJson(json[r'type'])!, + id: mapValueOfType(json, r'id')!, + description: mapValueOfType(json, r'description'), + userId: mapValueOfType(json, r'userId')!, + key: mapValueOfType(json, r'key')!, + createdAt: mapValueOfType(json, r'createdAt')!, + expiresAt: mapValueOfType(json, r'expiresAt'), + assets: json[r'assets'] is List + ? (json[r'assets'] as List).cast() + : const [], + album: AlbumResponseDto.fromJson(json[r'album']), + allowUpload: mapValueOfType(json, r'allowUpload')!, + ); + } + 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 = SharedLinkResponseDto.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 = SharedLinkResponseDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SharedLinkResponseDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SharedLinkResponseDto.listFromJson(entry.value, growable: growable,); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'type', + 'id', + 'userId', + 'key', + 'createdAt', + 'expiresAt', + 'assets', + 'allowUpload', + }; +} + diff --git a/mobile/openapi/lib/model/shared_link_type.dart b/mobile/openapi/lib/model/shared_link_type.dart new file mode 100644 index 0000000000000..117eb7ca01149 --- /dev/null +++ b/mobile/openapi/lib/model/shared_link_type.dart @@ -0,0 +1,85 @@ +// +// 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 SharedLinkType { + /// Instantiate a new enum with the provided [value]. + const SharedLinkType._(this.value); + + /// The underlying value of this enum member. + final String value; + + @override + String toString() => value; + + String toJson() => value; + + static const ALBUM = SharedLinkType._(r'ALBUM'); + static const INDIVIDUAL = SharedLinkType._(r'INDIVIDUAL'); + + /// List of all possible values in this [enum][SharedLinkType]. + static const values = [ + ALBUM, + INDIVIDUAL, + ]; + + static SharedLinkType? fromJson(dynamic value) => SharedLinkTypeTypeTransformer().decode(value); + + static List? listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SharedLinkType.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } +} + +/// Transformation class that can [encode] an instance of [SharedLinkType] to String, +/// and [decode] dynamic data back to [SharedLinkType]. +class SharedLinkTypeTypeTransformer { + factory SharedLinkTypeTypeTransformer() => _instance ??= const SharedLinkTypeTypeTransformer._(); + + const SharedLinkTypeTypeTransformer._(); + + String encode(SharedLinkType data) => data.value; + + /// Decodes a [dynamic value][data] to a SharedLinkType. + /// + /// If [allowNull] is true and the [dynamic value][data] cannot be decoded successfully, + /// then null is returned. However, if [allowNull] is false and the [dynamic value][data] + /// cannot be decoded successfully, then an [UnimplementedError] is thrown. + /// + /// The [allowNull] is very handy when an API changes and a new enum value is added or removed, + /// and users are still using an old app with the old code. + SharedLinkType? decode(dynamic data, {bool allowNull = true}) { + if (data != null) { + switch (data.toString()) { + case r'ALBUM': return SharedLinkType.ALBUM; + case r'INDIVIDUAL': return SharedLinkType.INDIVIDUAL; + default: + if (!allowNull) { + throw ArgumentError('Unknown enum value to decode: $data'); + } + } + } + return null; + } + + /// Singleton [SharedLinkTypeTypeTransformer] instance. + static SharedLinkTypeTypeTransformer? _instance; +} + diff --git a/mobile/openapi/test/album_api_test.dart b/mobile/openapi/test/album_api_test.dart index 6e4f6175de8f1..f120d01bdf0a0 100644 --- a/mobile/openapi/test/album_api_test.dart +++ b/mobile/openapi/test/album_api_test.dart @@ -32,6 +32,11 @@ void main() { // TODO }); + //Future createAlbumSharedLink(CreateAlbumShareLinkDto createAlbumShareLinkDto) async + test('test createAlbumSharedLink', () async { + // TODO + }); + //Future deleteAlbum(String albumId) async test('test deleteAlbum', () async { // TODO diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 924e8e288c89f..c7f356dac3fd5 100644 --- a/mobile/openapi/test/asset_api_test.dart +++ b/mobile/openapi/test/asset_api_test.dart @@ -41,6 +41,11 @@ void main() { // TODO }); + //Future downloadFiles(DownloadFilesDto downloadFilesDto) async + test('test downloadFiles', () async { + // TODO + }); + //Future downloadLibrary({ num skip }) async test('test downloadLibrary', () async { // TODO diff --git a/mobile/openapi/test/create_album_share_link_dto_test.dart b/mobile/openapi/test/create_album_share_link_dto_test.dart new file mode 100644 index 0000000000000..bb9bf91663e51 --- /dev/null +++ b/mobile/openapi/test/create_album_share_link_dto_test.dart @@ -0,0 +1,42 @@ +// +// 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 CreateAlbumShareLinkDto +void main() { + // final instance = CreateAlbumShareLinkDto(); + + group('test CreateAlbumShareLinkDto', () { + // String albumId + test('to test the property `albumId`', () async { + // TODO + }); + + // String expiredAt + test('to test the property `expiredAt`', () async { + // TODO + }); + + // bool allowUpload + test('to test the property `allowUpload`', () async { + // TODO + }); + + // String description + test('to test the property `description`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/download_files_dto_test.dart b/mobile/openapi/test/download_files_dto_test.dart new file mode 100644 index 0000000000000..fcc46a6c323b3 --- /dev/null +++ b/mobile/openapi/test/download_files_dto_test.dart @@ -0,0 +1,27 @@ +// +// 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 DownloadFilesDto +void main() { + // final instance = DownloadFilesDto(); + + group('test DownloadFilesDto', () { + // List assetIds (default value: const []) + test('to test the property `assetIds`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/edit_shared_link_dto_test.dart b/mobile/openapi/test/edit_shared_link_dto_test.dart new file mode 100644 index 0000000000000..b7815e0ed068e --- /dev/null +++ b/mobile/openapi/test/edit_shared_link_dto_test.dart @@ -0,0 +1,42 @@ +// +// 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 EditSharedLinkDto +void main() { + // final instance = EditSharedLinkDto(); + + group('test EditSharedLinkDto', () { + // String description + test('to test the property `description`', () async { + // TODO + }); + + // String expiredAt + test('to test the property `expiredAt`', () async { + // TODO + }); + + // bool allowUpload + test('to test the property `allowUpload`', () async { + // TODO + }); + + // bool isEditExpireTime + test('to test the property `isEditExpireTime`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/share_api_test.dart b/mobile/openapi/test/share_api_test.dart new file mode 100644 index 0000000000000..fcc988cdffc71 --- /dev/null +++ b/mobile/openapi/test/share_api_test.dart @@ -0,0 +1,46 @@ +// +// 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 ShareApi +void main() { + // final instance = ShareApi(); + + group('tests for ShareApi', () { + //Future editSharedLink(String id, EditSharedLinkDto editSharedLinkDto) async + test('test editSharedLink', () async { + // TODO + }); + + //Future> getAllSharedLinks() async + test('test getAllSharedLinks', () async { + // TODO + }); + + //Future getMySharedLink() async + test('test getMySharedLink', () async { + // TODO + }); + + //Future getSharedLinkById(String id) async + test('test getSharedLinkById', () async { + // TODO + }); + + //Future removeSharedLink(String id) async + test('test removeSharedLink', () async { + // TODO + }); + + }); +} diff --git a/mobile/openapi/test/shared_link_response_dto_test.dart b/mobile/openapi/test/shared_link_response_dto_test.dart new file mode 100644 index 0000000000000..46778bfa713ac --- /dev/null +++ b/mobile/openapi/test/shared_link_response_dto_test.dart @@ -0,0 +1,72 @@ +// +// 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 SharedLinkResponseDto +void main() { + // final instance = SharedLinkResponseDto(); + + group('test SharedLinkResponseDto', () { + // SharedLinkType type + test('to test the property `type`', () async { + // TODO + }); + + // String id + test('to test the property `id`', () async { + // TODO + }); + + // String description + test('to test the property `description`', () async { + // TODO + }); + + // String userId + test('to test the property `userId`', () async { + // TODO + }); + + // String key + test('to test the property `key`', () async { + // TODO + }); + + // String createdAt + test('to test the property `createdAt`', () async { + // TODO + }); + + // String expiresAt + test('to test the property `expiresAt`', () async { + // TODO + }); + + // List assets (default value: const []) + test('to test the property `assets`', () async { + // TODO + }); + + // AlbumResponseDto album + test('to test the property `album`', () async { + // TODO + }); + + // bool allowUpload + test('to test the property `allowUpload`', () async { + // TODO + }); + + + }); + +} diff --git a/mobile/openapi/test/shared_link_type_test.dart b/mobile/openapi/test/shared_link_type_test.dart new file mode 100644 index 0000000000000..6a2c8cdf51a2d --- /dev/null +++ b/mobile/openapi/test/shared_link_type_test.dart @@ -0,0 +1,21 @@ +// +// 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 SharedLinkType +void main() { + + group('test SharedLinkType', () { + + }); + +} diff --git a/notes.md b/notes.md index 74e97d5d980a3..043dc05993415 100644 --- a/notes.md +++ b/notes.md @@ -1,10 +1,6 @@ -# User defined storage structure +## Public sharing -# Folder structure -* Year is the top level - * Different parsing sequence will be the second level +### Albums -# Filename -* Filename will always be appended by a unique ID. Maybe use https://github.com/ai/nanoid - * Example: `notes.md` -> `notes-1234567890.md` -* Filename will be unique in the same folder \ No newline at end of file +- [ ] Add asset to shared link when new asset is added to shared album +- [ ] Prevent public user to delete asset from shared album diff --git a/server/apps/immich/src/api-v1/album/album-repository.ts b/server/apps/immich/src/api-v1/album/album-repository.ts index 988f68b5f7894..f504a5bd35d9b 100644 --- a/server/apps/immich/src/api-v1/album/album-repository.ts +++ b/server/apps/immich/src/api-v1/album/album-repository.ts @@ -1,7 +1,7 @@ import { AlbumEntity, AssetAlbumEntity, UserAlbumEntity } from '@app/database'; import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { In, Repository, SelectQueryBuilder, DataSource, Brackets } from 'typeorm'; +import { In, Repository, SelectQueryBuilder, DataSource, Brackets, Not, IsNull } from 'typeorm'; import { AddAssetsDto } from './dto/add-assets.dto'; import { AddUsersDto } from './dto/add-users.dto'; import { CreateAlbumDto } from './dto/create-album.dto'; @@ -14,6 +14,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; export interface IAlbumRepository { create(ownerId: string, createAlbumDto: CreateAlbumDto): Promise; getList(ownerId: string, getAlbumsDto: GetAlbumsDto): Promise; + getPublicSharingList(ownerId: string): Promise; get(albumId: string): Promise; delete(album: AlbumEntity): Promise; addSharedUsers(album: AlbumEntity, addUsersDto: AddUsersDto): Promise; @@ -43,6 +44,21 @@ export class AlbumRepository implements IAlbumRepository { private dataSource: DataSource, ) {} + async getPublicSharingList(ownerId: string): Promise { + return this.albumRepository.find({ + relations: { + sharedLinks: true, + assets: true, + }, + where: { + ownerId, + sharedLinks: { + id: Not(IsNull()), + }, + }, + }); + } + async getCountByUserId(userId: string): Promise { const ownedAlbums = await this.albumRepository.find({ where: { ownerId: userId }, relations: ['sharedUsers'] }); @@ -161,6 +177,9 @@ export class AlbumRepository implements IAlbumRepository { .leftJoinAndSelect('assets.assetInfo', 'assetInfo') .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC'); + // Get information of shared links in albums + query = query.leftJoinAndSelect('album.sharedLinks', 'sharedLink'); + const albums = await query.getMany(); albums.sort((a, b) => new Date(b.createdAt).valueOf() - new Date(a.createdAt).valueOf()); @@ -203,6 +222,7 @@ export class AlbumRepository implements IAlbumRepository { .leftJoinAndSelect('album.assets', 'assets') .leftJoinAndSelect('assets.assetInfo', 'assetInfo') .leftJoinAndSelect('assetInfo.exifInfo', 'exifInfo') + .leftJoinAndSelect('album.sharedLinks', 'sharedLinks') .orderBy('"assetInfo"."createdAt"::timestamptz', 'ASC') .getOne(); diff --git a/server/apps/immich/src/api-v1/album/album.controller.ts b/server/apps/immich/src/api-v1/album/album.controller.ts index 678c94bbf3197..a10a992979518 100644 --- a/server/apps/immich/src/api-v1/album/album.controller.ts +++ b/server/apps/immich/src/api-v1/album/album.controller.ts @@ -33,25 +33,29 @@ import { IMMICH_CONTENT_LENGTH_HINT, } from '../../constants/download.constant'; import { DownloadDto } from '../asset/dto/download-library.dto'; +import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto'; // TODO might be worth creating a AlbumParamsDto that validates `albumId` instead of using the pipe. -@Authenticated() + @ApiBearerAuth() @ApiTags('Album') @Controller('album') export class AlbumController { constructor(private readonly albumService: AlbumService) {} + @Authenticated() @Get('count-by-user-id') async getAlbumCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise { return this.albumService.getAlbumCountByUserId(authUser); } + @Authenticated() @Post() async createAlbum(@GetAuthUser() authUser: AuthUserDto, @Body(ValidationPipe) createAlbumDto: CreateAlbumDto) { return this.albumService.create(authUser, createAlbumDto); } + @Authenticated() @Put('/:albumId/users') async addUsersToAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -61,6 +65,7 @@ export class AlbumController { return this.albumService.addUsersToAlbum(authUser, addUsersDto, albumId); } + @Authenticated({ isShared: true }) @Put('/:albumId/assets') async addAssetsToAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -70,6 +75,7 @@ export class AlbumController { return this.albumService.addAssetsToAlbum(authUser, addAssetsDto, albumId); } + @Authenticated() @Get() async getAllAlbums( @GetAuthUser() authUser: AuthUserDto, @@ -78,6 +84,7 @@ export class AlbumController { return this.albumService.getAllAlbums(authUser, query); } + @Authenticated({ isShared: true }) @Get('/:albumId') async getAlbumInfo( @GetAuthUser() authUser: AuthUserDto, @@ -86,6 +93,7 @@ export class AlbumController { return this.albumService.getAlbumInfo(authUser, albumId); } + @Authenticated() @Delete('/:albumId/assets') async removeAssetFromAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -95,6 +103,7 @@ export class AlbumController { return this.albumService.removeAssetsFromAlbum(authUser, removeAssetsDto, albumId); } + @Authenticated() @Delete('/:albumId') async deleteAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -103,6 +112,7 @@ export class AlbumController { return this.albumService.deleteAlbum(authUser, albumId); } + @Authenticated() @Delete('/:albumId/user/:userId') async removeUserFromAlbum( @GetAuthUser() authUser: AuthUserDto, @@ -112,6 +122,7 @@ export class AlbumController { return this.albumService.removeUserFromAlbum(authUser, albumId, userId); } + @Authenticated() @Patch('/:albumId') async updateAlbumInfo( @GetAuthUser() authUser: AuthUserDto, @@ -121,6 +132,7 @@ export class AlbumController { return this.albumService.updateAlbumInfo(authUser, updateAlbumInfoDto, albumId); } + @Authenticated({ isShared: true }) @Get('/:albumId/download') async downloadArchive( @GetAuthUser() authUser: AuthUserDto, @@ -139,4 +151,13 @@ export class AlbumController { res.setHeader(IMMICH_ARCHIVE_COMPLETE, `${complete}`); return stream; } + + @Authenticated() + @Post('/create-shared-link') + async createAlbumSharedLink( + @GetAuthUser() authUser: AuthUserDto, + @Body(ValidationPipe) createAlbumShareLinkDto: CreateAlbumSharedLinkDto, + ) { + return this.albumService.createAlbumSharedLink(authUser, createAlbumShareLinkDto); + } } diff --git a/server/apps/immich/src/api-v1/album/album.module.ts b/server/apps/immich/src/api-v1/album/album.module.ts index 45b56b86cc5b0..aa1077d3fe6cf 100644 --- a/server/apps/immich/src/api-v1/album/album.module.ts +++ b/server/apps/immich/src/api-v1/album/album.module.ts @@ -7,6 +7,7 @@ import { AlbumRepository, IAlbumRepository } from './album-repository'; import { DownloadModule } from '../../modules/download/download.module'; import { AssetModule } from '../asset/asset.module'; import { UserModule } from '../user/user.module'; +import { ShareModule } from '../share/share.module'; const ALBUM_REPOSITORY_PROVIDER = { provide: IAlbumRepository, @@ -19,6 +20,7 @@ const ALBUM_REPOSITORY_PROVIDER = { DownloadModule, UserModule, forwardRef(() => AssetModule), + ShareModule, ], controllers: [AlbumController], providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER], diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts index 2239284cc2cb7..61633d7c90f0b 100644 --- a/server/apps/immich/src/api-v1/album/album.service.spec.ts +++ b/server/apps/immich/src/api-v1/album/album.service.spec.ts @@ -3,15 +3,15 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common'; import { AlbumEntity } from '@app/database'; import { AlbumResponseDto } from './response-dto/album-response.dto'; -import { IAssetRepository } from '../asset/asset-repository'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { IAlbumRepository } from './album-repository'; import { DownloadService } from '../../modules/download/download.service'; +import { ISharedLinkRepository } from '../share/shared-link.repository'; describe('Album service', () => { let sut: AlbumService; let albumRepositoryMock: jest.Mocked; - let assetRepositoryMock: jest.Mocked; + let sharedLinkRepositoryMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; const authUser: AuthUserDto = Object.freeze({ @@ -33,7 +33,7 @@ describe('Album service', () => { albumEntity.sharedUsers = []; albumEntity.assets = []; albumEntity.albumThumbnailAssetId = null; - + albumEntity.sharedLinks = []; return albumEntity; }; @@ -94,6 +94,7 @@ describe('Album service', () => { }, }, ]; + albumEntity.sharedLinks = []; return albumEntity; }; @@ -113,6 +114,7 @@ describe('Album service', () => { beforeAll(() => { albumRepositoryMock = { + getPublicSharingList: jest.fn(), addAssets: jest.fn(), addSharedUsers: jest.fn(), create: jest.fn(), @@ -127,31 +129,20 @@ describe('Album service', () => { getSharedWithUserAlbumCount: jest.fn(), }; - assetRepositoryMock = { + sharedLinkRepositoryMock = { create: jest.fn(), - update: jest.fn(), - getAllByUserId: jest.fn(), - getAllByDeviceId: jest.fn(), - getAssetCountByTimeBucket: jest.fn(), + remove: jest.fn(), + get: jest.fn(), getById: jest.fn(), - getDetectedObjectsByUserId: jest.fn(), - getLocationsByUserId: jest.fn(), - getSearchPropertiesByUserId: jest.fn(), - getAssetByTimeBucket: jest.fn(), - getAssetByChecksum: jest.fn(), - getAssetCountByUserId: jest.fn(), - getAssetWithNoEXIF: jest.fn(), - getAssetWithNoThumbnail: jest.fn(), - getAssetWithNoSmartInfo: jest.fn(), - getExistingAssets: jest.fn(), - countByIdAndUser: jest.fn(), + getByKey: jest.fn(), + save: jest.fn(), }; downloadServiceMock = { downloadArchive: jest.fn(), }; - sut = new AlbumService(albumRepositoryMock, assetRepositoryMock, downloadServiceMock as DownloadService); + sut = new AlbumService(albumRepositoryMock, sharedLinkRepositoryMock, downloadServiceMock as DownloadService); }); it('creates album', async () => { @@ -175,10 +166,8 @@ describe('Album service', () => { albumRepositoryMock.getList.mockImplementation(() => Promise.resolve(albums)); const result = await sut.getAllAlbums(authUser, {}); - expect(result).toHaveLength(3); + expect(result).toHaveLength(1); expect(result[0].id).toEqual(ownedAlbum.id); - expect(result[1].id).toEqual(ownedSharedAlbum.id); - expect(result[2].id).toEqual(sharedWithMeAlbum.id); }); it('gets an owned album', async () => { diff --git a/server/apps/immich/src/api-v1/album/album.service.ts b/server/apps/immich/src/api-v1/album/album.service.ts index 941df5df022b7..ffb364637d864 100644 --- a/server/apps/immich/src/api-v1/album/album.service.ts +++ b/server/apps/immich/src/api-v1/album/album.service.ts @@ -1,7 +1,7 @@ -import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { BadRequestException, Inject, Injectable, NotFoundException, ForbiddenException, Logger } from '@nestjs/common'; import { AuthUserDto } from '../../decorators/auth-user.decorator'; import { CreateAlbumDto } from './dto/create-album.dto'; -import { AlbumEntity } from '@app/database'; +import { AlbumEntity, SharedLinkType } from '@app/database'; import { AddUsersDto } from './dto/add-users.dto'; import { RemoveAssetsDto } from './dto/remove-assets.dto'; import { UpdateAlbumDto } from './dto/update-album.dto'; @@ -9,19 +9,28 @@ import { GetAlbumsDto } from './dto/get-albums.dto'; import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto'; import { IAlbumRepository } from './album-repository'; import { AlbumCountResponseDto } from './response-dto/album-count-response.dto'; -import { IAssetRepository } from '../asset/asset-repository'; import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto'; import { AddAssetsDto } from './dto/add-assets.dto'; import { DownloadService } from '../../modules/download/download.service'; import { DownloadDto } from '../asset/dto/download-library.dto'; +import { ShareCore } from '../share/share.core'; +import { ISharedLinkRepository } from '../share/shared-link.repository'; +import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto'; +import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto'; +import _ from 'lodash'; @Injectable() export class AlbumService { + readonly logger = new Logger(AlbumService.name); + private shareCore: ShareCore; + constructor( @Inject(IAlbumRepository) private _albumRepository: IAlbumRepository, - @Inject(IAssetRepository) private _assetRepository: IAssetRepository, + @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, private downloadService: DownloadService, - ) {} + ) { + this.shareCore = new ShareCore(sharedLinkRepository); + } private async _getAlbum({ authUser, @@ -63,8 +72,14 @@ export class AlbumService { albums = await this._albumRepository.getListByAssetId(authUser.id, getAlbumsDto.assetId); } else { albums = await this._albumRepository.getList(authUser.id, getAlbumsDto); + if (getAlbumsDto.shared) { + const publicSharingAlbums = await this._albumRepository.getPublicSharingList(authUser.id); + albums = [...albums, ...publicSharingAlbums]; + } } + albums = _.uniqBy(albums, (album) => album.id); + for (const album of albums) { await this._checkValidThumbnail(album); } @@ -85,6 +100,11 @@ export class AlbumService { async deleteAlbum(authUser: AuthUserDto, albumId: string): Promise { const album = await this._getAlbum({ authUser, albumId }); + + for (const sharedLink of album.sharedLinks) { + await this.shareCore.removeSharedLink(sharedLink.id, authUser.id); + } + await this._albumRepository.delete(album); } @@ -125,6 +145,11 @@ export class AlbumService { addAssetsDto: AddAssetsDto, albumId: string, ): Promise { + if (authUser.isPublicUser && !authUser.isAllowUpload) { + this.logger.warn('Deny public user attempt to add asset to album'); + throw new ForbiddenException('Public user is not allowed to upload'); + } + const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); const result = await this._albumRepository.addAssets(album, addAssetsDto); const newAlbum = await this._getAlbum({ authUser, albumId, validateIsOwner: false }); @@ -174,4 +199,19 @@ export class AlbumService { album.albumThumbnailAssetId = dto.albumThumbnailAssetId || null; } } + + async createAlbumSharedLink(authUser: AuthUserDto, dto: CreateAlbumShareLinkDto): Promise { + const album = await this._getAlbum({ authUser, albumId: dto.albumId }); + + const sharedLink = await this.shareCore.createSharedLink(authUser.id, { + sharedType: SharedLinkType.ALBUM, + expiredAt: dto.expiredAt, + allowUpload: dto.allowUpload, + album: album, + assets: [], + description: dto.description, + }); + + return mapSharedLinkToResponseDto(sharedLink); + } } diff --git a/server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts b/server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts new file mode 100644 index 0000000000000..a0ab83c1c3c17 --- /dev/null +++ b/server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts @@ -0,0 +1,19 @@ +import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateAlbumShareLinkDto { + @IsString() + @IsNotEmpty() + albumId!: string; + + @IsString() + @IsOptional() + expiredAt?: string; + + @IsBoolean() + @IsOptional() + allowUpload?: boolean; + + @IsString() + @IsOptional() + description?: string; +} diff --git a/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts b/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts index 41279252d5169..cb71543a0ed6e 100644 --- a/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts +++ b/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts @@ -33,7 +33,7 @@ export function mapAlbum(entity: AlbumEntity): AlbumResponseDto { id: entity.id, ownerId: entity.ownerId, sharedUsers, - shared: sharedUsers.length > 0, + shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [], assetCount: entity.assets?.length || 0, }; @@ -55,7 +55,7 @@ export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto id: entity.id, ownerId: entity.ownerId, sharedUsers, - shared: sharedUsers.length > 0, + shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0, assets: [], assetCount: entity.assets?.length || 0, }; diff --git a/server/apps/immich/src/api-v1/asset/asset-repository.ts b/server/apps/immich/src/api-v1/asset/asset-repository.ts index ccc90760737ab..4efe35bb16c3d 100644 --- a/server/apps/immich/src/api-v1/asset/asset-repository.ts +++ b/server/apps/immich/src/api-v1/asset/asset-repository.ts @@ -226,7 +226,7 @@ export class AssetRepository implements IAssetRepository { where: { id: assetId, }, - relations: ['exifInfo', 'tags'], + relations: ['exifInfo', 'tags', 'sharedLinks'], }); } diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index f9a0c109ab582..a2f9a7f7f1505 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/server/apps/immich/src/api-v1/asset/asset.controller.ts @@ -49,14 +49,15 @@ import { IMMICH_ARCHIVE_FILE_COUNT, IMMICH_CONTENT_LENGTH_HINT, } from '../../constants/download.constant'; +import { DownloadFilesDto } from './dto/download-files.dto'; -@Authenticated() @ApiBearerAuth() @ApiTags('Asset') @Controller('asset') export class AssetController { constructor(private assetService: AssetService, private backgroundTaskService: BackgroundTaskService) {} + @Authenticated({ isShared: true }) @Post('upload') @UseInterceptors( FileFieldsInterceptor( @@ -84,6 +85,7 @@ export class AssetController { return this.assetService.handleUploadedAsset(authUser, createAssetDto, res, originalAssetData, livePhotoAssetData); } + @Authenticated({ isShared: true }) @Get('/download/:assetId') async downloadFile( @GetAuthUser() authUser: AuthUserDto, @@ -95,6 +97,23 @@ export class AssetController { return this.assetService.downloadFile(query, assetId, res); } + @Authenticated({ isShared: true }) + @Post('/download-files') + async downloadFiles( + @GetAuthUser() authUser: AuthUserDto, + @Response({ passthrough: true }) res: Res, + @Body(new ValidationPipe()) dto: DownloadFilesDto, + ): Promise { + await this.assetService.checkAssetsAccess(authUser, [...dto.assetIds]); + const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadFiles(dto); + res.attachment(fileName); + res.setHeader(IMMICH_CONTENT_LENGTH_HINT, fileSize); + res.setHeader(IMMICH_ARCHIVE_FILE_COUNT, fileCount); + res.setHeader(IMMICH_ARCHIVE_COMPLETE, `${complete}`); + return stream; + } + + @Authenticated({ isShared: true }) @Get('/download-library') async downloadLibrary( @GetAuthUser() authUser: AuthUserDto, @@ -109,6 +128,7 @@ export class AssetController { return stream; } + @Authenticated({ isShared: true }) @Get('/file/:assetId') @Header('Cache-Control', 'max-age=31536000') async serveFile( @@ -122,6 +142,7 @@ export class AssetController { return this.assetService.serveFile(assetId, query, res, headers); } + @Authenticated({ isShared: true }) @Get('/thumbnail/:assetId') @Header('Cache-Control', 'max-age=31536000') async getAssetThumbnail( @@ -135,21 +156,25 @@ export class AssetController { return this.assetService.getAssetThumbnail(assetId, query, res, headers); } + @Authenticated() @Get('/curated-objects') async getCuratedObjects(@GetAuthUser() authUser: AuthUserDto): Promise { return this.assetService.getCuratedObject(authUser); } + @Authenticated() @Get('/curated-locations') async getCuratedLocations(@GetAuthUser() authUser: AuthUserDto): Promise { return this.assetService.getCuratedLocation(authUser); } + @Authenticated() @Get('/search-terms') async getAssetSearchTerms(@GetAuthUser() authUser: AuthUserDto): Promise { return this.assetService.getAssetSearchTerm(authUser); } + @Authenticated() @Post('/search') async searchAsset( @GetAuthUser() authUser: AuthUserDto, @@ -158,6 +183,7 @@ export class AssetController { return this.assetService.searchAsset(authUser, searchAssetDto); } + @Authenticated() @Post('/count-by-time-bucket') async getAssetCountByTimeBucket( @GetAuthUser() authUser: AuthUserDto, @@ -166,6 +192,7 @@ export class AssetController { return this.assetService.getAssetCountByTimeBucket(authUser, getAssetCountByTimeGroupDto); } + @Authenticated() @Get('/count-by-user-id') async getAssetCountByUserId(@GetAuthUser() authUser: AuthUserDto): Promise { return this.assetService.getAssetCountByUserId(authUser); @@ -174,6 +201,7 @@ export class AssetController { /** * Get all AssetEntity belong to the user */ + @Authenticated() @Get('/') @ApiHeader({ name: 'if-none-match', @@ -186,6 +214,7 @@ export class AssetController { return assets; } + @Authenticated() @Post('/time-bucket') async getAssetByTimeBucket( @GetAuthUser() authUser: AuthUserDto, @@ -193,9 +222,11 @@ export class AssetController { ): Promise { return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto); } + /** * Get all asset of a device that are in the database, ID only. */ + @Authenticated() @Get('/:deviceId') async getUserAssetsByDeviceId(@GetAuthUser() authUser: AuthUserDto, @Param('deviceId') deviceId: string) { return await this.assetService.getUserAssetsByDeviceId(authUser, deviceId); @@ -204,6 +235,7 @@ export class AssetController { /** * Get a single asset's information */ + @Authenticated({ isShared: true }) @Get('/assetById/:assetId') async getAssetById( @GetAuthUser() authUser: AuthUserDto, @@ -216,6 +248,7 @@ export class AssetController { /** * Update an asset */ + @Authenticated() @Put('/:assetId') async updateAsset( @GetAuthUser() authUser: AuthUserDto, @@ -226,6 +259,7 @@ export class AssetController { return await this.assetService.updateAsset(authUser, assetId, dto); } + @Authenticated() @Delete('/') async deleteAsset( @GetAuthUser() authUser: AuthUserDto, @@ -265,6 +299,7 @@ export class AssetController { /** * Check duplicated asset before uploading - for Web upload used */ + @Authenticated({ isShared: true }) @Post('/check') @HttpCode(200) async checkDuplicateAsset( @@ -277,6 +312,7 @@ export class AssetController { /** * Checks if multiple assets exist on the server and returns all existing - used by background backup */ + @Authenticated() @Post('/exist') @HttpCode(200) async checkExistingAssets( diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index 6833d3e2e1072..f987d8fc2d6df 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/server/apps/immich/src/api-v1/asset/asset.module.ts @@ -14,6 +14,7 @@ import { AlbumModule } from '../album/album.module'; import { UserModule } from '../user/user.module'; import { StorageModule } from '@app/storage'; import { immichSharedQueues } from '@app/job/constants/bull-queue-registration.constant'; +import { ShareModule } from '../share/share.module'; const ASSET_REPOSITORY_PROVIDER = { provide: IAssetRepository, @@ -32,6 +33,7 @@ const ASSET_REPOSITORY_PROVIDER = { StorageModule, forwardRef(() => AlbumModule), BullModule.registerQueue(...immichSharedQueues), + ShareModule, ], controllers: [AssetController], providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER], diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index c709f53a94ce1..ce38fc84e5c04 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts @@ -13,6 +13,7 @@ import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job'; import { Queue } from 'bull'; import { IAlbumRepository } from '../album/album-repository'; import { StorageService } from '@app/storage'; +import { ISharedLinkRepository } from '../share/shared-link.repository'; describe('AssetService', () => { let sui: AssetService; @@ -24,6 +25,7 @@ describe('AssetService', () => { let assetUploadedQueueMock: jest.Mocked>; let videoConversionQueueMock: jest.Mocked>; let storageSeriveMock: jest.Mocked; + let sharedLinkRepositoryMock: jest.Mocked; const authUser: AuthUserDto = Object.freeze({ id: 'user_id_1', email: 'auth@test.com', @@ -128,12 +130,22 @@ describe('AssetService', () => { getAssetWithNoSmartInfo: jest.fn(), getExistingAssets: jest.fn(), countByIdAndUser: jest.fn(), + getSharePermission: jest.fn(), }; downloadServiceMock = { downloadArchive: jest.fn(), }; + sharedLinkRepositoryMock = { + create: jest.fn(), + get: jest.fn(), + getById: jest.fn(), + getByKey: jest.fn(), + remove: jest.fn(), + save: jest.fn(), + }; + sui = new AssetService( assetRepositoryMock, albumRepositoryMock, @@ -143,6 +155,7 @@ describe('AssetService', () => { videoConversionQueueMock, downloadServiceMock as DownloadService, storageSeriveMock, + sharedLinkRepositoryMock, ); }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 33334bd2d1a5a..ad07db7b1c335 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/server/apps/immich/src/api-v1/asset/asset.service.ts @@ -56,11 +56,17 @@ import { DownloadService } from '../../modules/download/download.service'; import { DownloadDto } from './dto/download-library.dto'; import { IAlbumRepository } from '../album/album-repository'; import { StorageService } from '@app/storage'; +import { ShareCore } from '../share/share.core'; +import { ISharedLinkRepository } from '../share/shared-link.repository'; +import { DownloadFilesDto } from './dto/download-files.dto'; const fileInfo = promisify(stat); @Injectable() export class AssetService { + readonly logger = new Logger(AssetService.name); + private shareCore: ShareCore; + constructor( @Inject(IAssetRepository) private _assetRepository: IAssetRepository, @@ -80,7 +86,10 @@ export class AssetService { private downloadService: DownloadService, private storageService: StorageService, - ) {} + @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository, + ) { + this.shareCore = new ShareCore(sharedLinkRepository); + } public async handleUploadedAsset( authUser: AuthUserDto, @@ -253,6 +262,24 @@ export class AssetService { return this.downloadService.downloadArchive(dto.name || `library`, assets); } + public async downloadFiles(dto: DownloadFilesDto) { + const assetToDownload = []; + + for (const assetId of dto.assetIds) { + const asset = await this._assetRepository.getById(assetId); + assetToDownload.push(asset); + + // Get live photo asset + if (asset.livePhotoVideoId) { + const livePhotoAsset = await this._assetRepository.getById(asset.livePhotoVideoId); + assetToDownload.push(livePhotoAsset); + } + } + + const now = new Date().toISOString(); + return this.downloadService.downloadArchive(`immich-${now}`, assetToDownload); + } + public async downloadFile(query: ServeFileDto, assetId: string, res: Res) { try { let fileReadStream = null; @@ -649,7 +676,15 @@ export class AssetService { async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) { for (const assetId of assetIds) { - // Step 1: Check if user owns asset + // Step 1: Check if asset is part of a public shared + if (authUser.sharedLinkId) { + const canAccess = await this.shareCore.hasAssetAccess(authUser.sharedLinkId, assetId); + if (!canAccess) { + throw new ForbiddenException(); + } + } + + // Step 2: Check if user owns asset if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) { continue; } @@ -660,8 +695,6 @@ export class AssetService { if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) { continue; } - - //TODO: Step 3: Check if asset is part of a public album } throw new ForbiddenException(); } diff --git a/server/apps/immich/src/api-v1/asset/dto/download-files.dto.ts b/server/apps/immich/src/api-v1/asset/dto/download-files.dto.ts new file mode 100644 index 0000000000000..557db73d57acb --- /dev/null +++ b/server/apps/immich/src/api-v1/asset/dto/download-files.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; + +export class DownloadFilesDto { + @IsNotEmpty() + @ApiProperty({ + isArray: true, + type: String, + title: 'Array of asset ids to be downloaded', + }) + assetIds!: string[]; +} diff --git a/server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts b/server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts new file mode 100644 index 0000000000000..388fa67e7d4ee --- /dev/null +++ b/server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts @@ -0,0 +1,11 @@ +import { AlbumEntity, AssetEntity } from '@app/database'; +import { SharedLinkType } from '@app/database/entities/shared-link.entity'; + +export class CreateSharedLinkDto { + description?: string; + expiredAt?: string; + sharedType!: SharedLinkType; + assets!: AssetEntity[]; + album?: AlbumEntity; + allowUpload?: boolean; +} diff --git a/server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts b/server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts new file mode 100644 index 0000000000000..fb9a794958de2 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsOptional } from 'class-validator'; + +export class EditSharedLinkDto { + @IsOptional() + description?: string; + + @IsOptional() + expiredAt?: string; + + @IsOptional() + allowUpload?: boolean; + + @IsNotEmpty() + isEditExpireTime?: boolean; +} diff --git a/server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts b/server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts new file mode 100644 index 0000000000000..a0490698e0799 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts @@ -0,0 +1,40 @@ +import { SharedLinkEntity, SharedLinkType } from '@app/database'; +import { ApiProperty } from '@nestjs/swagger'; +import _ from 'lodash'; +import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album/response-dto/album-response.dto'; +import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto'; + +export class SharedLinkResponseDto { + id!: string; + description?: string; + userId!: string; + key!: string; + + @ApiProperty({ enumName: 'SharedLinkType', enum: SharedLinkType }) + type!: SharedLinkType; + createdAt!: string; + expiresAt!: string | null; + assets!: AssetResponseDto[]; + album?: AlbumResponseDto; + allowUpload!: boolean; +} + +export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): SharedLinkResponseDto { + const linkAssets = sharedLink.assets || []; + const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo); + + const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id); + + return { + id: sharedLink.id, + description: sharedLink.description, + userId: sharedLink.userId, + key: sharedLink.key.toString('hex'), + type: sharedLink.type, + createdAt: sharedLink.createdAt, + expiresAt: sharedLink.expiresAt, + assets: assets.map(mapAsset), + album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined, + allowUpload: sharedLink.allowUpload, + }; +} diff --git a/server/apps/immich/src/api-v1/share/share.controller.ts b/server/apps/immich/src/api-v1/share/share.controller.ts new file mode 100644 index 0000000000000..705116cd13945 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/share.controller.ts @@ -0,0 +1,46 @@ +import { Body, Controller, Delete, Get, Param, Patch, ValidationPipe } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator'; +import { Authenticated } from '../../decorators/authenticated.decorator'; +import { EditSharedLinkDto } from './dto/edit-shared-link.dto'; +import { SharedLinkResponseDto } from './response-dto/shared-link-response.dto'; +import { ShareService } from './share.service'; + +@ApiTags('share') +@Controller('share') +export class ShareController { + constructor(private readonly shareService: ShareService) {} + @Authenticated() + @Get() + getAllSharedLinks(@GetAuthUser() authUser: AuthUserDto): Promise { + return this.shareService.getAll(authUser); + } + + @Authenticated({ isShared: true }) + @Get('me') + getMySharedLink(@GetAuthUser() authUser: AuthUserDto): Promise { + return this.shareService.getMine(authUser); + } + + @Authenticated() + @Get(':id') + getSharedLinkById(@Param('id') id: string): Promise { + return this.shareService.getById(id); + } + + @Authenticated() + @Delete(':id') + removeSharedLink(@Param('id') id: string, @GetAuthUser() authUser: AuthUserDto): Promise { + return this.shareService.remove(id, authUser.id); + } + + @Authenticated() + @Patch(':id') + editSharedLink( + @Param('id') id: string, + @GetAuthUser() authUser: AuthUserDto, + @Body(new ValidationPipe()) dto: EditSharedLinkDto, + ): Promise { + return this.shareService.edit(id, authUser, dto); + } +} diff --git a/server/apps/immich/src/api-v1/share/share.core.ts b/server/apps/immich/src/api-v1/share/share.core.ts new file mode 100644 index 0000000000000..c65008eb40a59 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/share.core.ts @@ -0,0 +1,99 @@ +import { SharedLinkEntity } from '@app/database/entities/shared-link.entity'; +import { CreateSharedLinkDto } from './dto/create-shared-link.dto'; +import { ISharedLinkRepository } from './shared-link.repository'; +import crypto from 'node:crypto'; +import { BadRequestException, InternalServerErrorException, Logger } from '@nestjs/common'; +import { AssetEntity } from '@app/database'; +import { EditSharedLinkDto } from './dto/edit-shared-link.dto'; + +export class ShareCore { + readonly logger = new Logger(ShareCore.name); + + constructor(private sharedLinkRepository: ISharedLinkRepository) {} + + async createSharedLink(userId: string, dto: CreateSharedLinkDto): Promise { + try { + const sharedLink = new SharedLinkEntity(); + + sharedLink.key = Buffer.from(crypto.randomBytes(50)); + sharedLink.description = dto.description; + sharedLink.userId = userId; + sharedLink.createdAt = new Date().toISOString(); + sharedLink.expiresAt = dto.expiredAt ?? null; + sharedLink.type = dto.sharedType; + sharedLink.assets = dto.assets; + sharedLink.album = dto.album; + sharedLink.allowUpload = dto.allowUpload ?? false; + + return this.sharedLinkRepository.create(sharedLink); + } catch (error: any) { + this.logger.error(error, error.stack); + throw new InternalServerErrorException('failed to create shared link'); + } + } + + async getSharedLinks(userId: string): Promise { + return this.sharedLinkRepository.get(userId); + } + + async removeSharedLink(id: string, userId: string): Promise { + const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId); + + if (!link) { + throw new BadRequestException('Shared link not found'); + } + + return await this.sharedLinkRepository.remove(link); + } + + async getSharedLinkById(id: string): Promise { + const link = await this.sharedLinkRepository.getById(id); + + if (!link) { + throw new BadRequestException('Shared link not found'); + } + + return link; + } + + async getSharedLinkByKey(key: string): Promise { + const link = await this.sharedLinkRepository.getByKey(key); + + if (!link) { + throw new BadRequestException(); + } + + return link; + } + + async updateAssetsInSharedLink(sharedLinkId: string, assets: AssetEntity[]) { + const link = await this.getSharedLinkById(sharedLinkId); + + link.assets = assets; + + return await this.sharedLinkRepository.save(link); + } + + async updateSharedLink(id: string, userId: string, dto: EditSharedLinkDto): Promise { + const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId); + + if (!link) { + throw new BadRequestException('Shared link not found'); + } + + link.description = dto.description ?? link.description; + link.allowUpload = dto.allowUpload ?? link.allowUpload; + + if (dto.isEditExpireTime && dto.expiredAt) { + link.expiresAt = dto.expiredAt; + } else if (dto.isEditExpireTime && !dto.expiredAt) { + link.expiresAt = null; + } + + return await this.sharedLinkRepository.save(link); + } + + async hasAssetAccess(id: string, assetId: string): Promise { + return this.sharedLinkRepository.hasAssetAccess(id, assetId); + } +} diff --git a/server/apps/immich/src/api-v1/share/share.module.ts b/server/apps/immich/src/api-v1/share/share.module.ts new file mode 100644 index 0000000000000..4b164de51bd26 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/share.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ShareService } from './share.service'; +import { ShareController } from './share.controller'; +import { SharedLinkEntity } from '@app/database/entities/shared-link.entity'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { SharedLinkRepository, ISharedLinkRepository } from './shared-link.repository'; + +const SHARED_LINK_REPOSITORY_PROVIDER = { + provide: ISharedLinkRepository, + useClass: SharedLinkRepository, +}; + +@Module({ + imports: [TypeOrmModule.forFeature([SharedLinkEntity])], + controllers: [ShareController], + providers: [ShareService, SHARED_LINK_REPOSITORY_PROVIDER], + exports: [SHARED_LINK_REPOSITORY_PROVIDER, ShareService], +}) +export class ShareModule {} diff --git a/server/apps/immich/src/api-v1/share/share.service.ts b/server/apps/immich/src/api-v1/share/share.service.ts new file mode 100644 index 0000000000000..c3c9e63b80ff3 --- /dev/null +++ b/server/apps/immich/src/api-v1/share/share.service.ts @@ -0,0 +1,54 @@ +import { ForbiddenException, Inject, Injectable, Logger } from '@nestjs/common'; +import { AuthUserDto } from '../../decorators/auth-user.decorator'; +import { EditSharedLinkDto } from './dto/edit-shared-link.dto'; +import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from './response-dto/shared-link-response.dto'; +import { ShareCore } from './share.core'; +import { ISharedLinkRepository } from './shared-link.repository'; + +@Injectable() +export class ShareService { + readonly logger = new Logger(ShareService.name); + private shareCore: ShareCore; + + constructor( + @Inject(ISharedLinkRepository) + sharedLinkRepository: ISharedLinkRepository, + ) { + this.shareCore = new ShareCore(sharedLinkRepository); + } + async getAll(authUser: AuthUserDto): Promise { + const links = await this.shareCore.getSharedLinks(authUser.id); + return links.map(mapSharedLinkToResponseDto); + } + + async getMine(authUser: AuthUserDto): Promise { + if (!authUser.isPublicUser || !authUser.sharedLinkId) { + throw new ForbiddenException(); + } + + const link = await this.shareCore.getSharedLinkById(authUser.sharedLinkId); + + return mapSharedLinkToResponseDto(link); + } + + async getById(id: string): Promise { + const link = await this.shareCore.getSharedLinkById(id); + return mapSharedLinkToResponseDto(link); + } + + async remove(id: string, userId: string): Promise { + await this.shareCore.removeSharedLink(id, userId); + return id; + } + + async getByKey(key: string): Promise { + const link = await this.shareCore.getSharedLinkByKey(key); + return mapSharedLinkToResponseDto(link); + } + + async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) { + const link = await this.shareCore.updateSharedLink(id, authUser.id, dto); + + return mapSharedLinkToResponseDto(link); + } +} diff --git a/server/apps/immich/src/api-v1/share/shared-link.repository.ts b/server/apps/immich/src/api-v1/share/shared-link.repository.ts new file mode 100644 index 0000000000000..c7335124cb5fe --- /dev/null +++ b/server/apps/immich/src/api-v1/share/shared-link.repository.ts @@ -0,0 +1,123 @@ +import { SharedLinkEntity } from '@app/database/entities/shared-link.entity'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Logger } from '@nestjs/common'; + +export interface ISharedLinkRepository { + get(userId: string): Promise; + getById(id: string): Promise; + getByIdAndUserId(id: string, userId: string): Promise; + getByKey(key: string): Promise; + create(payload: SharedLinkEntity): Promise; + remove(entity: SharedLinkEntity): Promise; + save(entity: SharedLinkEntity): Promise; + hasAssetAccess(id: string, assetId: string): Promise; +} + +export const ISharedLinkRepository = 'ISharedLinkRepository'; + +export class SharedLinkRepository implements ISharedLinkRepository { + readonly logger = new Logger(SharedLinkRepository.name); + constructor( + @InjectRepository(SharedLinkEntity) + private readonly sharedLinkRepository: Repository, + ) {} + async getByIdAndUserId(id: string, userId: string): Promise { + return await this.sharedLinkRepository.findOne({ + where: { + userId: userId, + id: id, + }, + order: { + createdAt: 'DESC', + }, + }); + } + + async get(userId: string): Promise { + return await this.sharedLinkRepository.find({ + where: { + userId: userId, + }, + relations: ['assets', 'album'], + order: { + createdAt: 'DESC', + }, + }); + } + + async create(payload: SharedLinkEntity): Promise { + return await this.sharedLinkRepository.save(payload); + } + + async getById(id: string): Promise { + return await this.sharedLinkRepository.findOne({ + where: { + id: id, + }, + relations: { + assets: true, + album: { + assets: { + assetInfo: true, + }, + }, + }, + order: { + createdAt: 'DESC', + }, + }); + } + + async getByKey(key: string): Promise { + return await this.sharedLinkRepository.findOne({ + where: { + key: Buffer.from(key, 'hex'), + }, + relations: { + assets: true, + album: { + assets: { + assetInfo: true, + }, + }, + }, + order: { + createdAt: 'DESC', + }, + }); + } + + async remove(entity: SharedLinkEntity): Promise { + return await this.sharedLinkRepository.remove(entity); + } + + async save(entity: SharedLinkEntity): Promise { + return await this.sharedLinkRepository.save(entity); + } + + async hasAssetAccess(id: string, assetId: string): Promise { + const count1 = await this.sharedLinkRepository.count({ + where: { + id, + assets: { + id: assetId, + }, + }, + }); + + const count2 = await this.sharedLinkRepository.count({ + where: { + id, + album: { + assets: { + assetId, + }, + }, + }, + }); + + return Boolean(count1 + count2); + } +} diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 0e3af7e1193ba..ced895fec501e 100644 --- a/server/apps/immich/src/app.module.ts +++ b/server/apps/immich/src/app.module.ts @@ -19,6 +19,7 @@ import { JobModule } from './api-v1/job/job.module'; import { SystemConfigModule } from './api-v1/system-config/system-config.module'; import { OAuthModule } from './api-v1/oauth/oauth.module'; import { TagModule } from './api-v1/tag/tag.module'; +import { ShareModule } from './api-v1/share/share.module'; import { APIKeyModule } from './api-v1/api-key/api-key.module'; @Module({ @@ -58,6 +59,8 @@ import { APIKeyModule } from './api-v1/api-key/api-key.module'; SystemConfigModule, TagModule, + + ShareModule, ], controllers: [AppController], providers: [], diff --git a/server/apps/immich/src/config/asset-upload.config.ts b/server/apps/immich/src/config/asset-upload.config.ts index 8d01d4fb0fd88..4aca54bb874ac 100644 --- a/server/apps/immich/src/config/asset-upload.config.ts +++ b/server/apps/immich/src/config/asset-upload.config.ts @@ -7,6 +7,7 @@ import { existsSync, mkdirSync } from 'fs'; import { diskStorage } from 'multer'; import { extname, join } from 'path'; import sanitize from 'sanitize-filename'; +import { AuthUserDto } from '../decorators/auth-user.decorator'; import { patchFormData } from '../utils/path-form-data.util'; const logger = new Logger('AssetUploadConfig'); @@ -42,6 +43,12 @@ function destination(req: Request, file: Express.Multer.File, cb: any) { return cb(new UnauthorizedException()); } + const user = req.user as AuthUserDto; + + if (user.isPublicUser && !user.isAllowUpload) { + return cb(new UnauthorizedException()); + } + const basePath = APP_UPLOAD_LOCATION; const sanitizedDeviceId = sanitize(String(req.body['deviceId'])); const originalUploadFolder = join(basePath, req.user.id, 'original', sanitizedDeviceId); diff --git a/server/apps/immich/src/decorators/auth-user.decorator.ts b/server/apps/immich/src/decorators/auth-user.decorator.ts index 023cab7ea17ad..3b5ab6d199ff4 100644 --- a/server/apps/immich/src/decorators/auth-user.decorator.ts +++ b/server/apps/immich/src/decorators/auth-user.decorator.ts @@ -1,23 +1,15 @@ import { createParamDecorator, ExecutionContext } from '@nestjs/common'; -import { UserEntity } from '@app/database'; // import { AuthUserDto } from './dto/auth-user.dto'; export class AuthUserDto { id!: string; email!: string; isAdmin!: boolean; + isPublicUser?: boolean; + sharedLinkId?: string; + isAllowUpload?: boolean; } export const GetAuthUser = createParamDecorator((data, ctx: ExecutionContext): AuthUserDto => { - const req = ctx.switchToHttp().getRequest<{ user: UserEntity }>(); - - const { id, email, isAdmin } = req.user; - - const authUser: AuthUserDto = { - id: id.toString(), - email, - isAdmin, - }; - - return authUser; + return ctx.switchToHttp().getRequest<{ user: AuthUserDto }>().user; }); diff --git a/server/apps/immich/src/decorators/authenticated.decorator.ts b/server/apps/immich/src/decorators/authenticated.decorator.ts index 6e3690e5fb4d7..4939ec5f20506 100644 --- a/server/apps/immich/src/decorators/authenticated.decorator.ts +++ b/server/apps/immich/src/decorators/authenticated.decorator.ts @@ -1,16 +1,25 @@ import { UseGuards } from '@nestjs/common'; import { AdminRolesGuard } from '../middlewares/admin-role-guard.middleware'; +import { RouteNotSharedGuard } from '../middlewares/route-not-shared-guard.middleware'; import { AuthGuard } from '../modules/immich-jwt/guards/auth.guard'; interface AuthenticatedOptions { admin?: boolean; + isShared?: boolean; } export const Authenticated = (options?: AuthenticatedOptions) => { const guards: Parameters = [AuthGuard]; + options = options || {}; + if (options.admin) { guards.push(AdminRolesGuard); } + + if (!options.isShared) { + guards.push(RouteNotSharedGuard); + } + return UseGuards(...guards); }; diff --git a/server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts b/server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts new file mode 100644 index 0000000000000..bb90c607d86aa --- /dev/null +++ b/server/apps/immich/src/middlewares/route-not-shared-guard.middleware.ts @@ -0,0 +1,21 @@ +import { CanActivate, ExecutionContext, Injectable, Logger } from '@nestjs/common'; +import { Request } from 'express'; +import { AuthUserDto } from '../decorators/auth-user.decorator'; + +@Injectable() +export class RouteNotSharedGuard implements CanActivate { + logger = new Logger(RouteNotSharedGuard.name); + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const user = request.user as AuthUserDto; + + // Inverse logic - I know it is weird + if (user.isPublicUser) { + this.logger.warn(`Denied attempt to access non-shared route: ${request.path}`); + return false; + } + + return true; + } +} diff --git a/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts b/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts index bea032615fcf5..6bb237725d532 100644 --- a/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts +++ b/server/apps/immich/src/modules/immich-jwt/guards/auth.guard.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { AuthGuard as PassportAuthGuard } from '@nestjs/passport'; import { API_KEY_STRATEGY } from '../strategies/api-key.strategy'; import { JWT_STRATEGY } from '../strategies/jwt.strategy'; +import { PUBLIC_SHARE_STRATEGY } from '../strategies/public-share.strategy'; @Injectable() -export class AuthGuard extends PassportAuthGuard([JWT_STRATEGY, API_KEY_STRATEGY]) {} +export class AuthGuard extends PassportAuthGuard([PUBLIC_SHARE_STRATEGY, JWT_STRATEGY, API_KEY_STRATEGY]) {} diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts index a7faf1def6384..c1d04f4c8630a 100644 --- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts +++ b/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts @@ -7,10 +7,12 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { UserEntity } from '@app/database'; import { APIKeyModule } from '../../api-v1/api-key/api-key.module'; import { APIKeyStrategy } from './strategies/api-key.strategy'; +import { ShareModule } from '../../api-v1/share/share.module'; +import { PublicShareStrategy } from './strategies/public-share.strategy'; @Module({ - imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity]), APIKeyModule], - providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy], + imports: [JwtModule.register(jwtConfig), TypeOrmModule.forFeature([UserEntity]), APIKeyModule, ShareModule], + providers: [ImmichJwtService, JwtStrategy, APIKeyStrategy, PublicShareStrategy], exports: [ImmichJwtService], }) export class ImmichJwtModule {} diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts index fb6a222327f71..bf4aa8f9e7d47 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/api-key.strategy.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; +import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator'; import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; import { APIKeyService } from '../../../api-v1/api-key/api-key.service'; @@ -15,7 +16,16 @@ export class APIKeyStrategy extends PassportStrategy(Strategy, API_KEY_STRATEGY) super(options); } - async validate(token: string) { - return this.apiKeyService.validate(token); + async validate(token: string): Promise { + const user = await this.apiKeyService.validate(token); + + const authUser = new AuthUserDto(); + authUser.id = user.id; + authUser.email = user.email; + authUser.isAdmin = user.isAdmin; + authUser.isPublicUser = false; + authUser.isAllowUpload = true; + + return authUser; } } diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts index 58720174a62de..916e718e2c5bb 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts +++ b/server/apps/immich/src/modules/immich-jwt/strategies/jwt.strategy.ts @@ -7,6 +7,7 @@ import { JwtPayloadDto } from '../../../api-v1/auth/dto/jwt-payload.dto'; import { UserEntity } from '@app/database'; import { jwtSecret } from '../../../constants/jwt.constant'; import { ImmichJwtService } from '../immich-jwt.service'; +import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator'; export const JWT_STRATEGY = 'jwt'; @@ -27,7 +28,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { } as StrategyOptions); } - async validate(payload: JwtPayloadDto) { + async validate(payload: JwtPayloadDto): Promise { const { userId } = payload; const user = await this.usersRepository.findOne({ where: { id: userId } }); @@ -35,6 +36,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, JWT_STRATEGY) { throw new UnauthorizedException('Failure to validate JWT payload'); } - return user; + const authUser = new AuthUserDto(); + authUser.id = user.id; + authUser.email = user.email; + authUser.isAdmin = user.isAdmin; + authUser.isPublicUser = false; + authUser.isAllowUpload = true; + + return authUser; } } diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts new file mode 100644 index 0000000000000..41393e294da1c --- /dev/null +++ b/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts @@ -0,0 +1,53 @@ +import { UserEntity } from '@app/database'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { InjectRepository } from '@nestjs/typeorm'; +import { ShareService } from '../../../api-v1/share/share.service'; +import { IStrategyOptions, Strategy } from 'passport-http-header-strategy'; +import { Repository } from 'typeorm'; +import { AuthUserDto } from 'apps/immich/src/decorators/auth-user.decorator'; + +export const PUBLIC_SHARE_STRATEGY = 'public-share'; + +const options: IStrategyOptions = { + header: 'x-immich-share-key', + param: 'key', +}; + +@Injectable() +export class PublicShareStrategy extends PassportStrategy(Strategy, PUBLIC_SHARE_STRATEGY) { + constructor( + private shareService: ShareService, + @InjectRepository(UserEntity) + private usersRepository: Repository, + ) { + super(options); + } + + async validate(key: string): Promise { + const validatedLink = await this.shareService.getByKey(key); + + if (validatedLink.expiresAt) { + const now = new Date().getTime(); + const expiresAt = new Date(validatedLink.expiresAt).getTime(); + + if (now > expiresAt) { + throw new UnauthorizedException('Expired link'); + } + } + + const user = await this.usersRepository.findOne({ where: { id: validatedLink.userId } }); + + if (!user) { + throw new UnauthorizedException('Failure to validate public share payload'); + } + + let publicUser = new AuthUserDto(); + publicUser = user; + publicUser.isPublicUser = true; + publicUser.sharedLinkId = validatedLink.id; + publicUser.isAllowUpload = validatedLink.allowUpload; + + return publicUser; + } +} diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 203929f3af4fe..92fad332a3002 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -473,6 +473,147 @@ ] } }, + "/share": { + "get": { + "operationId": "getAllSharedLinks", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, + "/share/me": { + "get": { + "operationId": "getMySharedLink", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, + "/share/{id}": { + "get": { + "operationId": "getSharedLinkById", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + }, + "delete": { + "operationId": "removeSharedLink", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + }, + "tags": [ + "share" + ] + }, + "patch": { + "operationId": "editSharedLink", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditSharedLinkDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, "/asset/upload": { "post": { "operationId": "uploadFile", @@ -563,6 +704,42 @@ ] } }, + "/asset/download-files": { + "post": { + "operationId": "downloadFiles", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DownloadFilesDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + }, + "tags": [ + "Asset" + ], + "security": [ + { + "bearer": [] + } + ] + } + }, "/asset/download-library": { "get": { "operationId": "downloadLibrary", @@ -1616,6 +1793,42 @@ ] } }, + "/album/create-shared-link": { + "post": { + "operationId": "createAlbumSharedLink", + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateAlbumShareLinkDto" + } + } + } + }, + "responses": { + "201": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "Album" + ], + "security": [ + { + "bearer": [] + } + ] + } + }, "/tag": { "post": { "operationId": "create", @@ -2666,99 +2879,11 @@ "name" ] }, - "AssetFileUploadDto": { - "type": "object", - "properties": { - "assetData": { - "type": "string", - "format": "binary" - } - }, - "required": [ - "assetData" - ] - }, - "AssetFileUploadResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": [ - "id" - ] - }, - "ThumbnailFormat": { + "SharedLinkType": { "type": "string", "enum": [ - "JPEG", - "WEBP" - ] - }, - "CuratedObjectsResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "object": { - "type": "string" - }, - "resizePath": { - "type": "string" - }, - "deviceAssetId": { - "type": "string" - }, - "deviceId": { - "type": "string" - } - }, - "required": [ - "id", - "object", - "resizePath", - "deviceAssetId", - "deviceId" - ] - }, - "CuratedLocationsResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "city": { - "type": "string" - }, - "resizePath": { - "type": "string" - }, - "deviceAssetId": { - "type": "string" - }, - "deviceId": { - "type": "string" - } - }, - "required": [ - "id", - "city", - "resizePath", - "deviceAssetId", - "deviceId" - ] - }, - "SearchAssetDto": { - "type": "object", - "properties": { - "searchTerm": { - "type": "string" - } - }, - "required": [ - "searchTerm" + "ALBUM", + "INDIVIDUAL" ] }, "AssetTypeEnum": { @@ -3019,6 +3144,232 @@ "tags" ] }, + "AlbumResponseDto": { + "type": "object", + "properties": { + "assetCount": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "albumName": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "albumThumbnailAssetId": { + "type": "string", + "nullable": true + }, + "shared": { + "type": "boolean" + }, + "sharedUsers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserResponseDto" + } + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + } + }, + "required": [ + "assetCount", + "id", + "ownerId", + "albumName", + "createdAt", + "albumThumbnailAssetId", + "shared", + "sharedUsers", + "assets" + ] + }, + "SharedLinkResponseDto": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/SharedLinkType" + }, + "id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "nullable": true + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + }, + "album": { + "$ref": "#/components/schemas/AlbumResponseDto" + }, + "allowUpload": { + "type": "boolean" + } + }, + "required": [ + "type", + "id", + "userId", + "key", + "createdAt", + "expiresAt", + "assets", + "allowUpload" + ] + }, + "EditSharedLinkDto": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "expiredAt": { + "type": "string" + }, + "allowUpload": { + "type": "boolean" + }, + "isEditExpireTime": { + "type": "boolean" + } + } + }, + "AssetFileUploadDto": { + "type": "object", + "properties": { + "assetData": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "assetData" + ] + }, + "AssetFileUploadResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "DownloadFilesDto": { + "type": "object", + "properties": { + "assetIds": { + "title": "Array of asset ids to be downloaded", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "assetIds" + ] + }, + "ThumbnailFormat": { + "type": "string", + "enum": [ + "JPEG", + "WEBP" + ] + }, + "CuratedObjectsResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "object": { + "type": "string" + }, + "resizePath": { + "type": "string" + }, + "deviceAssetId": { + "type": "string" + }, + "deviceId": { + "type": "string" + } + }, + "required": [ + "id", + "object", + "resizePath", + "deviceAssetId", + "deviceId" + ] + }, + "CuratedLocationsResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "city": { + "type": "string" + }, + "resizePath": { + "type": "string" + }, + "deviceAssetId": { + "type": "string" + }, + "deviceId": { + "type": "string" + } + }, + "required": [ + "id", + "city", + "resizePath", + "deviceAssetId", + "deviceId" + ] + }, + "SearchAssetDto": { + "type": "object", + "properties": { + "searchTerm": { + "type": "string" + } + }, + "required": [ + "searchTerm" + ] + }, "TimeGroupEnum": { "type": "string", "enum": [ @@ -3287,56 +3638,6 @@ "albumName" ] }, - "AlbumResponseDto": { - "type": "object", - "properties": { - "assetCount": { - "type": "integer" - }, - "id": { - "type": "string" - }, - "ownerId": { - "type": "string" - }, - "albumName": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "albumThumbnailAssetId": { - "type": "string", - "nullable": true - }, - "shared": { - "type": "boolean" - }, - "sharedUsers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UserResponseDto" - } - }, - "assets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - } - } - }, - "required": [ - "assetCount", - "id", - "ownerId", - "albumName", - "createdAt", - "albumThumbnailAssetId", - "shared", - "sharedUsers", - "assets" - ] - }, "AddUsersDto": { "type": "object", "properties": { @@ -3411,6 +3712,26 @@ } } }, + "CreateAlbumShareLinkDto": { + "type": "object", + "properties": { + "albumId": { + "type": "string" + }, + "expiredAt": { + "type": "string" + }, + "allowUpload": { + "type": "boolean" + }, + "description": { + "type": "string" + } + }, + "required": [ + "albumId" + ] + }, "CreateTagDto": { "type": "object", "properties": { diff --git a/server/libs/database/src/entities/album.entity.ts b/server/libs/database/src/entities/album.entity.ts index e113f269a0460..53c06a53fc6ca 100644 --- a/server/libs/database/src/entities/album.entity.ts +++ b/server/libs/database/src/entities/album.entity.ts @@ -1,5 +1,6 @@ import { Column, CreateDateColumn, Entity, OneToMany, PrimaryGeneratedColumn } from 'typeorm'; import { AssetAlbumEntity } from './asset-album.entity'; +import { SharedLinkEntity } from './shared-link.entity'; import { UserAlbumEntity } from './user-album.entity'; @Entity('albums') @@ -24,4 +25,7 @@ export class AlbumEntity { @OneToMany(() => AssetAlbumEntity, (assetAlbumEntity) => assetAlbumEntity.albumInfo) assets?: AssetAlbumEntity[]; + + @OneToMany(() => SharedLinkEntity, (link) => link.album) + sharedLinks!: SharedLinkEntity[]; } diff --git a/server/libs/database/src/entities/asset.entity.ts b/server/libs/database/src/entities/asset.entity.ts index 6ff49747ef5fd..9fcb8be5eddcd 100644 --- a/server/libs/database/src/entities/asset.entity.ts +++ b/server/libs/database/src/entities/asset.entity.ts @@ -1,5 +1,6 @@ import { Column, Entity, Index, JoinTable, ManyToMany, OneToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; import { ExifEntity } from './exif.entity'; +import { SharedLinkEntity } from './shared-link.entity'; import { SmartInfoEntity } from './smart-info.entity'; import { TagEntity } from './tag.entity'; @@ -68,6 +69,10 @@ export class AssetEntity { @ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true }) @JoinTable({ name: 'tag_asset' }) tags!: TagEntity[]; + + @ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true }) + @JoinTable({ name: 'shared_link__asset' }) + sharedLinks!: SharedLinkEntity[]; } export enum AssetType { diff --git a/server/libs/database/src/entities/index.ts b/server/libs/database/src/entities/index.ts index f5edae663b45e..81073d4ce1ce0 100644 --- a/server/libs/database/src/entities/index.ts +++ b/server/libs/database/src/entities/index.ts @@ -9,3 +9,4 @@ export * from './system-config.entity'; export * from './tag.entity'; export * from './user-album.entity'; export * from './user.entity'; +export * from './shared-link.entity'; diff --git a/server/libs/database/src/entities/shared-link.entity.ts b/server/libs/database/src/entities/shared-link.entity.ts new file mode 100644 index 0000000000000..f096e361ea1e1 --- /dev/null +++ b/server/libs/database/src/entities/shared-link.entity.ts @@ -0,0 +1,50 @@ +import { Column, Entity, Index, ManyToMany, ManyToOne, PrimaryGeneratedColumn, Unique } from 'typeorm'; +import { AlbumEntity } from './album.entity'; +import { AssetEntity } from './asset.entity'; + +@Entity('shared_links') +@Unique('UQ_sharedlink_key', ['key']) +export class SharedLinkEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column({ nullable: true }) + description?: string; + + @Column() + userId!: string; + + @Index('IDX_sharedlink_key') + @Column({ type: 'bytea' }) + key!: Buffer; // use to access the inidividual asset + + @Column() + type!: SharedLinkType; + + @Column({ type: 'timestamptz' }) + createdAt!: string; + + @Column({ type: 'timestamptz', nullable: true }) + expiresAt!: string | null; + + @Column({ type: 'boolean', default: false }) + allowUpload!: boolean; + + @ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks) + assets!: AssetEntity[]; + + @ManyToOne(() => AlbumEntity, (album) => album.sharedLinks) + album?: AlbumEntity; +} + +export enum SharedLinkType { + ALBUM = 'ALBUM', + + /** + * Individual asset + * or group of assets that are not in an album + */ + INDIVIDUAL = 'INDIVIDUAL', +} + +// npm run typeorm -- migration:generate ./libs/database/src/AddSharedLinkTable -d libs/database/src/config/database.config.ts diff --git a/server/libs/database/src/migrations/1673150490490-AddSharedLinkTable.ts b/server/libs/database/src/migrations/1673150490490-AddSharedLinkTable.ts new file mode 100644 index 0000000000000..a7508722d2c33 --- /dev/null +++ b/server/libs/database/src/migrations/1673150490490-AddSharedLinkTable.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSharedLinkTable1673150490490 implements MigrationInterface { + name = 'AddSharedLinkTable1673150490490' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "shared_links" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "description" character varying, "userId" character varying NOT NULL, "key" bytea NOT NULL, "type" character varying NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, "allowUpload" boolean NOT NULL DEFAULT false, "albumId" uuid, CONSTRAINT "UQ_sharedlink_key" UNIQUE ("key"), CONSTRAINT "PK_642e2b0f619e4876e5f90a43465" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_sharedlink_key" ON "shared_links" ("key") `); + await queryRunner.query(`CREATE TABLE "shared_link__asset" ("assetsId" uuid NOT NULL, "sharedLinksId" uuid NOT NULL, CONSTRAINT "PK_9b4f3687f9b31d1e311336b05e3" PRIMARY KEY ("assetsId", "sharedLinksId"))`); + await queryRunner.query(`CREATE INDEX "IDX_5b7decce6c8d3db9593d6111a6" ON "shared_link__asset" ("assetsId") `); + await queryRunner.query(`CREATE INDEX "IDX_c9fab4aa97ffd1b034f3d6581a" ON "shared_link__asset" ("sharedLinksId") `); + await queryRunner.query(`ALTER TABLE "shared_links" ADD CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66" FOREIGN KEY ("albumId") REFERENCES "albums"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66" FOREIGN KEY ("assetsId") REFERENCES "assets"("id") ON DELETE CASCADE ON UPDATE CASCADE`); + await queryRunner.query(`ALTER TABLE "shared_link__asset" ADD CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab" FOREIGN KEY ("sharedLinksId") REFERENCES "shared_links"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_c9fab4aa97ffd1b034f3d6581ab"`); + await queryRunner.query(`ALTER TABLE "shared_link__asset" DROP CONSTRAINT "FK_5b7decce6c8d3db9593d6111a66"`); + await queryRunner.query(`ALTER TABLE "shared_links" DROP CONSTRAINT "FK_0c6ce9058c29f07cdf7014eac66"`); + await queryRunner.query(`DROP INDEX "public"."IDX_c9fab4aa97ffd1b034f3d6581a"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5b7decce6c8d3db9593d6111a6"`); + await queryRunner.query(`DROP TABLE "shared_link__asset"`); + await queryRunner.query(`DROP INDEX "public"."IDX_sharedlink_key"`); + await queryRunner.query(`DROP TABLE "shared_links"`); + } + +} diff --git a/server/package-lock.json b/server/package-lock.json index 5dba867bb18c4..492b4a1f750b1 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -47,6 +47,7 @@ "nest-commander": "^3.3.0", "openid-client": "^5.2.1", "passport": "^0.6.0", + "passport-custom": "^1.1.1", "passport-http-header-strategy": "^1.1.0", "passport-jwt": "^4.0.0", "pg": "^8.7.1", @@ -8619,6 +8620,17 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-custom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", + "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", + "dependencies": { + "passport-strategy": "1.x.x" + }, + "engines": { + "node": ">= 0.10.0" + } + }, "node_modules/passport-http-header-strategy": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz", @@ -17927,6 +17939,14 @@ "utils-merge": "^1.0.1" } }, + "passport-custom": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/passport-custom/-/passport-custom-1.1.1.tgz", + "integrity": "sha512-/2m7jUGxmCYvoqenLB9UrmkCgPt64h8ZtV+UtuQklZ/Tn1NpKBeOorCYkB/8lMRoiZ5hUrCoMmDtxCS/d38mlg==", + "requires": { + "passport-strategy": "1.x.x" + } + }, "passport-http-header-strategy": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/passport-http-header-strategy/-/passport-http-header-strategy-1.1.0.tgz", diff --git a/server/package.json b/server/package.json index 1cf4f4f7b2919..1229f23c5a2f3 100644 --- a/server/package.json +++ b/server/package.json @@ -70,6 +70,7 @@ "nest-commander": "^3.3.0", "openid-client": "^5.2.1", "passport": "^0.6.0", + "passport-custom": "^1.1.1", "passport-http-header-strategy": "^1.1.0", "passport-jwt": "^4.0.0", "pg": "^8.7.1", diff --git a/web/src/api/api.ts b/web/src/api/api.ts index 061bc3ffdf461..ed21ab7570e58 100644 --- a/web/src/api/api.ts +++ b/web/src/api/api.ts @@ -9,6 +9,7 @@ import { JobApi, OAuthApi, ServerInfoApi, + ShareApi, SystemConfigApi, UserApi } from './open-api'; @@ -24,6 +25,7 @@ class ImmichApi { public jobApi: JobApi; public keyApi: APIKeyApi; public systemConfigApi: SystemConfigApi; + public shareApi: ShareApi; private config = new Configuration({ basePath: '/api' }); @@ -38,6 +40,7 @@ class ImmichApi { this.jobApi = new JobApi(this.config); this.keyApi = new APIKeyApi(this.config); this.systemConfigApi = new SystemConfigApi(this.config); + this.shareApi = new ShareApi(this.config); } public setAccessToken(accessToken: string) { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 7bfcdfe8b3bff..72e819edf0533 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -671,6 +671,37 @@ export interface CreateAlbumDto { */ 'assetIds'?: Array; } +/** + * + * @export + * @interface CreateAlbumShareLinkDto + */ +export interface CreateAlbumShareLinkDto { + /** + * + * @type {string} + * @memberof CreateAlbumShareLinkDto + */ + 'albumId': string; + /** + * + * @type {string} + * @memberof CreateAlbumShareLinkDto + */ + 'expiredAt'?: string; + /** + * + * @type {boolean} + * @memberof CreateAlbumShareLinkDto + */ + 'allowUpload'?: boolean; + /** + * + * @type {string} + * @memberof CreateAlbumShareLinkDto + */ + 'description'?: string; +} /** * * @export @@ -918,6 +949,50 @@ export const DeviceTypeEnum = { export type DeviceTypeEnum = typeof DeviceTypeEnum[keyof typeof DeviceTypeEnum]; +/** + * + * @export + * @interface DownloadFilesDto + */ +export interface DownloadFilesDto { + /** + * + * @type {Array} + * @memberof DownloadFilesDto + */ + 'assetIds': Array; +} +/** + * + * @export + * @interface EditSharedLinkDto + */ +export interface EditSharedLinkDto { + /** + * + * @type {string} + * @memberof EditSharedLinkDto + */ + 'description'?: string; + /** + * + * @type {string} + * @memberof EditSharedLinkDto + */ + 'expiredAt'?: string; + /** + * + * @type {boolean} + * @memberof EditSharedLinkDto + */ + 'allowUpload'?: boolean; + /** + * + * @type {boolean} + * @memberof EditSharedLinkDto + */ + 'isEditExpireTime'?: boolean; +} /** * * @export @@ -1477,6 +1552,87 @@ export interface ServerVersionReponseDto { */ 'build': number; } +/** + * + * @export + * @interface SharedLinkResponseDto + */ +export interface SharedLinkResponseDto { + /** + * + * @type {SharedLinkType} + * @memberof SharedLinkResponseDto + */ + 'type': SharedLinkType; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'id': string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'description'?: string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'userId': string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'key': string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'createdAt': string; + /** + * + * @type {string} + * @memberof SharedLinkResponseDto + */ + 'expiresAt': string | null; + /** + * + * @type {Array} + * @memberof SharedLinkResponseDto + */ + 'assets': Array; + /** + * + * @type {AlbumResponseDto} + * @memberof SharedLinkResponseDto + */ + 'album'?: AlbumResponseDto; + /** + * + * @type {boolean} + * @memberof SharedLinkResponseDto + */ + 'allowUpload': boolean; +} +/** + * + * @export + * @enum {string} + */ + +export const SharedLinkType = { + Album: 'ALBUM', + Individual: 'INDIVIDUAL' +} as const; + +export type SharedLinkType = typeof SharedLinkType[keyof typeof SharedLinkType]; + + /** * * @export @@ -2554,6 +2710,45 @@ export const AlbumApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createAlbumSharedLink: async (createAlbumShareLinkDto: CreateAlbumShareLinkDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'createAlbumShareLinkDto' is not null or undefined + assertParamExists('createAlbumSharedLink', 'createAlbumShareLinkDto', createAlbumShareLinkDto) + const localVarPath = `/album/create-shared-link`; + // 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 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(createAlbumShareLinkDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {string} albumId @@ -2915,6 +3110,16 @@ export const AlbumApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.createAlbum(createAlbumDto, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createAlbumSharedLink(createAlbumShareLinkDto: CreateAlbumShareLinkDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.createAlbumSharedLink(createAlbumShareLinkDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {string} albumId @@ -3038,6 +3243,15 @@ export const AlbumApiFactory = function (configuration?: Configuration, basePath createAlbum(createAlbumDto: CreateAlbumDto, options?: any): AxiosPromise { return localVarFp.createAlbum(createAlbumDto, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createAlbumSharedLink(createAlbumShareLinkDto: CreateAlbumShareLinkDto, options?: any): AxiosPromise { + return localVarFp.createAlbumSharedLink(createAlbumShareLinkDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {string} albumId @@ -3159,6 +3373,17 @@ export class AlbumApi extends BaseAPI { return AlbumApiFp(this.configuration).createAlbum(createAlbumDto, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {CreateAlbumShareLinkDto} createAlbumShareLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AlbumApi + */ + public createAlbumSharedLink(createAlbumShareLinkDto: CreateAlbumShareLinkDto, options?: AxiosRequestConfig) { + return AlbumApiFp(this.configuration).createAlbumSharedLink(createAlbumShareLinkDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {string} albumId @@ -3423,6 +3648,45 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration options: localVarRequestOptions, }; }, + /** + * + * @param {DownloadFilesDto} downloadFilesDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadFiles: async (downloadFilesDto: DownloadFilesDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'downloadFilesDto' is not null or undefined + assertParamExists('downloadFiles', 'downloadFilesDto', downloadFilesDto) + const localVarPath = `/asset/download-files`; + // 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 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(downloadFilesDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {number} [skip] @@ -4050,6 +4314,16 @@ export const AssetApiFp = function(configuration?: Configuration) { const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFile(assetId, isThumb, isWeb, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, + /** + * + * @param {DownloadFilesDto} downloadFilesDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async downloadFiles(downloadFilesDto: DownloadFilesDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.downloadFiles(downloadFilesDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, /** * * @param {number} [skip] @@ -4248,6 +4522,15 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath downloadFile(assetId: string, isThumb?: boolean, isWeb?: boolean, options?: any): AxiosPromise { return localVarFp.downloadFile(assetId, isThumb, isWeb, options).then((request) => request(axios, basePath)); }, + /** + * + * @param {DownloadFilesDto} downloadFilesDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + downloadFiles(downloadFilesDto: DownloadFilesDto, options?: any): AxiosPromise { + return localVarFp.downloadFiles(downloadFilesDto, options).then((request) => request(axios, basePath)); + }, /** * * @param {number} [skip] @@ -4439,6 +4722,17 @@ export class AssetApi extends BaseAPI { return AssetApiFp(this.configuration).downloadFile(assetId, isThumb, isWeb, options).then((request) => request(this.axios, this.basePath)); } + /** + * + * @param {DownloadFilesDto} downloadFilesDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof AssetApi + */ + public downloadFiles(downloadFilesDto: DownloadFilesDto, options?: AxiosRequestConfig) { + return AssetApiFp(this.configuration).downloadFiles(downloadFilesDto, options).then((request) => request(this.axios, this.basePath)); + } + /** * * @param {number} [skip] @@ -6052,6 +6346,354 @@ export class ServerInfoApi extends BaseAPI { } +/** + * ShareApi - axios parameter creator + * @export + */ +export const ShareApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * + * @param {string} id + * @param {EditSharedLinkDto} editSharedLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editSharedLink: async (id: string, editSharedLinkDto: EditSharedLinkDto, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('editSharedLink', 'id', id) + // verify required parameter 'editSharedLinkDto' is not null or undefined + assertParamExists('editSharedLink', 'editSharedLinkDto', editSharedLinkDto) + const localVarPath = `/share/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // 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: 'PATCH', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(editSharedLinkDto, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllSharedLinks: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/share`; + // 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: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getMySharedLink: async (options: AxiosRequestConfig = {}): Promise => { + const localVarPath = `/share/me`; + // 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: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSharedLinkById: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('getSharedLinkById', 'id', id) + const localVarPath = `/share/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // 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: 'GET', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeSharedLink: async (id: string, options: AxiosRequestConfig = {}): Promise => { + // verify required parameter 'id' is not null or undefined + assertParamExists('removeSharedLink', 'id', id) + const localVarPath = `/share/{id}` + .replace(`{${"id"}}`, encodeURIComponent(String(id))); + // 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: 'DELETE', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * ShareApi - functional programming interface + * @export + */ +export const ShareApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = ShareApiAxiosParamCreator(configuration) + return { + /** + * + * @param {string} id + * @param {EditSharedLinkDto} editSharedLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async editSharedLink(id: string, editSharedLinkDto: EditSharedLinkDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.editSharedLink(id, editSharedLinkDto, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getAllSharedLinks(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getAllSharedLinks(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getMySharedLink(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getSharedLinkById(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getSharedLinkById(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async removeSharedLink(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.removeSharedLink(id, options); + return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); + }, + } +}; + +/** + * ShareApi - factory interface + * @export + */ +export const ShareApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = ShareApiFp(configuration) + return { + /** + * + * @param {string} id + * @param {EditSharedLinkDto} editSharedLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + editSharedLink(id: string, editSharedLinkDto: EditSharedLinkDto, options?: any): AxiosPromise { + return localVarFp.editSharedLink(id, editSharedLinkDto, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getAllSharedLinks(options?: any): AxiosPromise> { + return localVarFp.getAllSharedLinks(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getMySharedLink(options?: any): AxiosPromise { + return localVarFp.getMySharedLink(options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getSharedLinkById(id: string, options?: any): AxiosPromise { + return localVarFp.getSharedLinkById(id, options).then((request) => request(axios, basePath)); + }, + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + removeSharedLink(id: string, options?: any): AxiosPromise { + return localVarFp.removeSharedLink(id, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * ShareApi - object-oriented interface + * @export + * @class ShareApi + * @extends {BaseAPI} + */ +export class ShareApi extends BaseAPI { + /** + * + * @param {string} id + * @param {EditSharedLinkDto} editSharedLinkDto + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public editSharedLink(id: string, editSharedLinkDto: EditSharedLinkDto, options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).editSharedLink(id, editSharedLinkDto, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public getAllSharedLinks(options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).getAllSharedLinks(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public getMySharedLink(options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).getMySharedLink(options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public getSharedLinkById(id: string, options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).getSharedLinkById(id, options).then((request) => request(this.axios, this.basePath)); + } + + /** + * + * @param {string} id + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ShareApi + */ + public removeSharedLink(id: string, options?: AxiosRequestConfig) { + return ShareApiFp(this.configuration).removeSharedLink(id, options).then((request) => request(this.axios, this.basePath)); + } +} + + /** * SystemConfigApi - axios parameter creator * @export diff --git a/web/src/api/utils.ts b/web/src/api/utils.ts index d2381c9e924a4..cddada7c866ea 100644 --- a/web/src/api/utils.ts +++ b/web/src/api/utils.ts @@ -4,13 +4,14 @@ import { UserResponseDto } from './open-api'; const _basePath = '/api'; -export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean) { +export function getFileUrl(assetId: string, isThumb?: boolean, isWeb?: boolean, key?: string) { const urlObj = new URL(`${window.location.origin}${_basePath}/asset/file/${assetId}`); if (isThumb !== undefined && isThumb !== null) urlObj.searchParams.append('isThumb', `${isThumb}`); if (isWeb !== undefined && isWeb !== null) urlObj.searchParams.append('isWeb', `${isWeb}`); + if (key !== undefined && key !== null) urlObj.searchParams.append('key', key); return urlObj.href; } diff --git a/web/src/app.html b/web/src/app.html index 58e88d2ff557b..b5f19dfba539c 100644 --- a/web/src/app.html +++ b/web/src/app.html @@ -1,15 +1,13 @@ + + + + + %sveltekit.head% + - - - - - %sveltekit.head% - - - -
%sveltekit.body%
- - - \ No newline at end of file + +
%sveltekit.body%
+ + diff --git a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte index 29ded0c5998aa..e2cb1cf9a5885 100644 --- a/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte +++ b/web/src/lib/components/admin-page/settings/oauth/oauth-settings.svelte @@ -93,7 +93,7 @@ >.

- +
- + {#if required}
*
{/if} diff --git a/web/src/lib/components/admin-page/settings/setting-switch.svelte b/web/src/lib/components/admin-page/settings/setting-switch.svelte index 83163bd28b0e7..e7c591b934704 100644 --- a/web/src/lib/components/admin-page/settings/setting-switch.svelte +++ b/web/src/lib/components/admin-page/settings/setting-switch.svelte @@ -8,13 +8,13 @@

- {title.toUpperCase()} + {title}

{subtitle}

-
{/if}
+ +
+
+ + + {#if sharedLinks.length} + + {/if} +
diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index 6240545ed7b2a..c4efa91bf90bd 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -23,7 +23,7 @@ export let showMotionPlayButton: boolean; export let isMotionPhotoPlaying = false; - const isOwner = asset.ownerId === $page.data.user.id; + const isOwner = asset.ownerId === $page.data.user?.id; const dispatch = createEventDispatcher(); @@ -94,12 +94,15 @@ title="Favorite" /> {/if} - dispatch('delete')} title="Delete" /> - showOptionsMenu(event)} - title="More" - /> + + {#if isOwner} + dispatch('delete')} title="Delete" /> + showOptionsMenu(event)} + title="More" + /> + {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 38b09dea10462..0df252ceaec22 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -10,12 +10,7 @@ import { downloadAssets } from '$lib/stores/download'; import VideoViewer from './video-viewer.svelte'; import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte'; - import { - api, - AssetResponseDto, - AssetTypeEnum, - AlbumResponseDto - } from '@api'; + import { api, AssetResponseDto, AssetTypeEnum, AlbumResponseDto } from '@api'; import { notificationController, NotificationType @@ -25,6 +20,9 @@ import { addAssetsToAlbum } from '$lib/utils/asset-utils'; export let asset: AssetResponseDto; + export let publicSharedKey = ''; + export let showNavigation = true; + $: { appearsInAlbums = []; @@ -91,12 +89,12 @@ const handleDownload = () => { if (asset.livePhotoVideoId) { - downloadFile(asset.livePhotoVideoId, true); - downloadFile(asset.id, false); + downloadFile(asset.livePhotoVideoId, true, publicSharedKey); + downloadFile(asset.id, false, publicSharedKey); return; } - downloadFile(asset.id, false); + downloadFile(asset.id, false, publicSharedKey); }; /** @@ -111,7 +109,7 @@ }; }; - const downloadFile = async (assetId: string, isLivePhoto: boolean) => { + const downloadFile = async (assetId: string, isLivePhoto: boolean, key: string) => { try { const { filenameWithoutExtension } = getTemplateFilename(); @@ -126,6 +124,9 @@ $downloadAssets[imageFileName] = 0; const { data, status } = await api.assetApi.downloadFile(assetId, false, false, { + params: { + key + }, responseType: 'blob', onDownloadProgress: (progressEvent) => { if (progressEvent.lengthComputable) { @@ -251,69 +252,74 @@ /> -
{ - halfLeftHover = true; - halfRightHover = false; - }} - on:mouseleave={() => { - halfLeftHover = false; - }} - on:click={navigateAssetBackward} - on:keydown={navigateAssetBackward} - > - -
+ + + {/if}
{#key asset.id} {#if asset.type === AssetTypeEnum.Image} {#if shouldPlayMotionPhoto && asset.livePhotoVideoId} (shouldPlayMotionPhoto = false)} /> {:else} - + {/if} {:else} - + {/if} {/key}
-
{ - halfLeftHover = false; - halfRightHover = true; - }} - on:mouseleave={() => { - halfRightHover = false; - }} - > - -
+ + + {/if} {#if isShowDetail}
Promise; onMount(async () => { - const { data } = await api.assetApi.getAssetById(assetId); + const { data } = await api.assetApi.getAssetById(assetId, { + params: { + key: publicSharedKey + } + }); assetInfo = data; //Import hack :( see https://github.com/vadimkorr/svelte-carousel/issues/27#issuecomment-851022295 @@ -29,6 +34,9 @@ const loadAssetData = async () => { try { const { data } = await api.assetApi.serveFile(assetInfo.id, false, true, { + params: { + key: publicSharedKey + }, responseType: 'blob' }); diff --git a/web/src/lib/components/asset-viewer/video-viewer.svelte b/web/src/lib/components/asset-viewer/video-viewer.svelte index 1c365aa8e259a..73ffc0f06b641 100644 --- a/web/src/lib/components/asset-viewer/video-viewer.svelte +++ b/web/src/lib/components/asset-viewer/video-viewer.svelte @@ -6,7 +6,7 @@ import { api, AssetResponseDto, getFileUrl } from '@api'; export let assetId: string; - + export let publicSharedKey = ''; let asset: AssetResponseDto; let videoPlayerNode: HTMLVideoElement; @@ -15,7 +15,11 @@ const dispatch = createEventDispatcher(); onMount(async () => { - const { data: assetInfo } = await api.assetApi.getAssetById(assetId); + const { data: assetInfo } = await api.assetApi.getAssetById(assetId, { + params: { + key: publicSharedKey + } + }); await loadVideoData(assetInfo); @@ -25,7 +29,7 @@ const loadVideoData = async (assetInfo: AssetResponseDto) => { isVideoLoading = true; - videoUrl = getFileUrl(assetInfo.id, false, true); + videoUrl = getFileUrl(assetInfo.id, false, true, publicSharedKey); return assetInfo; }; diff --git a/web/src/lib/components/shared-components/control-app-bar.svelte b/web/src/lib/components/shared-components/control-app-bar.svelte index 909ff92eb4bfe..4a86dd115b306 100644 --- a/web/src/lib/components/shared-components/control-app-bar.svelte +++ b/web/src/lib/components/shared-components/control-app-bar.svelte @@ -5,6 +5,8 @@ import Close from 'svelte-material-icons/Close.svelte'; import CircleIconButton from '../shared-components/circle-icon-button.svelte'; import { fly } from 'svelte/transition'; + + export let showBackButton = true; export let backIcon = Close; export let tailwindClasses = ''; @@ -42,14 +44,15 @@ class={`flex justify-between ${appBarBorder} rounded-lg p-2 mx-2 mt-2 transition-all place-items-center ${tailwindClasses} dark:bg-immich-dark-gray`} >
- dispatch('close-button-click')} - logo={backIcon} - backgroundColor={'transparent'} - hoverColor={'#e2e7e9'} - size={'24'} - /> - + {#if showBackButton} + dispatch('close-button-click')} + logo={backIcon} + backgroundColor={'transparent'} + hoverColor={'#e2e7e9'} + size={'24'} + /> + {/if}
diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte new file mode 100644 index 0000000000000..330cb14f14792 --- /dev/null +++ b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte @@ -0,0 +1,243 @@ + + + dispatch('close')}> + + + + {#if editingLink} +

Edit link

+ {:else} +

Create link to share

+ {/if} +
+
+ +
+ {#if shareType == SharedLinkType.Album} + {#if !editingLink} +
Let anyone with the link see photos and people in this album.
+ {:else} +
+ Public album | {editingLink.album?.albumName} +
+ {/if} + {/if} + +
+

LINK OPTIONS

+
+
+
+
+ +
+ + + +
+ {#if editingLink} +

+ +

+ {:else} +

Expire after

+ {/if} + + +
+
+
+
+ +
+ +
+ {#if !isShowSharedLink} + {#if editingLink} +
+ +
+ {:else} +
+ +
+ {/if} + {/if} + + {#if isShowSharedLink} +
+ + + +
+ {/if} +
+
diff --git a/web/src/lib/components/shared-components/dropdown-button.svelte b/web/src/lib/components/shared-components/dropdown-button.svelte new file mode 100644 index 0000000000000..d5d530546dd70 --- /dev/null +++ b/web/src/lib/components/shared-components/dropdown-button.svelte @@ -0,0 +1,76 @@ + + + + +
+ + + {#if isOpen} +
+ {#each options.options as option} + + {/each} +
+ {/if} +
+ + diff --git a/web/src/lib/components/shared-components/immich-thumbnail.svelte b/web/src/lib/components/shared-components/immich-thumbnail.svelte index 5952af6aa9608..bf8f32f259414 100644 --- a/web/src/lib/components/shared-components/immich-thumbnail.svelte +++ b/web/src/lib/components/shared-components/immich-thumbnail.svelte @@ -18,6 +18,9 @@ export let format: ThumbnailFormat = ThumbnailFormat.Webp; export let selected = false; export let disabled = false; + export let publicSharedKey = ''; + export let isRoundedCorner = false; + let imageData: string; let mouseOver = false; @@ -35,10 +38,9 @@ isThumbnailVideoPlaying = false; if (isLivePhoto && asset.livePhotoVideoId) { - console.log('get file url'); - videoUrl = getFileUrl(asset.livePhotoVideoId, false, true); + videoUrl = getFileUrl(asset.livePhotoVideoId, false, true, publicSharedKey); } else { - videoUrl = getFileUrl(asset.id, false, true); + videoUrl = getFileUrl(asset.id, false, true, publicSharedKey); } }; @@ -118,6 +120,8 @@ return 'border-[20px] border-immich-primary/20'; } else if (disabled) { return 'border-[20px] border-gray-300'; + } else if (isRoundedCorner) { + return 'rounded-[20px]'; } else { return ''; } @@ -244,7 +248,7 @@ style:width={`${thumbnailSize}px`} style:height={`${thumbnailSize}px`} in:fade={{ duration: 150 }} - src={`/api/asset/thumbnail/${asset.id}?format=${format}`} + src={`/api/asset/thumbnail/${asset.id}?format=${format}&key=${publicSharedKey}`} alt={asset.id} class={`object-cover ${getSize()} transition-all z-0 ${getThumbnailBorderStyle()}`} loading="lazy" diff --git a/web/src/lib/components/shared-components/theme-button.svelte b/web/src/lib/components/shared-components/theme-button.svelte index babb3b768c6af..7f6c90dc41b59 100644 --- a/web/src/lib/components/shared-components/theme-button.svelte +++ b/web/src/lib/components/shared-components/theme-button.svelte @@ -49,7 +49,7 @@ on:click={toggleTheme} id="theme-toggle" type="button" - class="text-gray-500 dark:text-immich-dark-primary hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none rounded-lg text-sm p-2.5" + class="text-gray-500 dark:text-immich-dark-primary hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none rounded-full text-sm p-2.5" > + import { api, AssetResponseDto, SharedLinkResponseDto, SharedLinkType } from '@api'; + import LoadingSpinner from '../shared-components/loading-spinner.svelte'; + import OpenInNew from 'svelte-material-icons/OpenInNew.svelte'; + import Delete from 'svelte-material-icons/TrashCanOutline.svelte'; + import ContentCopy from 'svelte-material-icons/ContentCopy.svelte'; + import CircleEditOutline from 'svelte-material-icons/CircleEditOutline.svelte'; + import * as luxon from 'luxon'; + import CircleIconButton from '../shared-components/circle-icon-button.svelte'; + import { createEventDispatcher } from 'svelte'; + import { goto } from '$app/navigation'; + + export let link: SharedLinkResponseDto; + + let expirationCountdown: luxon.DurationObjectUnits; + const dispatch = createEventDispatcher(); + + const getAssetInfo = async (): Promise => { + let assetId = ''; + + if (link.album?.albumThumbnailAssetId) { + assetId = link.album.albumThumbnailAssetId; + } else if (link.assets.length > 0) { + assetId = link.assets[0]; + } + + const { data } = await api.assetApi.getAssetById(assetId); + + return data; + }; + + const getCountDownExpirationDate = () => { + if (!link.expiresAt) { + return; + } + + const expiresAtDate = luxon.DateTime.fromISO(new Date(link.expiresAt).toISOString()); + const now = luxon.DateTime.now(); + + expirationCountdown = expiresAtDate + .diff(now, ['days', 'hours', 'minutes', 'seconds']) + .toObject(); + + if (expirationCountdown.days && expirationCountdown.days > 0) { + return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'days' }); + } else if (expirationCountdown.hours && expirationCountdown.hours > 0) { + return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'hours' }); + } else if (expirationCountdown.minutes && expirationCountdown.minutes > 0) { + return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'minutes' }); + } else if (expirationCountdown.seconds && expirationCountdown.seconds > 0) { + return expiresAtDate.toRelativeCalendar({ base: now, locale: 'en-US', unit: 'seconds' }); + } + }; + + const isExpired = (expiresAt: string) => { + const now = new Date().getTime(); + const expiration = new Date(expiresAt).getTime(); + + return now > expiration; + }; + + +
+
+ {#await getAssetInfo()} + + {:then asset} + {asset.id} + {/await} +
+ +
+
+
+ {#if link.expiresAt} + {#if isExpired(link.expiresAt)} +

Expired

+ {:else} +

+ Expires {getCountDownExpirationDate()} +

+ {/if} + {:else} +

Expires ∞

+ {/if} +
+ +
+
+ {#if link.type === SharedLinkType.Album} +

+ {link.album?.albumName.toUpperCase()} +

+ {:else if link.type === SharedLinkType.Individual} +

INDIVIDUAL SHARE

+ {/if} + + {#if !link.expiresAt || !isExpired(link.expiresAt)} +
goto(`/share/${link.key}`)} + on:keydown={() => goto(`/share/${link.key}`)} + > + +
+ {/if} +
+ +

{link.description ?? ''}

+
+
+ +
+ {#if link.allowUpload} +
+ Allow upload +
+ {/if} +
+
+ +
+
+ dispatch('delete')} /> + dispatch('edit')} /> + dispatch('copy')} /> +
+
+
diff --git a/web/src/lib/utils/asset-utils.ts b/web/src/lib/utils/asset-utils.ts index f1b6596a54c1e..c4c9b16b03cd2 100644 --- a/web/src/lib/utils/asset-utils.ts +++ b/web/src/lib/utils/asset-utils.ts @@ -1,21 +1,106 @@ -import { api, AddAssetsResponseDto } from '@api'; +import { api, AddAssetsResponseDto, AssetResponseDto } from '@api'; import { notificationController, NotificationType } from '$lib/components/shared-components/notification/notification'; +import { downloadAssets } from '$lib/stores/download'; +import { get } from 'svelte/store'; export const addAssetsToAlbum = async ( albumId: string, - assetIds: Array + assetIds: Array, + key: string | undefined = undefined ): Promise => - api.albumApi.addAssetsToAlbum(albumId, { assetIds }).then(({ data: dto }) => { - if (dto.successfullyAdded > 0) { - // This might be 0 if the user tries to add an asset that is already in the album - notificationController.show({ - message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`, - type: NotificationType.Info - }); - } + api.albumApi + .addAssetsToAlbum(albumId, { assetIds }, { params: { key } }) + .then(({ data: dto }) => { + if (dto.successfullyAdded > 0) { + // This might be 0 if the user tries to add an asset that is already in the album + notificationController.show({ + message: `Added ${dto.successfullyAdded} to ${dto.album?.albumName}`, + type: NotificationType.Info + }); + } - return dto; - }); + return dto; + }); + +export async function bulkDownload( + fileName: string, + assets: AssetResponseDto[], + onDone: () => void, + key?: string +) { + const assetIds = assets.map((asset) => asset.id); + + try { + let skip = 0; + let count = 0; + let done = false; + + while (!done) { + count++; + + const downloadFileName = fileName + `${count === 1 ? '' : count}.zip`; + downloadAssets.set({ [downloadFileName]: 0 }); + + let total = 0; + + const { data, status, headers } = await api.assetApi.downloadFiles( + { assetIds }, + { + params: { key }, + responseType: 'blob', + onDownloadProgress: function (progressEvent) { + const request = this as XMLHttpRequest; + if (!total) { + total = Number(request.getResponseHeader('X-Immich-Content-Length-Hint')) || 0; + } + + if (total) { + const current = progressEvent.loaded; + downloadAssets.set({ [downloadFileName]: Math.floor((current / total) * 100) }); + } + } + } + ); + + const isNotComplete = headers['x-immich-archive-complete'] === 'false'; + const fileCount = Number(headers['x-immich-archive-file-count']) || 0; + if (isNotComplete && fileCount > 0) { + skip += fileCount; + } else { + onDone(); + done = true; + } + + if (!(data instanceof Blob)) { + return; + } + + if (status === 201) { + const fileUrl = URL.createObjectURL(data); + const anchor = document.createElement('a'); + anchor.href = fileUrl; + anchor.download = downloadFileName; + + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + URL.revokeObjectURL(fileUrl); + + // Remove item from download list + setTimeout(() => { + downloadAssets.set({}); + }, 2000); + } + } + } catch (e) { + console.error('Error downloading file ', e); + notificationController.show({ + type: NotificationType.Error, + message: 'Error downloading file, check console for more details.' + }); + } +} diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index 2b352b89ecdb3..671534f257e72 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -11,6 +11,7 @@ import { addAssetsToAlbum } from '$lib/utils/asset-utils'; export const openFileUploadDialog = ( albumId: string | undefined = undefined, + sharedKey: string | undefined = undefined, callback?: () => void ) => { try { @@ -27,7 +28,7 @@ export const openFileUploadDialog = ( } const files = Array.from(target.files); - await fileUploadHandler(files, albumId); + await fileUploadHandler(files, albumId, sharedKey); callback && callback(); }; @@ -37,7 +38,11 @@ export const openFileUploadDialog = ( } }; -export const fileUploadHandler = async (files: File[], albumId: string | undefined = undefined) => { +export const fileUploadHandler = async ( + files: File[], + albumId: string | undefined = undefined, + sharedKey: string | undefined = undefined +) => { if (files.length > 50) { notificationController.show({ type: NotificationType.Error, @@ -49,18 +54,22 @@ export const fileUploadHandler = async (files: File[], albumId: string | undefin return; } - + console.log('fileUploadHandler'); const acceptedFile = files.filter( (e) => e.type.split('/')[0] === 'video' || e.type.split('/')[0] === 'image' ); for (const asset of acceptedFile) { - await fileUploader(asset, albumId); + await fileUploader(asset, albumId, sharedKey); } }; //TODO: should probably use the @api SDK -async function fileUploader(asset: File, albumId: string | undefined = undefined) { +async function fileUploader( + asset: File, + albumId: string | undefined = undefined, + sharedKey: string | undefined = undefined +) { const assetType = asset.type.split('/')[0].toUpperCase(); const temp = asset.name.split('.'); const fileExtension = temp[temp.length - 1]; @@ -108,10 +117,17 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined formData.append('assetData', asset); // Check if asset upload on server before performing upload - const { data, status } = await api.assetApi.checkDuplicateAsset({ - deviceAssetId: String(deviceAssetId), - deviceId: 'WEB' - }); + const { data, status } = await api.assetApi.checkDuplicateAsset( + { + deviceAssetId: String(deviceAssetId), + deviceId: 'WEB' + }, + { + params: { + key: sharedKey + } + } + ); if (status === 200) { if (data.isExist) { @@ -124,7 +140,6 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined } const request = new XMLHttpRequest(); - request.upload.onloadstart = () => { const newUploadAsset: UploadAsset = { id: deviceAssetId, @@ -144,7 +159,7 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined try { const res: AssetFileUploadResponseDto = JSON.parse(request.response || '{}'); if (res.id) { - addAssetsToAlbum(albumId, [res.id]); + addAssetsToAlbum(albumId, [res.id], sharedKey); } } catch (e) { console.error('ERROR parsing data JSON in upload onload'); @@ -171,7 +186,7 @@ async function fileUploader(asset: File, albumId: string | undefined = undefined uploadAssetsStore.updateProgress(deviceAssetId, percentComplete); }; - request.open('POST', `/api/asset/upload`); + request.open('POST', `/api/asset/upload?key=${sharedKey ?? ''}`); request.send(formData); } catch (e) { diff --git a/web/src/routes/photos/+page.svelte b/web/src/routes/photos/+page.svelte index 4bb7eb2815ae7..5d0b6e270fae7 100644 --- a/web/src/routes/photos/+page.svelte +++ b/web/src/routes/photos/+page.svelte @@ -17,6 +17,7 @@ } from '$lib/stores/asset-interaction.store'; import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte'; import Close from 'svelte-material-icons/Close.svelte'; + import CloudDownloadOutline from 'svelte-material-icons/CloudDownloadOutline.svelte'; import CircleIconButton from '$lib/components/shared-components/circle-icon-button.svelte'; import DeleteOutline from 'svelte-material-icons/DeleteOutline.svelte'; import Plus from 'svelte-material-icons/Plus.svelte'; @@ -26,7 +27,7 @@ NotificationType } from '$lib/components/shared-components/notification/notification'; import { assetStore } from '$lib/stores/assets.store'; - import { addAssetsToAlbum } from '$lib/utils/asset-utils'; + import { addAssetsToAlbum, bulkDownload } from '$lib/utils/asset-utils'; export let data: PageData; @@ -106,6 +107,12 @@ assetInteractionStore.clearMultiselect(); }); }; + + const handleDownloadFiles = async () => { + await bulkDownload('immich', Array.from($selectedAssets), () => { + assetInteractionStore.clearMultiselect(); + }); + }; @@ -125,6 +132,11 @@

+ + Opps! Error - Immich +
+ +
+
+ Page not found :/ +
+
diff --git a/web/src/routes/share/[key]/+page.server.ts b/web/src/routes/share/[key]/+page.server.ts new file mode 100644 index 0000000000000..45a8fe6b06725 --- /dev/null +++ b/web/src/routes/share/[key]/+page.server.ts @@ -0,0 +1,18 @@ +export const prerender = false; +import { error } from '@sveltejs/kit'; + +import { serverApi } from '@api'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + const { key } = params; + + try { + const { data: sharedLink } = await serverApi.shareApi.getMySharedLink({ params: { key } }); + return { sharedLink }; + } catch (e) { + throw error(404, { + message: 'Invalid shared link' + }); + } +}; diff --git a/web/src/routes/share/[key]/+page.svelte b/web/src/routes/share/[key]/+page.svelte new file mode 100644 index 0000000000000..50e1030b7809b --- /dev/null +++ b/web/src/routes/share/[key]/+page.svelte @@ -0,0 +1,22 @@ + + + + {data.sharedLink.album?.albumName || 'Public Shared'} - Immich + + +{#if album} +
+ +
+{/if} diff --git a/web/src/routes/share/[key]/photos/[assetId]/+page.server.ts b/web/src/routes/share/[key]/photos/[assetId]/+page.server.ts new file mode 100644 index 0000000000000..4fa079a150c10 --- /dev/null +++ b/web/src/routes/share/[key]/photos/[assetId]/+page.server.ts @@ -0,0 +1,21 @@ +export const prerender = false; +import { error } from '@sveltejs/kit'; + +import { serverApi } from '@api'; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + try { + const { key, assetId } = params; + const { data: asset } = await serverApi.assetApi.getAssetById(assetId, { + params: { key } + }); + + if (!asset) { + return error(404, 'Asset not found'); + } + return { asset, key }; + } catch (e) { + console.log('Error', e); + } +}; diff --git a/web/src/routes/share/[key]/photos/[assetId]/+page.svelte b/web/src/routes/share/[key]/photos/[assetId]/+page.svelte new file mode 100644 index 0000000000000..41f5012c9d3d4 --- /dev/null +++ b/web/src/routes/share/[key]/photos/[assetId]/+page.svelte @@ -0,0 +1,17 @@ + + +{#if data.asset && data.key} + null} + on:navigate-next={() => null} + showNavigation={false} + on:close={() => goto(`/share/${data.key}`)} + /> +{/if} diff --git a/web/src/routes/sharing/+page.svelte b/web/src/routes/sharing/+page.svelte index df27095f419c4..70a6fb8280826 100644 --- a/web/src/routes/sharing/+page.svelte +++ b/web/src/routes/sharing/+page.svelte @@ -2,6 +2,8 @@ import NavigationBar from '$lib/components/shared-components/navigation-bar/navigation-bar.svelte'; import SideBar from '$lib/components/shared-components/side-bar/side-bar.svelte'; import PlusBoxOutline from 'svelte-material-icons/PlusBoxOutline.svelte'; + import Link from 'svelte-material-icons/Link.svelte'; + import SharedAlbumListTile from '$lib/components/sharing-page/shared-album-list-tile.svelte'; import { goto } from '$app/navigation'; import { api } from '@api'; @@ -55,7 +57,7 @@

Sharing

-
+
+ +
diff --git a/web/src/routes/sharing/sharedlinks/+page.server.ts b/web/src/routes/sharing/sharedlinks/+page.server.ts new file mode 100644 index 0000000000000..52746063c5ebe --- /dev/null +++ b/web/src/routes/sharing/sharedlinks/+page.server.ts @@ -0,0 +1,18 @@ +import { redirect } from '@sveltejs/kit'; +export const prerender = false; +import type { PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ parent }) => { + try { + const { user } = await parent(); + if (!user) { + throw redirect(302, '/auth/login'); + } + + return { + user + }; + } catch (e) { + throw redirect(302, '/auth/login'); + } +}; diff --git a/web/src/routes/sharing/sharedlinks/+page.svelte b/web/src/routes/sharing/sharedlinks/+page.svelte new file mode 100644 index 0000000000000..9ae23ace60cd3 --- /dev/null +++ b/web/src/routes/sharing/sharedlinks/+page.svelte @@ -0,0 +1,109 @@ + + + + Shared links - Immich + + + goto('/sharing')}> + Shared links + + +
+
+

Manage shared links

+
+ {#if sharedLinks.length === 0} +
+

You don't have any shared links

+
+ {:else} +
+ {#each sharedLinks as link (link.id)} + handleDeleteLink(link.id)} + on:edit={() => handleEditLink(link.id)} + on:copy={() => handleCopy(link.key)} + /> + {/each} +
+ {/if} +
+ +{#if showEditForm} + +{/if}