Browse Source

Merge branch 'main' of https://github.com/immich-app/immich into thumbhash_mobile

Luke McCarthy 2 years ago
parent
commit
4fab2bcf63
100 changed files with 2609 additions and 1964 deletions
  1. 1 0
      README.md
  2. 45 32
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  3. 20 6
      mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
  4. 54 16
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  5. 9 0
      mobile/lib/utils/files_helper.dart
  6. 12 12
      mobile/openapi/.openapi-generator/FILES
  7. 12 12
      mobile/openapi/README.md
  8. 0 56
      mobile/openapi/doc/AlbumApi.md
  9. 1 0
      mobile/openapi/doc/AlbumResponseDto.md
  10. 14 128
      mobile/openapi/doc/AssetApi.md
  11. 0 20
      mobile/openapi/doc/CreateAssetsShareLinkDto.md
  12. 1 0
      mobile/openapi/doc/CreateUserDto.md
  13. 26 0
      mobile/openapi/doc/ImportAssetDto.md
  14. 196 20
      mobile/openapi/doc/SharedLinkApi.md
  15. 8 6
      mobile/openapi/doc/SharedLinkCreateDto.md
  16. 1 1
      mobile/openapi/doc/SharedLinkEditDto.md
  17. 1 1
      mobile/openapi/doc/SharedLinkResponseDto.md
  18. 1 0
      mobile/openapi/doc/UpdateUserDto.md
  19. 1 0
      mobile/openapi/doc/UserResponseDto.md
  20. 4 4
      mobile/openapi/lib/api.dart
  21. 0 47
      mobile/openapi/lib/api/album_api.dart
  22. 29 131
      mobile/openapi/lib/api/asset_api.dart
  23. 0 253
      mobile/openapi/lib/api/share_api.dart
  24. 426 0
      mobile/openapi/lib/api/shared_link_api.dart
  25. 6 6
      mobile/openapi/lib/api_client.dart
  26. 20 3
      mobile/openapi/lib/model/album_response_dto.dart
  27. 0 194
      mobile/openapi/lib/model/create_album_share_link_dto.dart
  28. 14 3
      mobile/openapi/lib/model/create_user_dto.dart
  29. 232 0
      mobile/openapi/lib/model/import_asset_dto.dart
  30. 63 75
      mobile/openapi/lib/model/shared_link_create_dto.dart
  31. 20 20
      mobile/openapi/lib/model/shared_link_edit_dto.dart
  32. 2 7
      mobile/openapi/lib/model/shared_link_response_dto.dart
  33. 18 1
      mobile/openapi/lib/model/update_user_dto.dart
  34. 0 5
      mobile/openapi/test/album_api_test.dart
  35. 5 0
      mobile/openapi/test/album_response_dto_test.dart
  36. 3 13
      mobile/openapi/test/asset_api_test.dart
  37. 0 52
      mobile/openapi/test/create_album_share_link_dto_test.dart
  38. 5 0
      mobile/openapi/test/create_user_dto_test.dart
  39. 82 0
      mobile/openapi/test/import_asset_dto_test.dart
  40. 19 4
      mobile/openapi/test/shared_link_api_test.dart
  41. 21 11
      mobile/openapi/test/shared_link_create_dto_test.dart
  42. 3 3
      mobile/openapi/test/shared_link_edit_dto_test.dart
  43. 5 0
      mobile/openapi/test/update_user_dto_test.dart
  44. 5 0
      mobile/openapi/test/user_response_dto_test.dart
  45. 2 2
      server/Dockerfile
  46. 13 4
      server/e2e/album.e2e-spec.ts
  47. 3 0
      server/e2e/user.e2e-spec.ts
  48. 364 290
      server/immich-openapi-specs.json
  49. 12 0
      server/package-lock.json
  50. 2 0
      server/package.json
  51. 3 0
      server/src/domain/access/access.repository.ts
  52. 1 0
      server/src/domain/album/album-response.dto.ts
  53. 1 0
      server/src/domain/album/album.service.spec.ts
  54. 13 9
      server/src/domain/album/album.service.ts
  55. 1 0
      server/src/domain/api-key/api-key.core.ts
  56. 1 0
      server/src/domain/asset/asset.repository.ts
  57. 28 5
      server/src/domain/auth/auth.service.ts
  58. 1 0
      server/src/domain/auth/dto/auth-user.dto.ts
  59. 1 0
      server/src/domain/crypto/crypto.repository.ts
  60. 57 0
      server/src/domain/domain.constant.ts
  61. 2 0
      server/src/domain/partner/partner.service.spec.ts
  62. 0 12
      server/src/domain/shared-link/dto/create-shared-link.dto.ts
  63. 0 18
      server/src/domain/shared-link/dto/edit-shared-link.dto.ts
  64. 0 2
      server/src/domain/shared-link/dto/index.ts
  65. 2 3
      server/src/domain/shared-link/index.ts
  66. 0 1
      server/src/domain/shared-link/response-dto/index.ts
  67. 3 3
      server/src/domain/shared-link/shared-link-response.dto.ts
  68. 0 80
      server/src/domain/shared-link/shared-link.core.ts
  69. 53 0
      server/src/domain/shared-link/shared-link.dto.ts
  70. 1 1
      server/src/domain/shared-link/shared-link.repository.ts
  71. 149 2
      server/src/domain/shared-link/shared-link.service.spec.ts
  72. 112 6
      server/src/domain/shared-link/shared-link.service.ts
  73. 21 0
      server/src/domain/storage-template/storage-template.service.spec.ts
  74. 9 1
      server/src/domain/storage-template/storage-template.service.ts
  75. 4 0
      server/src/domain/user/dto/create-user.dto.ts
  76. 4 0
      server/src/domain/user/dto/update-user.dto.ts
  77. 2 0
      server/src/domain/user/response-dto/user-response.dto.ts
  78. 6 2
      server/src/domain/user/user.core.ts
  79. 5 0
      server/src/domain/user/user.service.spec.ts
  80. 1 7
      server/src/immich/api-v1/album/album.controller.ts
  81. 4 14
      server/src/immich/api-v1/album/album.service.spec.ts
  82. 9 34
      server/src/immich/api-v1/album/album.service.ts
  83. 0 35
      server/src/immich/api-v1/album/dto/create-album-shared-link.dto.ts
  84. 18 0
      server/src/immich/api-v1/asset/asset-repository.ts
  85. 16 32
      server/src/immich/api-v1/asset/asset.controller.ts
  86. 5 4
      server/src/immich/api-v1/asset/asset.core.ts
  87. 45 90
      server/src/immich/api-v1/asset/asset.service.spec.ts
  88. 92 80
      server/src/immich/api-v1/asset/asset.service.ts
  89. 0 41
      server/src/immich/api-v1/asset/dto/create-asset-shared-link.dto.ts
  90. 31 5
      server/src/immich/api-v1/asset/dto/create-asset.dto.ts
  91. 20 18
      server/src/immich/config/asset-upload.config.spec.ts
  92. 12 13
      server/src/immich/config/asset-upload.config.ts
  93. 38 5
      server/src/immich/controllers/shared-link.controller.ts
  94. 4 1
      server/src/infra/entities/asset.entity.ts
  95. 5 2
      server/src/infra/entities/shared-link.entity.ts
  96. 3 0
      server/src/infra/entities/user.entity.ts
  97. 18 0
      server/src/infra/migrations/1686584273471-ImportAsset.ts
  98. 9 0
      server/src/infra/repositories/access.repository.ts
  99. 7 0
      server/src/infra/repositories/asset.repository.ts
  100. 11 0
      server/src/infra/repositories/crypto.repository.ts

+ 1 - 0
README.md

@@ -84,6 +84,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
 | Global Map                                   | No     | Yes |
 | Global Map                                   | No     | Yes |
 | Partner Sharing                              | Yes    | Yes |
 | Partner Sharing                              | Yes    | Yes |
 | Facial recognition and clustering            | No     | Yes |
 | Facial recognition and clustering            | No     | Yes |
+| Offline support                              | Yes    | No  |
 
 
 # Support the project
 # Support the project
 
 

+ 45 - 32
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -52,7 +52,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     final showAppBar = useState<bool>(true);
     final showAppBar = useState<bool>(true);
     final isPlayingMotionVideo = useState(false);
     final isPlayingMotionVideo = useState(false);
     final isPlayingVideo = useState(false);
     final isPlayingVideo = useState(false);
-    late Offset localPosition;
+    Offset? localPosition;
     final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
     final authToken = 'Bearer ${Store.get(StoreKey.accessToken)}';
     final currentIndex = useState(initialIndex);
     final currentIndex = useState(initialIndex);
     final currentAsset = loadAsset(currentIndex.value);
     final currentAsset = loadAsset(currentIndex.value);
@@ -246,8 +246,13 @@ class GalleryViewerPage extends HookConsumerWidget {
         return;
         return;
       }
       }
 
 
+      // Guard [localPosition] null
+      if (localPosition == null) {
+        return;
+      }
+
       // Check for delta from initial down point
       // Check for delta from initial down point
-      final d = details.localPosition - localPosition;
+      final d = details.localPosition - localPosition!;
       // If the magnitude of the dx swipe is large, we probably didn't mean to go down
       // If the magnitude of the dx swipe is large, we probably didn't mean to go down
       if (d.dx.abs() > dxThreshold) {
       if (d.dx.abs() > dxThreshold) {
         return;
         return;
@@ -367,6 +372,26 @@ class GalleryViewerPage extends HookConsumerWidget {
       );
       );
     }
     }
 
 
+    ImageProvider imageProvider(Asset asset) {
+      if (asset.isLocal) {
+        return localImageProvider(asset);
+      } else {
+        if (isLoadOriginal.value) {
+          return originalImageProvider(asset);
+        } else if (isLoadPreview.value) {
+          return remoteThumbnailImageProvider(
+            asset,
+            api.ThumbnailFormat.JPEG,
+          );
+        } else {
+          return remoteThumbnailImageProvider(
+            asset,
+            api.ThumbnailFormat.WEBP,
+          );
+        }
+      }
+    }
+
     return Scaffold(
     return Scaffold(
       backgroundColor: Colors.black,
       backgroundColor: Colors.black,
       body: WillPopScope(
       body: WillPopScope(
@@ -460,26 +485,9 @@ class GalleryViewerPage extends HookConsumerWidget {
                   : null,
                   : null,
               builder: (context, index) {
               builder: (context, index) {
                 final asset = loadAsset(index);
                 final asset = loadAsset(index);
+                final ImageProvider provider = imageProvider(asset);
+
                 if (asset.isImage && !isPlayingMotionVideo.value) {
                 if (asset.isImage && !isPlayingMotionVideo.value) {
-                  // Show photo
-                  final ImageProvider provider;
-                  if (asset.isLocal) {
-                    provider = localImageProvider(asset);
-                  } else {
-                    if (isLoadOriginal.value) {
-                      provider = originalImageProvider(asset);
-                    } else if (isLoadPreview.value) {
-                      provider = remoteThumbnailImageProvider(
-                        asset,
-                        api.ThumbnailFormat.JPEG,
-                      );
-                    } else {
-                      provider = remoteThumbnailImageProvider(
-                        asset,
-                        api.ThumbnailFormat.WEBP,
-                      );
-                    }
-                  }
                   return PhotoViewGalleryPageOptions(
                   return PhotoViewGalleryPageOptions(
                     onDragStart: (_, details, __) =>
                     onDragStart: (_, details, __) =>
                         localPosition = details.localPosition,
                         localPosition = details.localPosition,
@@ -512,18 +520,23 @@ class GalleryViewerPage extends HookConsumerWidget {
                     maxScale: 1.0,
                     maxScale: 1.0,
                     minScale: 1.0,
                     minScale: 1.0,
                     basePosition: Alignment.bottomCenter,
                     basePosition: Alignment.bottomCenter,
-                    child: SafeArea(
-                      child: VideoViewerPage(
-                        onPlaying: () => isPlayingVideo.value = true,
-                        onPaused: () => isPlayingVideo.value = false,
-                        asset: asset,
-                        isMotionVideo: isPlayingMotionVideo.value,
-                        onVideoEnded: () {
-                          if (isPlayingMotionVideo.value) {
-                            isPlayingMotionVideo.value = false;
-                          }
-                        },
+                    child: VideoViewerPage(
+                      onPlaying: () => isPlayingVideo.value = true,
+                      onPaused: () => isPlayingVideo.value = false,
+                      asset: asset,
+                      isMotionVideo: isPlayingMotionVideo.value,
+                      placeholder: Image(
+                        image: provider,
+                        fit: BoxFit.fitWidth,
+                        height: MediaQuery.of(context).size.height,
+                        width: MediaQuery.of(context).size.width,
+                        alignment: Alignment.center,
                       ),
                       ),
+                      onVideoEnded: () {
+                        if (isPlayingMotionVideo.value) {
+                          isPlayingMotionVideo.value = false;
+                        }
+                      },
                     ),
                     ),
                   );
                   );
                 }
                 }

+ 20 - 6
mobile/lib/modules/asset_viewer/views/video_viewer_page.dart

@@ -15,6 +15,7 @@ import 'package:video_player/video_player.dart';
 class VideoViewerPage extends HookConsumerWidget {
 class VideoViewerPage extends HookConsumerWidget {
   final Asset asset;
   final Asset asset;
   final bool isMotionVideo;
   final bool isMotionVideo;
+  final Widget? placeholder;
   final VoidCallback onVideoEnded;
   final VoidCallback onVideoEnded;
   final VoidCallback? onPlaying;
   final VoidCallback? onPlaying;
   final VoidCallback? onPaused;
   final VoidCallback? onPaused;
@@ -26,6 +27,7 @@ class VideoViewerPage extends HookConsumerWidget {
     required this.onVideoEnded,
     required this.onVideoEnded,
     this.onPlaying,
     this.onPlaying,
     this.onPaused,
     this.onPaused,
+    this.placeholder,
   }) : super(key: key);
   }) : super(key: key);
 
 
   @override
   @override
@@ -66,6 +68,7 @@ class VideoViewerPage extends HookConsumerWidget {
           onVideoEnded: onVideoEnded,
           onVideoEnded: onVideoEnded,
           onPaused: onPaused,
           onPaused: onPaused,
           onPlaying: onPlaying,
           onPlaying: onPlaying,
+          placeholder: placeholder,
         ),
         ),
         if (downloadAssetStatus == DownloadAssetStatus.loading)
         if (downloadAssetStatus == DownloadAssetStatus.loading)
           const Center(
           const Center(
@@ -95,6 +98,10 @@ class VideoPlayer extends StatefulWidget {
   final Function()? onPlaying;
   final Function()? onPlaying;
   final Function()? onPaused;
   final Function()? onPaused;
 
 
+  /// The placeholder to show while the video is loading
+  /// usually, a thumbnail of the video
+  final Widget? placeholder;
+
   const VideoPlayer({
   const VideoPlayer({
     Key? key,
     Key? key,
     this.url,
     this.url,
@@ -104,6 +111,7 @@ class VideoPlayer extends StatefulWidget {
     required this.isMotionVideo,
     required this.isMotionVideo,
     this.onPlaying,
     this.onPlaying,
     this.onPaused,
     this.onPaused,
+    this.placeholder,
   }) : super(key: key);
   }) : super(key: key);
 
 
   @override
   @override
@@ -186,12 +194,18 @@ class _VideoPlayerState extends State<VideoPlayer> {
         ),
         ),
       );
       );
     } else {
     } else {
-      return const Center(
-        child: SizedBox(
-          width: 75,
-          height: 75,
-          child: CircularProgressIndicator.adaptive(
-            strokeWidth: 2,
+      return SizedBox(
+        height: MediaQuery.of(context).size.height,
+        width: MediaQuery.of(context).size.width,
+        child: Center(
+          child: Stack(
+            children: [
+              if (widget.placeholder != null)
+                widget.placeholder!,
+              const Center(
+                child: ImmichLoadingIndicator(),
+              ),
+            ],
           ),
           ),
         ),
         ),
       );
       );

+ 54 - 16
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -1,3 +1,6 @@
+import 'dart:math';
+
+import 'package:flutter/gestures.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -51,6 +54,12 @@ class ImmichAssetGrid extends HookConsumerWidget {
     final enableHeroAnimations = useState(false);
     final enableHeroAnimations = useState(false);
     final transitionDuration = ModalRoute.of(context)?.transitionDuration;
     final transitionDuration = ModalRoute.of(context)?.transitionDuration;
 
 
+    final perRow = useState(
+      assetsPerRow ?? settings.getSetting(AppSettingsEnum.tilesPerRow)!,
+    );
+    final scaleFactor = useState(7.0 - perRow.value);
+    final baseScaleFactor = useState(7.0 - perRow.value);
+
     useEffect(
     useEffect(
       () {
       () {
         // Wait for transition to complete, then re-enable
         // Wait for transition to complete, then re-enable
@@ -80,22 +89,43 @@ class ImmichAssetGrid extends HookConsumerWidget {
         onWillPop: onWillPop,
         onWillPop: onWillPop,
         child: HeroMode(
         child: HeroMode(
           enabled: enableHeroAnimations.value,
           enabled: enableHeroAnimations.value,
-          child: ImmichAssetGridView(
-            onRefresh: onRefresh,
-            assetsPerRow: assetsPerRow ??
-                settings.getSetting(AppSettingsEnum.tilesPerRow),
-            listener: listener,
-            showStorageIndicator: showStorageIndicator ??
-                settings.getSetting(AppSettingsEnum.storageIndicator),
-            renderList: renderList,
-            margin: margin,
-            selectionActive: selectionActive,
-            preselectedAssets: preselectedAssets,
-            canDeselect: canDeselect,
-            dynamicLayout: dynamicLayout ??
-                settings.getSetting(AppSettingsEnum.dynamicLayout),
-            showMultiSelectIndicator: showMultiSelectIndicator,
-            visibleItemsListener: visibleItemsListener,
+          child: RawGestureDetector(
+            gestures: {
+              CustomScaleGestureRecognizer:
+                  GestureRecognizerFactoryWithHandlers<
+                          CustomScaleGestureRecognizer>(
+                      () => CustomScaleGestureRecognizer(),
+                      (CustomScaleGestureRecognizer scale) {
+                scale.onStart = (details) {
+                  baseScaleFactor.value = scaleFactor.value;
+                };
+
+                scale.onUpdate = (details) {
+                  scaleFactor.value =
+                      max(min(5.0, baseScaleFactor.value * details.scale), 1.0);
+                  if (7 - scaleFactor.value.toInt() != perRow.value) {
+                    perRow.value = 7 - scaleFactor.value.toInt();
+                  }
+                };
+                scale.onEnd = (details) {};
+              })
+            },
+            child: ImmichAssetGridView(
+              onRefresh: onRefresh,
+              assetsPerRow: perRow.value,
+              listener: listener,
+              showStorageIndicator: showStorageIndicator ??
+                  settings.getSetting(AppSettingsEnum.storageIndicator),
+              renderList: renderList,
+              margin: margin,
+              selectionActive: selectionActive,
+              preselectedAssets: preselectedAssets,
+              canDeselect: canDeselect,
+              dynamicLayout: dynamicLayout ??
+                  settings.getSetting(AppSettingsEnum.dynamicLayout),
+              showMultiSelectIndicator: showMultiSelectIndicator,
+              visibleItemsListener: visibleItemsListener,
+            ),
           ),
           ),
         ),
         ),
       );
       );
@@ -113,3 +143,11 @@ class ImmichAssetGrid extends HookConsumerWidget {
     );
     );
   }
   }
 }
 }
+
+/// accepts a gesture even though it should reject it (because child won)
+class CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
+  @override
+  void rejectGesture(int pointer) {
+    acceptGesture(pointer);
+  }
+}

+ 9 - 0
mobile/lib/utils/files_helper.dart

@@ -134,6 +134,15 @@ class FileHelper {
       case 'cin':
       case 'cin':
         return {"type": "image", "subType": "x-phantom-cin"};
         return {"type": "image", "subType": "x-phantom-cin"};
 
 
+      case 'jxl':
+        return {"type": "image", "subType": "jxl"};
+
+      case 'mts':
+        return {"type": "video", "subType": "mp2t"};
+
+      case 'm2ts':
+        return {"type": "video", "subType": "mp2t"};
+
       default:
       default:
         return {"type": "unsupport", "subType": "unsupport"};
         return {"type": "unsupport", "subType": "unsupport"};
     }
     }

+ 12 - 12
mobile/openapi/.openapi-generator/FILES

@@ -37,8 +37,6 @@ doc/CheckDuplicateAssetResponseDto.md
 doc/CheckExistingAssetsDto.md
 doc/CheckExistingAssetsDto.md
 doc/CheckExistingAssetsResponseDto.md
 doc/CheckExistingAssetsResponseDto.md
 doc/CreateAlbumDto.md
 doc/CreateAlbumDto.md
-doc/CreateAlbumShareLinkDto.md
-doc/CreateAssetsShareLinkDto.md
 doc/CreateProfileImageResponseDto.md
 doc/CreateProfileImageResponseDto.md
 doc/CreateTagDto.md
 doc/CreateTagDto.md
 doc/CreateUserDto.md
 doc/CreateUserDto.md
@@ -48,10 +46,10 @@ doc/DeleteAssetDto.md
 doc/DeleteAssetResponseDto.md
 doc/DeleteAssetResponseDto.md
 doc/DeleteAssetStatus.md
 doc/DeleteAssetStatus.md
 doc/DownloadFilesDto.md
 doc/DownloadFilesDto.md
-doc/EditSharedLinkDto.md
 doc/ExifResponseDto.md
 doc/ExifResponseDto.md
 doc/GetAssetByTimeBucketDto.md
 doc/GetAssetByTimeBucketDto.md
 doc/GetAssetCountByTimeBucketDto.md
 doc/GetAssetCountByTimeBucketDto.md
+doc/ImportAssetDto.md
 doc/JobApi.md
 doc/JobApi.md
 doc/JobCommand.md
 doc/JobCommand.md
 doc/JobCommandDto.md
 doc/JobCommandDto.md
@@ -89,7 +87,9 @@ doc/ServerInfoResponseDto.md
 doc/ServerPingResponse.md
 doc/ServerPingResponse.md
 doc/ServerStatsResponseDto.md
 doc/ServerStatsResponseDto.md
 doc/ServerVersionReponseDto.md
 doc/ServerVersionReponseDto.md
-doc/ShareApi.md
+doc/SharedLinkApi.md
+doc/SharedLinkCreateDto.md
+doc/SharedLinkEditDto.md
 doc/SharedLinkResponseDto.md
 doc/SharedLinkResponseDto.md
 doc/SharedLinkType.md
 doc/SharedLinkType.md
 doc/SignUpDto.md
 doc/SignUpDto.md
@@ -128,7 +128,7 @@ lib/api/partner_api.dart
 lib/api/person_api.dart
 lib/api/person_api.dart
 lib/api/search_api.dart
 lib/api/search_api.dart
 lib/api/server_info_api.dart
 lib/api/server_info_api.dart
-lib/api/share_api.dart
+lib/api/shared_link_api.dart
 lib/api/system_config_api.dart
 lib/api/system_config_api.dart
 lib/api/tag_api.dart
 lib/api/tag_api.dart
 lib/api/user_api.dart
 lib/api/user_api.dart
@@ -170,8 +170,6 @@ lib/model/check_duplicate_asset_response_dto.dart
 lib/model/check_existing_assets_dto.dart
 lib/model/check_existing_assets_dto.dart
 lib/model/check_existing_assets_response_dto.dart
 lib/model/check_existing_assets_response_dto.dart
 lib/model/create_album_dto.dart
 lib/model/create_album_dto.dart
-lib/model/create_album_share_link_dto.dart
-lib/model/create_assets_share_link_dto.dart
 lib/model/create_profile_image_response_dto.dart
 lib/model/create_profile_image_response_dto.dart
 lib/model/create_tag_dto.dart
 lib/model/create_tag_dto.dart
 lib/model/create_user_dto.dart
 lib/model/create_user_dto.dart
@@ -181,10 +179,10 @@ lib/model/delete_asset_dto.dart
 lib/model/delete_asset_response_dto.dart
 lib/model/delete_asset_response_dto.dart
 lib/model/delete_asset_status.dart
 lib/model/delete_asset_status.dart
 lib/model/download_files_dto.dart
 lib/model/download_files_dto.dart
-lib/model/edit_shared_link_dto.dart
 lib/model/exif_response_dto.dart
 lib/model/exif_response_dto.dart
 lib/model/get_asset_by_time_bucket_dto.dart
 lib/model/get_asset_by_time_bucket_dto.dart
 lib/model/get_asset_count_by_time_bucket_dto.dart
 lib/model/get_asset_count_by_time_bucket_dto.dart
+lib/model/import_asset_dto.dart
 lib/model/job_command.dart
 lib/model/job_command.dart
 lib/model/job_command_dto.dart
 lib/model/job_command_dto.dart
 lib/model/job_counts_dto.dart
 lib/model/job_counts_dto.dart
@@ -216,6 +214,8 @@ lib/model/server_info_response_dto.dart
 lib/model/server_ping_response.dart
 lib/model/server_ping_response.dart
 lib/model/server_stats_response_dto.dart
 lib/model/server_stats_response_dto.dart
 lib/model/server_version_reponse_dto.dart
 lib/model/server_version_reponse_dto.dart
+lib/model/shared_link_create_dto.dart
+lib/model/shared_link_edit_dto.dart
 lib/model/shared_link_response_dto.dart
 lib/model/shared_link_response_dto.dart
 lib/model/shared_link_type.dart
 lib/model/shared_link_type.dart
 lib/model/sign_up_dto.dart
 lib/model/sign_up_dto.dart
@@ -274,8 +274,6 @@ test/check_duplicate_asset_response_dto_test.dart
 test/check_existing_assets_dto_test.dart
 test/check_existing_assets_dto_test.dart
 test/check_existing_assets_response_dto_test.dart
 test/check_existing_assets_response_dto_test.dart
 test/create_album_dto_test.dart
 test/create_album_dto_test.dart
-test/create_album_share_link_dto_test.dart
-test/create_assets_share_link_dto_test.dart
 test/create_profile_image_response_dto_test.dart
 test/create_profile_image_response_dto_test.dart
 test/create_tag_dto_test.dart
 test/create_tag_dto_test.dart
 test/create_user_dto_test.dart
 test/create_user_dto_test.dart
@@ -285,10 +283,10 @@ test/delete_asset_dto_test.dart
 test/delete_asset_response_dto_test.dart
 test/delete_asset_response_dto_test.dart
 test/delete_asset_status_test.dart
 test/delete_asset_status_test.dart
 test/download_files_dto_test.dart
 test/download_files_dto_test.dart
-test/edit_shared_link_dto_test.dart
 test/exif_response_dto_test.dart
 test/exif_response_dto_test.dart
 test/get_asset_by_time_bucket_dto_test.dart
 test/get_asset_by_time_bucket_dto_test.dart
 test/get_asset_count_by_time_bucket_dto_test.dart
 test/get_asset_count_by_time_bucket_dto_test.dart
+test/import_asset_dto_test.dart
 test/job_api_test.dart
 test/job_api_test.dart
 test/job_command_dto_test.dart
 test/job_command_dto_test.dart
 test/job_command_test.dart
 test/job_command_test.dart
@@ -326,7 +324,9 @@ test/server_info_response_dto_test.dart
 test/server_ping_response_test.dart
 test/server_ping_response_test.dart
 test/server_stats_response_dto_test.dart
 test/server_stats_response_dto_test.dart
 test/server_version_reponse_dto_test.dart
 test/server_version_reponse_dto_test.dart
-test/share_api_test.dart
+test/shared_link_api_test.dart
+test/shared_link_create_dto_test.dart
+test/shared_link_edit_dto_test.dart
 test/shared_link_response_dto_test.dart
 test/shared_link_response_dto_test.dart
 test/shared_link_type_test.dart
 test/shared_link_type_test.dart
 test/sign_up_dto_test.dart
 test/sign_up_dto_test.dart

+ 12 - 12
mobile/openapi/README.md

@@ -80,7 +80,6 @@ Class | Method | HTTP request | Description
 *AlbumApi* | [**addAssetsToAlbum**](doc//AlbumApi.md#addassetstoalbum) | **PUT** /album/{id}/assets | 
 *AlbumApi* | [**addAssetsToAlbum**](doc//AlbumApi.md#addassetstoalbum) | **PUT** /album/{id}/assets | 
 *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | 
 *AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | 
 *AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album | 
 *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/{id} | 
 *AlbumApi* | [**deleteAlbum**](doc//AlbumApi.md#deletealbum) | **DELETE** /album/{id} | 
 *AlbumApi* | [**downloadArchive**](doc//AlbumApi.md#downloadarchive) | **GET** /album/{id}/download | 
 *AlbumApi* | [**downloadArchive**](doc//AlbumApi.md#downloadarchive) | **GET** /album/{id}/download | 
 *AlbumApi* | [**getAlbumCount**](doc//AlbumApi.md#getalbumcount) | **GET** /album/count | 
 *AlbumApi* | [**getAlbumCount**](doc//AlbumApi.md#getalbumcount) | **GET** /album/count | 
@@ -89,11 +88,9 @@ Class | Method | HTTP request | Description
 *AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{id}/assets | 
 *AlbumApi* | [**removeAssetFromAlbum**](doc//AlbumApi.md#removeassetfromalbum) | **DELETE** /album/{id}/assets | 
 *AlbumApi* | [**removeUserFromAlbum**](doc//AlbumApi.md#removeuserfromalbum) | **DELETE** /album/{id}/user/{userId} | 
 *AlbumApi* | [**removeUserFromAlbum**](doc//AlbumApi.md#removeuserfromalbum) | **DELETE** /album/{id}/user/{userId} | 
 *AlbumApi* | [**updateAlbumInfo**](doc//AlbumApi.md#updatealbuminfo) | **PATCH** /album/{id} | 
 *AlbumApi* | [**updateAlbumInfo**](doc//AlbumApi.md#updatealbuminfo) | **PATCH** /album/{id} | 
-*AssetApi* | [**addAssetsToSharedLink**](doc//AssetApi.md#addassetstosharedlink) | **PATCH** /asset/shared-link/add | 
 *AssetApi* | [**bulkUploadCheck**](doc//AssetApi.md#bulkuploadcheck) | **POST** /asset/bulk-upload-check | 
 *AssetApi* | [**bulkUploadCheck**](doc//AssetApi.md#bulkuploadcheck) | **POST** /asset/bulk-upload-check | 
 *AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 *AssetApi* | [**checkDuplicateAsset**](doc//AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 *AssetApi* | [**checkExistingAssets**](doc//AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
-*AssetApi* | [**createAssetsSharedLink**](doc//AssetApi.md#createassetssharedlink) | **POST** /asset/shared-link | 
 *AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset | 
 *AssetApi* | [**deleteAsset**](doc//AssetApi.md#deleteasset) | **DELETE** /asset | 
 *AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **GET** /asset/download/{id} | 
 *AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **GET** /asset/download/{id} | 
 *AssetApi* | [**downloadFiles**](doc//AssetApi.md#downloadfiles) | **POST** /asset/download-files | 
 *AssetApi* | [**downloadFiles**](doc//AssetApi.md#downloadfiles) | **POST** /asset/download-files | 
@@ -111,7 +108,7 @@ Class | Method | HTTP request | Description
 *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 *AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 *AssetApi* | [**getUserAssetsByDeviceId**](doc//AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
-*AssetApi* | [**removeAssetsFromSharedLink**](doc//AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | 
+*AssetApi* | [**importFile**](doc//AssetApi.md#importfile) | **POST** /asset/import | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**searchAsset**](doc//AssetApi.md#searchasset) | **POST** /asset/search | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 *AssetApi* | [**serveFile**](doc//AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 *AssetApi* | [**updateAsset**](doc//AssetApi.md#updateasset) | **PUT** /asset/{id} | 
@@ -146,11 +143,14 @@ Class | Method | HTTP request | Description
 *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 *ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version | 
 *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
 *ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats | 
 *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
 *ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping | 
-*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} | 
-*ShareApi* | [**updateSharedLink**](doc//ShareApi.md#updatesharedlink) | **PATCH** /share/{id} | 
+*SharedLinkApi* | [**addSharedLinkAssets**](doc//SharedLinkApi.md#addsharedlinkassets) | **PUT** /shared-link/{id}/assets | 
+*SharedLinkApi* | [**createSharedLink**](doc//SharedLinkApi.md#createsharedlink) | **POST** /shared-link | 
+*SharedLinkApi* | [**getAllSharedLinks**](doc//SharedLinkApi.md#getallsharedlinks) | **GET** /shared-link | 
+*SharedLinkApi* | [**getMySharedLink**](doc//SharedLinkApi.md#getmysharedlink) | **GET** /shared-link/me | 
+*SharedLinkApi* | [**getSharedLinkById**](doc//SharedLinkApi.md#getsharedlinkbyid) | **GET** /shared-link/{id} | 
+*SharedLinkApi* | [**removeSharedLink**](doc//SharedLinkApi.md#removesharedlink) | **DELETE** /shared-link/{id} | 
+*SharedLinkApi* | [**removeSharedLinkAssets**](doc//SharedLinkApi.md#removesharedlinkassets) | **DELETE** /shared-link/{id}/assets | 
+*SharedLinkApi* | [**updateSharedLink**](doc//SharedLinkApi.md#updatesharedlink) | **PATCH** /shared-link/{id} | 
 *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | 
 *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | 
 *SystemConfigApi* | [**getDefaults**](doc//SystemConfigApi.md#getdefaults) | **GET** /system-config/defaults | 
 *SystemConfigApi* | [**getDefaults**](doc//SystemConfigApi.md#getdefaults) | **GET** /system-config/defaults | 
 *SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | 
 *SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | 
@@ -207,8 +207,6 @@ Class | Method | HTTP request | Description
  - [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
  - [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
  - [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
  - [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
  - [CreateAlbumDto](doc//CreateAlbumDto.md)
  - [CreateAlbumDto](doc//CreateAlbumDto.md)
- - [CreateAlbumShareLinkDto](doc//CreateAlbumShareLinkDto.md)
- - [CreateAssetsShareLinkDto](doc//CreateAssetsShareLinkDto.md)
  - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
  - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md)
  - [CreateTagDto](doc//CreateTagDto.md)
  - [CreateTagDto](doc//CreateTagDto.md)
  - [CreateUserDto](doc//CreateUserDto.md)
  - [CreateUserDto](doc//CreateUserDto.md)
@@ -218,10 +216,10 @@ Class | Method | HTTP request | Description
  - [DeleteAssetResponseDto](doc//DeleteAssetResponseDto.md)
  - [DeleteAssetResponseDto](doc//DeleteAssetResponseDto.md)
  - [DeleteAssetStatus](doc//DeleteAssetStatus.md)
  - [DeleteAssetStatus](doc//DeleteAssetStatus.md)
  - [DownloadFilesDto](doc//DownloadFilesDto.md)
  - [DownloadFilesDto](doc//DownloadFilesDto.md)
- - [EditSharedLinkDto](doc//EditSharedLinkDto.md)
  - [ExifResponseDto](doc//ExifResponseDto.md)
  - [ExifResponseDto](doc//ExifResponseDto.md)
  - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md)
  - [GetAssetByTimeBucketDto](doc//GetAssetByTimeBucketDto.md)
  - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
  - [GetAssetCountByTimeBucketDto](doc//GetAssetCountByTimeBucketDto.md)
+ - [ImportAssetDto](doc//ImportAssetDto.md)
  - [JobCommand](doc//JobCommand.md)
  - [JobCommand](doc//JobCommand.md)
  - [JobCommandDto](doc//JobCommandDto.md)
  - [JobCommandDto](doc//JobCommandDto.md)
  - [JobCountsDto](doc//JobCountsDto.md)
  - [JobCountsDto](doc//JobCountsDto.md)
@@ -253,6 +251,8 @@ Class | Method | HTTP request | Description
  - [ServerPingResponse](doc//ServerPingResponse.md)
  - [ServerPingResponse](doc//ServerPingResponse.md)
  - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
  - [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
  - [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
  - [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)
+ - [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)
+ - [SharedLinkEditDto](doc//SharedLinkEditDto.md)
  - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
  - [SharedLinkResponseDto](doc//SharedLinkResponseDto.md)
  - [SharedLinkType](doc//SharedLinkType.md)
  - [SharedLinkType](doc//SharedLinkType.md)
  - [SignUpDto](doc//SignUpDto.md)
  - [SignUpDto](doc//SignUpDto.md)

+ 0 - 56
mobile/openapi/doc/AlbumApi.md

@@ -12,7 +12,6 @@ Method | HTTP request | Description
 [**addAssetsToAlbum**](AlbumApi.md#addassetstoalbum) | **PUT** /album/{id}/assets | 
 [**addAssetsToAlbum**](AlbumApi.md#addassetstoalbum) | **PUT** /album/{id}/assets | 
 [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | 
 [**addUsersToAlbum**](AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users | 
 [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | 
 [**createAlbum**](AlbumApi.md#createalbum) | **POST** /album | 
-[**createAlbumSharedLink**](AlbumApi.md#createalbumsharedlink) | **POST** /album/create-shared-link | 
 [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{id} | 
 [**deleteAlbum**](AlbumApi.md#deletealbum) | **DELETE** /album/{id} | 
 [**downloadArchive**](AlbumApi.md#downloadarchive) | **GET** /album/{id}/download | 
 [**downloadArchive**](AlbumApi.md#downloadarchive) | **GET** /album/{id}/download | 
 [**getAlbumCount**](AlbumApi.md#getalbumcount) | **GET** /album/count | 
 [**getAlbumCount**](AlbumApi.md#getalbumcount) | **GET** /album/count | 
@@ -194,61 +193,6 @@ 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)
 [[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 API key authorization: cookie
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
-// TODO Configure API key authorization: api_key
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
-// TODO Configure HTTP Bearer authorization: bearer
-// Case 1. Use String Token
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
-// Case 2. Use Function which generate token.
-// String yourTokenGeneratorFunction() { ... }
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
-
-final api_instance = 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
-
-[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
-
-### HTTP request headers
-
- - **Content-Type**: application/json
- - **Accept**: application/json
-
-[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
-
 # **deleteAlbum**
 # **deleteAlbum**
 > deleteAlbum(id)
 > deleteAlbum(id)
 
 

+ 1 - 0
mobile/openapi/doc/AlbumResponseDto.md

@@ -19,6 +19,7 @@ Name | Type | Description | Notes
 **sharedUsers** | [**List<UserResponseDto>**](UserResponseDto.md) |  | [default to const []]
 **sharedUsers** | [**List<UserResponseDto>**](UserResponseDto.md) |  | [default to const []]
 **assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [default to const []]
 **assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [default to const []]
 **owner** | [**UserResponseDto**](UserResponseDto.md) |  | 
 **owner** | [**UserResponseDto**](UserResponseDto.md) |  | 
+**lastModifiedAssetTimestamp** | [**DateTime**](DateTime.md) |  | [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)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 14 - 128
mobile/openapi/doc/AssetApi.md

@@ -9,11 +9,9 @@ All URIs are relative to */api*
 
 
 Method | HTTP request | Description
 Method | HTTP request | Description
 ------------- | ------------- | -------------
 ------------- | ------------- | -------------
-[**addAssetsToSharedLink**](AssetApi.md#addassetstosharedlink) | **PATCH** /asset/shared-link/add | 
 [**bulkUploadCheck**](AssetApi.md#bulkuploadcheck) | **POST** /asset/bulk-upload-check | 
 [**bulkUploadCheck**](AssetApi.md#bulkuploadcheck) | **POST** /asset/bulk-upload-check | 
 [**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 [**checkDuplicateAsset**](AssetApi.md#checkduplicateasset) | **POST** /asset/check | 
 [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
 [**checkExistingAssets**](AssetApi.md#checkexistingassets) | **POST** /asset/exist | 
-[**createAssetsSharedLink**](AssetApi.md#createassetssharedlink) | **POST** /asset/shared-link | 
 [**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset | 
 [**deleteAsset**](AssetApi.md#deleteasset) | **DELETE** /asset | 
 [**downloadFile**](AssetApi.md#downloadfile) | **GET** /asset/download/{id} | 
 [**downloadFile**](AssetApi.md#downloadfile) | **GET** /asset/download/{id} | 
 [**downloadFiles**](AssetApi.md#downloadfiles) | **POST** /asset/download-files | 
 [**downloadFiles**](AssetApi.md#downloadfiles) | **POST** /asset/download-files | 
@@ -31,70 +29,13 @@ Method | HTTP request | Description
 [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getMapMarkers**](AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | 
 [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 [**getMemoryLane**](AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
 [**getUserAssetsByDeviceId**](AssetApi.md#getuserassetsbydeviceid) | **GET** /asset/{deviceId} | 
-[**removeAssetsFromSharedLink**](AssetApi.md#removeassetsfromsharedlink) | **PATCH** /asset/shared-link/remove | 
+[**importFile**](AssetApi.md#importfile) | **POST** /asset/import | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**searchAsset**](AssetApi.md#searchasset) | **POST** /asset/search | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 [**serveFile**](AssetApi.md#servefile) | **GET** /asset/file/{id} | 
 [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 [**updateAsset**](AssetApi.md#updateasset) | **PUT** /asset/{id} | 
 [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | 
 [**uploadFile**](AssetApi.md#uploadfile) | **POST** /asset/upload | 
 
 
 
 
-# **addAssetsToSharedLink**
-> SharedLinkResponseDto addAssetsToSharedLink(addAssetsDto, key)
-
-
-
-### Example
-```dart
-import 'package:openapi/api.dart';
-// TODO Configure API key authorization: cookie
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
-// TODO Configure API key authorization: api_key
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
-// TODO Configure HTTP Bearer authorization: bearer
-// Case 1. Use String Token
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
-// Case 2. Use Function which generate token.
-// String yourTokenGeneratorFunction() { ... }
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
-
-final api_instance = AssetApi();
-final addAssetsDto = AddAssetsDto(); // AddAssetsDto | 
-final key = key_example; // String | 
-
-try {
-    final result = api_instance.addAssetsToSharedLink(addAssetsDto, key);
-    print(result);
-} catch (e) {
-    print('Exception when calling AssetApi->addAssetsToSharedLink: $e\n');
-}
-```
-
-### Parameters
-
-Name | Type | Description  | Notes
-------------- | ------------- | ------------- | -------------
- **addAssetsDto** | [**AddAssetsDto**](AddAssetsDto.md)|  | 
- **key** | **String**|  | [optional] 
-
-### Return type
-
-[**SharedLinkResponseDto**](SharedLinkResponseDto.md)
-
-### Authorization
-
-[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
-
-### HTTP request headers
-
- - **Content-Type**: application/json
- - **Accept**: application/json
-
-[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
-
 # **bulkUploadCheck**
 # **bulkUploadCheck**
 > AssetBulkUploadCheckResponseDto bulkUploadCheck(assetBulkUploadCheckDto)
 > AssetBulkUploadCheckResponseDto bulkUploadCheck(assetBulkUploadCheckDto)
 
 
@@ -268,61 +209,6 @@ 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)
 [[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)
 
 
-# **createAssetsSharedLink**
-> SharedLinkResponseDto createAssetsSharedLink(createAssetsShareLinkDto)
-
-
-
-### Example
-```dart
-import 'package:openapi/api.dart';
-// TODO Configure API key authorization: cookie
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
-// TODO Configure API key authorization: api_key
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
-// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
-//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
-// TODO Configure HTTP Bearer authorization: bearer
-// Case 1. Use String Token
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
-// Case 2. Use Function which generate token.
-// String yourTokenGeneratorFunction() { ... }
-//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
-
-final api_instance = AssetApi();
-final createAssetsShareLinkDto = CreateAssetsShareLinkDto(); // CreateAssetsShareLinkDto | 
-
-try {
-    final result = api_instance.createAssetsSharedLink(createAssetsShareLinkDto);
-    print(result);
-} catch (e) {
-    print('Exception when calling AssetApi->createAssetsSharedLink: $e\n');
-}
-```
-
-### Parameters
-
-Name | Type | Description  | Notes
-------------- | ------------- | ------------- | -------------
- **createAssetsShareLinkDto** | [**CreateAssetsShareLinkDto**](CreateAssetsShareLinkDto.md)|  | 
-
-### Return type
-
-[**SharedLinkResponseDto**](SharedLinkResponseDto.md)
-
-### Authorization
-
-[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
-
-### HTTP request headers
-
- - **Content-Type**: application/json
- - **Accept**: application/json
-
-[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
-
 # **deleteAsset**
 # **deleteAsset**
 > List<DeleteAssetResponseDto> deleteAsset(deleteAssetDto)
 > List<DeleteAssetResponseDto> deleteAsset(deleteAssetDto)
 
 
@@ -1274,8 +1160,8 @@ 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)
 [[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)
 
 
-# **removeAssetsFromSharedLink**
-> SharedLinkResponseDto removeAssetsFromSharedLink(removeAssetsDto, key)
+# **importFile**
+> AssetFileUploadResponseDto importFile(importAssetDto)
 
 
 
 
 
 
@@ -1298,14 +1184,13 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
 final api_instance = AssetApi();
 final api_instance = AssetApi();
-final removeAssetsDto = RemoveAssetsDto(); // RemoveAssetsDto | 
-final key = key_example; // String | 
+final importAssetDto = ImportAssetDto(); // ImportAssetDto | 
 
 
 try {
 try {
-    final result = api_instance.removeAssetsFromSharedLink(removeAssetsDto, key);
+    final result = api_instance.importFile(importAssetDto);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
-    print('Exception when calling AssetApi->removeAssetsFromSharedLink: $e\n');
+    print('Exception when calling AssetApi->importFile: $e\n');
 }
 }
 ```
 ```
 
 
@@ -1313,12 +1198,11 @@ try {
 
 
 Name | Type | Description  | Notes
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
 ------------- | ------------- | ------------- | -------------
- **removeAssetsDto** | [**RemoveAssetsDto**](RemoveAssetsDto.md)|  | 
- **key** | **String**|  | [optional] 
+ **importAssetDto** | [**ImportAssetDto**](ImportAssetDto.md)|  | 
 
 
 ### Return type
 ### Return type
 
 
-[**SharedLinkResponseDto**](SharedLinkResponseDto.md)
+[**AssetFileUploadResponseDto**](AssetFileUploadResponseDto.md)
 
 
 ### Authorization
 ### Authorization
 
 
@@ -1507,7 +1391,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 
 # **uploadFile**
 # **uploadFile**
-> AssetFileUploadResponseDto uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration)
+> AssetFileUploadResponseDto uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration)
 
 
 
 
 
 
@@ -1532,21 +1416,22 @@ import 'package:openapi/api.dart';
 final api_instance = AssetApi();
 final api_instance = AssetApi();
 final assetType = ; // AssetTypeEnum | 
 final assetType = ; // AssetTypeEnum | 
 final assetData = BINARY_DATA_HERE; // MultipartFile | 
 final assetData = BINARY_DATA_HERE; // MultipartFile | 
+final fileExtension = fileExtension_example; // String | 
 final deviceAssetId = deviceAssetId_example; // String | 
 final deviceAssetId = deviceAssetId_example; // String | 
 final deviceId = deviceId_example; // String | 
 final deviceId = deviceId_example; // String | 
 final fileCreatedAt = 2013-10-20T19:20:30+01:00; // DateTime | 
 final fileCreatedAt = 2013-10-20T19:20:30+01:00; // DateTime | 
 final fileModifiedAt = 2013-10-20T19:20:30+01:00; // DateTime | 
 final fileModifiedAt = 2013-10-20T19:20:30+01:00; // DateTime | 
 final isFavorite = true; // bool | 
 final isFavorite = true; // bool | 
-final fileExtension = fileExtension_example; // String | 
 final key = key_example; // String | 
 final key = key_example; // String | 
 final livePhotoData = BINARY_DATA_HERE; // MultipartFile | 
 final livePhotoData = BINARY_DATA_HERE; // MultipartFile | 
 final sidecarData = BINARY_DATA_HERE; // MultipartFile | 
 final sidecarData = BINARY_DATA_HERE; // MultipartFile | 
+final isReadOnly = true; // bool | 
 final isArchived = true; // bool | 
 final isArchived = true; // bool | 
 final isVisible = true; // bool | 
 final isVisible = true; // bool | 
 final duration = duration_example; // String | 
 final duration = duration_example; // String | 
 
 
 try {
 try {
-    final result = api_instance.uploadFile(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension, key, livePhotoData, sidecarData, isArchived, isVisible, duration);
+    final result = api_instance.uploadFile(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, key, livePhotoData, sidecarData, isReadOnly, isArchived, isVisible, duration);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
     print('Exception when calling AssetApi->uploadFile: $e\n');
     print('Exception when calling AssetApi->uploadFile: $e\n');
@@ -1559,15 +1444,16 @@ Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
 ------------- | ------------- | ------------- | -------------
  **assetType** | [**AssetTypeEnum**](AssetTypeEnum.md)|  | 
  **assetType** | [**AssetTypeEnum**](AssetTypeEnum.md)|  | 
  **assetData** | **MultipartFile**|  | 
  **assetData** | **MultipartFile**|  | 
+ **fileExtension** | **String**|  | 
  **deviceAssetId** | **String**|  | 
  **deviceAssetId** | **String**|  | 
  **deviceId** | **String**|  | 
  **deviceId** | **String**|  | 
  **fileCreatedAt** | **DateTime**|  | 
  **fileCreatedAt** | **DateTime**|  | 
  **fileModifiedAt** | **DateTime**|  | 
  **fileModifiedAt** | **DateTime**|  | 
  **isFavorite** | **bool**|  | 
  **isFavorite** | **bool**|  | 
- **fileExtension** | **String**|  | 
  **key** | **String**|  | [optional] 
  **key** | **String**|  | [optional] 
  **livePhotoData** | **MultipartFile**|  | [optional] 
  **livePhotoData** | **MultipartFile**|  | [optional] 
  **sidecarData** | **MultipartFile**|  | [optional] 
  **sidecarData** | **MultipartFile**|  | [optional] 
+ **isReadOnly** | **bool**|  | [optional] [default to false]
  **isArchived** | **bool**|  | [optional] 
  **isArchived** | **bool**|  | [optional] 
  **isVisible** | **bool**|  | [optional] 
  **isVisible** | **bool**|  | [optional] 
  **duration** | **String**|  | [optional] 
  **duration** | **String**|  | [optional] 

+ 0 - 20
mobile/openapi/doc/CreateAssetsShareLinkDto.md

@@ -1,20 +0,0 @@
-# openapi.model.CreateAssetsShareLinkDto
-
-## Load the model package
-```dart
-import 'package:openapi/api.dart';
-```
-
-## Properties
-Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
-**assetIds** | **List<String>** |  | [default to const []]
-**expiresAt** | [**DateTime**](DateTime.md) |  | [optional] 
-**allowUpload** | **bool** |  | [optional] 
-**allowDownload** | **bool** |  | [optional] 
-**showExif** | **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)
-
-

+ 1 - 0
mobile/openapi/doc/CreateUserDto.md

@@ -13,6 +13,7 @@ Name | Type | Description | Notes
 **firstName** | **String** |  | 
 **firstName** | **String** |  | 
 **lastName** | **String** |  | 
 **lastName** | **String** |  | 
 **storageLabel** | **String** |  | [optional] 
 **storageLabel** | **String** |  | [optional] 
+**externalPath** | **String** |  | [optional] 
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 26 - 0
mobile/openapi/doc/ImportAssetDto.md

@@ -0,0 +1,26 @@
+# openapi.model.ImportAssetDto
+
+## Load the model package
+```dart
+import 'package:openapi/api.dart';
+```
+
+## Properties
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**assetType** | [**AssetTypeEnum**](AssetTypeEnum.md) |  | 
+**isReadOnly** | **bool** |  | [optional] [default to true]
+**assetPath** | **String** |  | 
+**sidecarPath** | **String** |  | [optional] 
+**deviceAssetId** | **String** |  | 
+**deviceId** | **String** |  | 
+**fileCreatedAt** | [**DateTime**](DateTime.md) |  | 
+**fileModifiedAt** | [**DateTime**](DateTime.md) |  | 
+**isFavorite** | **bool** |  | 
+**isArchived** | **bool** |  | [optional] 
+**isVisible** | **bool** |  | [optional] 
+**duration** | **String** |  | [optional] 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+

+ 196 - 20
mobile/openapi/doc/ShareApi.md → mobile/openapi/doc/SharedLinkApi.md

@@ -1,4 +1,4 @@
-# openapi.api.ShareApi
+# openapi.api.SharedLinkApi
 
 
 ## Load the API package
 ## Load the API package
 ```dart
 ```dart
@@ -9,13 +9,130 @@ All URIs are relative to */api*
 
 
 Method | HTTP request | Description
 Method | HTTP request | Description
 ------------- | ------------- | -------------
 ------------- | ------------- | -------------
-[**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} | 
-[**updateSharedLink**](ShareApi.md#updatesharedlink) | **PATCH** /share/{id} | 
+[**addSharedLinkAssets**](SharedLinkApi.md#addsharedlinkassets) | **PUT** /shared-link/{id}/assets | 
+[**createSharedLink**](SharedLinkApi.md#createsharedlink) | **POST** /shared-link | 
+[**getAllSharedLinks**](SharedLinkApi.md#getallsharedlinks) | **GET** /shared-link | 
+[**getMySharedLink**](SharedLinkApi.md#getmysharedlink) | **GET** /shared-link/me | 
+[**getSharedLinkById**](SharedLinkApi.md#getsharedlinkbyid) | **GET** /shared-link/{id} | 
+[**removeSharedLink**](SharedLinkApi.md#removesharedlink) | **DELETE** /shared-link/{id} | 
+[**removeSharedLinkAssets**](SharedLinkApi.md#removesharedlinkassets) | **DELETE** /shared-link/{id}/assets | 
+[**updateSharedLink**](SharedLinkApi.md#updatesharedlink) | **PATCH** /shared-link/{id} | 
 
 
 
 
+# **addSharedLinkAssets**
+> List<AssetIdsResponseDto> addSharedLinkAssets(id, assetIdsDto, key)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = SharedLinkApi();
+final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final assetIdsDto = AssetIdsDto(); // AssetIdsDto | 
+final key = key_example; // String | 
+
+try {
+    final result = api_instance.addSharedLinkAssets(id, assetIdsDto, key);
+    print(result);
+} catch (e) {
+    print('Exception when calling SharedLinkApi->addSharedLinkAssets: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+ **assetIdsDto** | [**AssetIdsDto**](AssetIdsDto.md)|  | 
+ **key** | **String**|  | [optional] 
+
+### Return type
+
+[**List<AssetIdsResponseDto>**](AssetIdsResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
+# **createSharedLink**
+> SharedLinkResponseDto createSharedLink(sharedLinkCreateDto)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = SharedLinkApi();
+final sharedLinkCreateDto = SharedLinkCreateDto(); // SharedLinkCreateDto | 
+
+try {
+    final result = api_instance.createSharedLink(sharedLinkCreateDto);
+    print(result);
+} catch (e) {
+    print('Exception when calling SharedLinkApi->createSharedLink: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **sharedLinkCreateDto** | [**SharedLinkCreateDto**](SharedLinkCreateDto.md)|  | 
+
+### Return type
+
+[**SharedLinkResponseDto**](SharedLinkResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **getAllSharedLinks**
 # **getAllSharedLinks**
 > List<SharedLinkResponseDto> getAllSharedLinks()
 > List<SharedLinkResponseDto> getAllSharedLinks()
 
 
@@ -39,13 +156,13 @@ import 'package:openapi/api.dart';
 // String yourTokenGeneratorFunction() { ... }
 // String yourTokenGeneratorFunction() { ... }
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
-final api_instance = ShareApi();
+final api_instance = SharedLinkApi();
 
 
 try {
 try {
     final result = api_instance.getAllSharedLinks();
     final result = api_instance.getAllSharedLinks();
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
-    print('Exception when calling ShareApi->getAllSharedLinks: $e\n');
+    print('Exception when calling SharedLinkApi->getAllSharedLinks: $e\n');
 }
 }
 ```
 ```
 
 
@@ -90,14 +207,14 @@ import 'package:openapi/api.dart';
 // String yourTokenGeneratorFunction() { ... }
 // String yourTokenGeneratorFunction() { ... }
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
-final api_instance = ShareApi();
+final api_instance = SharedLinkApi();
 final key = key_example; // String | 
 final key = key_example; // String | 
 
 
 try {
 try {
     final result = api_instance.getMySharedLink(key);
     final result = api_instance.getMySharedLink(key);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
-    print('Exception when calling ShareApi->getMySharedLink: $e\n');
+    print('Exception when calling SharedLinkApi->getMySharedLink: $e\n');
 }
 }
 ```
 ```
 
 
@@ -145,14 +262,14 @@ import 'package:openapi/api.dart';
 // String yourTokenGeneratorFunction() { ... }
 // String yourTokenGeneratorFunction() { ... }
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
-final api_instance = ShareApi();
+final api_instance = SharedLinkApi();
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 
 
 try {
 try {
     final result = api_instance.getSharedLinkById(id);
     final result = api_instance.getSharedLinkById(id);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
-    print('Exception when calling ShareApi->getSharedLinkById: $e\n');
+    print('Exception when calling SharedLinkApi->getSharedLinkById: $e\n');
 }
 }
 ```
 ```
 
 
@@ -200,13 +317,13 @@ import 'package:openapi/api.dart';
 // String yourTokenGeneratorFunction() { ... }
 // String yourTokenGeneratorFunction() { ... }
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
-final api_instance = ShareApi();
+final api_instance = SharedLinkApi();
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 
 
 try {
 try {
     api_instance.removeSharedLink(id);
     api_instance.removeSharedLink(id);
 } catch (e) {
 } catch (e) {
-    print('Exception when calling ShareApi->removeSharedLink: $e\n');
+    print('Exception when calling SharedLinkApi->removeSharedLink: $e\n');
 }
 }
 ```
 ```
 
 
@@ -231,8 +348,67 @@ void (empty response body)
 
 
 [[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)
 [[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)
 
 
+# **removeSharedLinkAssets**
+> List<AssetIdsResponseDto> removeSharedLinkAssets(id, assetIdsDto, key)
+
+
+
+### Example
+```dart
+import 'package:openapi/api.dart';
+// TODO Configure API key authorization: cookie
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
+// TODO Configure API key authorization: api_key
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
+// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
+//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
+// TODO Configure HTTP Bearer authorization: bearer
+// Case 1. Use String Token
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
+// Case 2. Use Function which generate token.
+// String yourTokenGeneratorFunction() { ... }
+//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
+
+final api_instance = SharedLinkApi();
+final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
+final assetIdsDto = AssetIdsDto(); // AssetIdsDto | 
+final key = key_example; // String | 
+
+try {
+    final result = api_instance.removeSharedLinkAssets(id, assetIdsDto, key);
+    print(result);
+} catch (e) {
+    print('Exception when calling SharedLinkApi->removeSharedLinkAssets: $e\n');
+}
+```
+
+### Parameters
+
+Name | Type | Description  | Notes
+------------- | ------------- | ------------- | -------------
+ **id** | **String**|  | 
+ **assetIdsDto** | [**AssetIdsDto**](AssetIdsDto.md)|  | 
+ **key** | **String**|  | [optional] 
+
+### Return type
+
+[**List<AssetIdsResponseDto>**](AssetIdsResponseDto.md)
+
+### Authorization
+
+[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
+
+### HTTP request headers
+
+ - **Content-Type**: application/json
+ - **Accept**: application/json
+
+[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
+
 # **updateSharedLink**
 # **updateSharedLink**
-> SharedLinkResponseDto updateSharedLink(id, editSharedLinkDto)
+> SharedLinkResponseDto updateSharedLink(id, sharedLinkEditDto)
 
 
 
 
 
 
@@ -254,15 +430,15 @@ import 'package:openapi/api.dart';
 // String yourTokenGeneratorFunction() { ... }
 // String yourTokenGeneratorFunction() { ... }
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 
-final api_instance = ShareApi();
+final api_instance = SharedLinkApi();
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
 final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String | 
-final editSharedLinkDto = EditSharedLinkDto(); // EditSharedLinkDto | 
+final sharedLinkEditDto = SharedLinkEditDto(); // SharedLinkEditDto | 
 
 
 try {
 try {
-    final result = api_instance.updateSharedLink(id, editSharedLinkDto);
+    final result = api_instance.updateSharedLink(id, sharedLinkEditDto);
     print(result);
     print(result);
 } catch (e) {
 } catch (e) {
-    print('Exception when calling ShareApi->updateSharedLink: $e\n');
+    print('Exception when calling SharedLinkApi->updateSharedLink: $e\n');
 }
 }
 ```
 ```
 
 
@@ -271,7 +447,7 @@ try {
 Name | Type | Description  | Notes
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
 ------------- | ------------- | ------------- | -------------
  **id** | **String**|  | 
  **id** | **String**|  | 
- **editSharedLinkDto** | [**EditSharedLinkDto**](EditSharedLinkDto.md)|  | 
+ **sharedLinkEditDto** | [**SharedLinkEditDto**](SharedLinkEditDto.md)|  | 
 
 
 ### Return type
 ### Return type
 
 

+ 8 - 6
mobile/openapi/doc/CreateAlbumShareLinkDto.md → mobile/openapi/doc/SharedLinkCreateDto.md

@@ -1,4 +1,4 @@
-# openapi.model.CreateAlbumShareLinkDto
+# openapi.model.SharedLinkCreateDto
 
 
 ## Load the model package
 ## Load the model package
 ```dart
 ```dart
@@ -8,12 +8,14 @@ import 'package:openapi/api.dart';
 ## Properties
 ## Properties
 Name | Type | Description | Notes
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
-**albumId** | **String** |  | 
-**expiresAt** | [**DateTime**](DateTime.md) |  | [optional] 
-**allowUpload** | **bool** |  | [optional] 
-**allowDownload** | **bool** |  | [optional] 
-**showExif** | **bool** |  | [optional] 
+**type** | [**SharedLinkType**](SharedLinkType.md) |  | 
+**assetIds** | **List<String>** |  | [optional] [default to const []]
+**albumId** | **String** |  | [optional] 
 **description** | **String** |  | [optional] 
 **description** | **String** |  | [optional] 
+**expiresAt** | [**DateTime**](DateTime.md) |  | [optional] 
+**allowUpload** | **bool** |  | [optional] [default to false]
+**allowDownload** | **bool** |  | [optional] [default to true]
+**showExif** | **bool** |  | [optional] [default to true]
 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 
 

+ 1 - 1
mobile/openapi/doc/EditSharedLinkDto.md → mobile/openapi/doc/SharedLinkEditDto.md

@@ -1,4 +1,4 @@
-# openapi.model.EditSharedLinkDto
+# openapi.model.SharedLinkEditDto
 
 
 ## Load the model package
 ## Load the model package
 ```dart
 ```dart

+ 1 - 1
mobile/openapi/doc/SharedLinkResponseDto.md

@@ -10,7 +10,7 @@ Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 ------------ | ------------- | ------------- | -------------
 **type** | [**SharedLinkType**](SharedLinkType.md) |  | 
 **type** | [**SharedLinkType**](SharedLinkType.md) |  | 
 **id** | **String** |  | 
 **id** | **String** |  | 
-**description** | **String** |  | [optional] 
+**description** | **String** |  | 
 **userId** | **String** |  | 
 **userId** | **String** |  | 
 **key** | **String** |  | 
 **key** | **String** |  | 
 **createdAt** | [**DateTime**](DateTime.md) |  | 
 **createdAt** | [**DateTime**](DateTime.md) |  | 

+ 1 - 0
mobile/openapi/doc/UpdateUserDto.md

@@ -14,6 +14,7 @@ Name | Type | Description | Notes
 **firstName** | **String** |  | [optional] 
 **firstName** | **String** |  | [optional] 
 **lastName** | **String** |  | [optional] 
 **lastName** | **String** |  | [optional] 
 **storageLabel** | **String** |  | [optional] 
 **storageLabel** | **String** |  | [optional] 
+**externalPath** | **String** |  | [optional] 
 **isAdmin** | **bool** |  | [optional] 
 **isAdmin** | **bool** |  | [optional] 
 **shouldChangePassword** | **bool** |  | [optional] 
 **shouldChangePassword** | **bool** |  | [optional] 
 
 

+ 1 - 0
mobile/openapi/doc/UserResponseDto.md

@@ -13,6 +13,7 @@ Name | Type | Description | Notes
 **firstName** | **String** |  | 
 **firstName** | **String** |  | 
 **lastName** | **String** |  | 
 **lastName** | **String** |  | 
 **storageLabel** | **String** |  | 
 **storageLabel** | **String** |  | 
+**externalPath** | **String** |  | 
 **profileImagePath** | **String** |  | 
 **profileImagePath** | **String** |  | 
 **shouldChangePassword** | **bool** |  | 
 **shouldChangePassword** | **bool** |  | 
 **isAdmin** | **bool** |  | 
 **isAdmin** | **bool** |  | 

+ 4 - 4
mobile/openapi/lib/api.dart

@@ -38,7 +38,7 @@ part 'api/partner_api.dart';
 part 'api/person_api.dart';
 part 'api/person_api.dart';
 part 'api/search_api.dart';
 part 'api/search_api.dart';
 part 'api/server_info_api.dart';
 part 'api/server_info_api.dart';
-part 'api/share_api.dart';
+part 'api/shared_link_api.dart';
 part 'api/system_config_api.dart';
 part 'api/system_config_api.dart';
 part 'api/tag_api.dart';
 part 'api/tag_api.dart';
 part 'api/user_api.dart';
 part 'api/user_api.dart';
@@ -73,8 +73,6 @@ part 'model/check_duplicate_asset_response_dto.dart';
 part 'model/check_existing_assets_dto.dart';
 part 'model/check_existing_assets_dto.dart';
 part 'model/check_existing_assets_response_dto.dart';
 part 'model/check_existing_assets_response_dto.dart';
 part 'model/create_album_dto.dart';
 part 'model/create_album_dto.dart';
-part 'model/create_album_share_link_dto.dart';
-part 'model/create_assets_share_link_dto.dart';
 part 'model/create_profile_image_response_dto.dart';
 part 'model/create_profile_image_response_dto.dart';
 part 'model/create_tag_dto.dart';
 part 'model/create_tag_dto.dart';
 part 'model/create_user_dto.dart';
 part 'model/create_user_dto.dart';
@@ -84,10 +82,10 @@ part 'model/delete_asset_dto.dart';
 part 'model/delete_asset_response_dto.dart';
 part 'model/delete_asset_response_dto.dart';
 part 'model/delete_asset_status.dart';
 part 'model/delete_asset_status.dart';
 part 'model/download_files_dto.dart';
 part 'model/download_files_dto.dart';
-part 'model/edit_shared_link_dto.dart';
 part 'model/exif_response_dto.dart';
 part 'model/exif_response_dto.dart';
 part 'model/get_asset_by_time_bucket_dto.dart';
 part 'model/get_asset_by_time_bucket_dto.dart';
 part 'model/get_asset_count_by_time_bucket_dto.dart';
 part 'model/get_asset_count_by_time_bucket_dto.dart';
+part 'model/import_asset_dto.dart';
 part 'model/job_command.dart';
 part 'model/job_command.dart';
 part 'model/job_command_dto.dart';
 part 'model/job_command_dto.dart';
 part 'model/job_counts_dto.dart';
 part 'model/job_counts_dto.dart';
@@ -119,6 +117,8 @@ part 'model/server_info_response_dto.dart';
 part 'model/server_ping_response.dart';
 part 'model/server_ping_response.dart';
 part 'model/server_stats_response_dto.dart';
 part 'model/server_stats_response_dto.dart';
 part 'model/server_version_reponse_dto.dart';
 part 'model/server_version_reponse_dto.dart';
+part 'model/shared_link_create_dto.dart';
+part 'model/shared_link_edit_dto.dart';
 part 'model/shared_link_response_dto.dart';
 part 'model/shared_link_response_dto.dart';
 part 'model/shared_link_type.dart';
 part 'model/shared_link_type.dart';
 part 'model/sign_up_dto.dart';
 part 'model/sign_up_dto.dart';

+ 0 - 47
mobile/openapi/lib/api/album_api.dart

@@ -175,53 +175,6 @@ class AlbumApi {
     return null;
     return null;
   }
   }
 
 
-  /// Performs an HTTP 'POST /album/create-shared-link' operation and returns the [Response].
-  /// Parameters:
-  ///
-  /// * [CreateAlbumShareLinkDto] createAlbumShareLinkDto (required):
-  Future<Response> createAlbumSharedLinkWithHttpInfo(CreateAlbumShareLinkDto createAlbumShareLinkDto,) async {
-    // ignore: prefer_const_declarations
-    final path = r'/album/create-shared-link';
-
-    // ignore: prefer_final_locals
-    Object? postBody = createAlbumShareLinkDto;
-
-    final queryParams = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    const contentTypes = <String>['application/json'];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'POST',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  /// Parameters:
-  ///
-  /// * [CreateAlbumShareLinkDto] createAlbumShareLinkDto (required):
-  Future<SharedLinkResponseDto?> 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/{id}' operation and returns the [Response].
   /// Performs an HTTP 'DELETE /album/{id}' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///

+ 29 - 131
mobile/openapi/lib/api/asset_api.dart

@@ -16,61 +16,6 @@ class AssetApi {
 
 
   final ApiClient apiClient;
   final ApiClient apiClient;
 
 
-  /// Performs an HTTP 'PATCH /asset/shared-link/add' operation and returns the [Response].
-  /// Parameters:
-  ///
-  /// * [AddAssetsDto] addAssetsDto (required):
-  ///
-  /// * [String] key:
-  Future<Response> addAssetsToSharedLinkWithHttpInfo(AddAssetsDto addAssetsDto, { String? key, }) async {
-    // ignore: prefer_const_declarations
-    final path = r'/asset/shared-link/add';
-
-    // ignore: prefer_final_locals
-    Object? postBody = addAssetsDto;
-
-    final queryParams = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    if (key != null) {
-      queryParams.addAll(_queryParams('', 'key', key));
-    }
-
-    const contentTypes = <String>['application/json'];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'PATCH',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  /// Parameters:
-  ///
-  /// * [AddAssetsDto] addAssetsDto (required):
-  ///
-  /// * [String] key:
-  Future<SharedLinkResponseDto?> addAssetsToSharedLink(AddAssetsDto addAssetsDto, { String? key, }) async {
-    final response = await addAssetsToSharedLinkWithHttpInfo(addAssetsDto,  key: key, );
-    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;
-  }
-
   /// Checks if assets exist by checksums
   /// Checks if assets exist by checksums
   ///
   ///
   /// Note: This method returns the HTTP [Response].
   /// Note: This method returns the HTTP [Response].
@@ -235,53 +180,6 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
-  /// Performs an HTTP 'POST /asset/shared-link' operation and returns the [Response].
-  /// Parameters:
-  ///
-  /// * [CreateAssetsShareLinkDto] createAssetsShareLinkDto (required):
-  Future<Response> createAssetsSharedLinkWithHttpInfo(CreateAssetsShareLinkDto createAssetsShareLinkDto,) async {
-    // ignore: prefer_const_declarations
-    final path = r'/asset/shared-link';
-
-    // ignore: prefer_final_locals
-    Object? postBody = createAssetsShareLinkDto;
-
-    final queryParams = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    const contentTypes = <String>['application/json'];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'POST',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  /// Parameters:
-  ///
-  /// * [CreateAssetsShareLinkDto] createAssetsShareLinkDto (required):
-  Future<SharedLinkResponseDto?> createAssetsSharedLink(CreateAssetsShareLinkDto createAssetsShareLinkDto,) async {
-    final response = await createAssetsSharedLinkWithHttpInfo(createAssetsShareLinkDto,);
-    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 /asset' operation and returns the [Response].
   /// Performs an HTTP 'DELETE /asset' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///
@@ -1225,33 +1123,27 @@ class AssetApi {
     return null;
     return null;
   }
   }
 
 
-  /// Performs an HTTP 'PATCH /asset/shared-link/remove' operation and returns the [Response].
+  /// Performs an HTTP 'POST /asset/import' operation and returns the [Response].
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [RemoveAssetsDto] removeAssetsDto (required):
-  ///
-  /// * [String] key:
-  Future<Response> removeAssetsFromSharedLinkWithHttpInfo(RemoveAssetsDto removeAssetsDto, { String? key, }) async {
+  /// * [ImportAssetDto] importAssetDto (required):
+  Future<Response> importFileWithHttpInfo(ImportAssetDto importAssetDto,) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
-    final path = r'/asset/shared-link/remove';
+    final path = r'/asset/import';
 
 
     // ignore: prefer_final_locals
     // ignore: prefer_final_locals
-    Object? postBody = removeAssetsDto;
+    Object? postBody = importAssetDto;
 
 
     final queryParams = <QueryParam>[];
     final queryParams = <QueryParam>[];
     final headerParams = <String, String>{};
     final headerParams = <String, String>{};
     final formParams = <String, String>{};
     final formParams = <String, String>{};
 
 
-    if (key != null) {
-      queryParams.addAll(_queryParams('', 'key', key));
-    }
-
     const contentTypes = <String>['application/json'];
     const contentTypes = <String>['application/json'];
 
 
 
 
     return apiClient.invokeAPI(
     return apiClient.invokeAPI(
       path,
       path,
-      'PATCH',
+      'POST',
       queryParams,
       queryParams,
       postBody,
       postBody,
       headerParams,
       headerParams,
@@ -1262,11 +1154,9 @@ class AssetApi {
 
 
   /// Parameters:
   /// Parameters:
   ///
   ///
-  /// * [RemoveAssetsDto] removeAssetsDto (required):
-  ///
-  /// * [String] key:
-  Future<SharedLinkResponseDto?> removeAssetsFromSharedLink(RemoveAssetsDto removeAssetsDto, { String? key, }) async {
-    final response = await removeAssetsFromSharedLinkWithHttpInfo(removeAssetsDto,  key: key, );
+  /// * [ImportAssetDto] importAssetDto (required):
+  Future<AssetFileUploadResponseDto?> importFile(ImportAssetDto importAssetDto,) async {
+    final response = await importFileWithHttpInfo(importAssetDto,);
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }
@@ -1274,7 +1164,7 @@ class AssetApi {
     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
     // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
     // FormatException when trying to decode an empty string.
     // FormatException when trying to decode an empty string.
     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
     if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
-      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SharedLinkResponseDto',) as SharedLinkResponseDto;
+      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'AssetFileUploadResponseDto',) as AssetFileUploadResponseDto;
     
     
     }
     }
     return null;
     return null;
@@ -1464,6 +1354,8 @@ class AssetApi {
   ///
   ///
   /// * [MultipartFile] assetData (required):
   /// * [MultipartFile] assetData (required):
   ///
   ///
+  /// * [String] fileExtension (required):
+  ///
   /// * [String] deviceAssetId (required):
   /// * [String] deviceAssetId (required):
   ///
   ///
   /// * [String] deviceId (required):
   /// * [String] deviceId (required):
@@ -1474,20 +1366,20 @@ class AssetApi {
   ///
   ///
   /// * [bool] isFavorite (required):
   /// * [bool] isFavorite (required):
   ///
   ///
-  /// * [String] fileExtension (required):
-  ///
   /// * [String] key:
   /// * [String] key:
   ///
   ///
   /// * [MultipartFile] livePhotoData:
   /// * [MultipartFile] livePhotoData:
   ///
   ///
   /// * [MultipartFile] sidecarData:
   /// * [MultipartFile] sidecarData:
   ///
   ///
+  /// * [bool] isReadOnly:
+  ///
   /// * [bool] isArchived:
   /// * [bool] isArchived:
   ///
   ///
   /// * [bool] isVisible:
   /// * [bool] isVisible:
   ///
   ///
   /// * [String] duration:
   /// * [String] duration:
-  Future<Response> uploadFileWithHttpInfo(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, String fileExtension, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isArchived, bool? isVisible, String? duration, }) async {
+  Future<Response> uploadFileWithHttpInfo(AssetTypeEnum assetType, MultipartFile assetData, String fileExtension, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isReadOnly, bool? isArchived, bool? isVisible, String? duration, }) async {
     // ignore: prefer_const_declarations
     // ignore: prefer_const_declarations
     final path = r'/asset/upload';
     final path = r'/asset/upload';
 
 
@@ -1525,6 +1417,14 @@ class AssetApi {
       mp.fields[r'sidecarData'] = sidecarData.field;
       mp.fields[r'sidecarData'] = sidecarData.field;
       mp.files.add(sidecarData);
       mp.files.add(sidecarData);
     }
     }
+    if (isReadOnly != null) {
+      hasFields = true;
+      mp.fields[r'isReadOnly'] = parameterToString(isReadOnly);
+    }
+    if (fileExtension != null) {
+      hasFields = true;
+      mp.fields[r'fileExtension'] = parameterToString(fileExtension);
+    }
     if (deviceAssetId != null) {
     if (deviceAssetId != null) {
       hasFields = true;
       hasFields = true;
       mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
       mp.fields[r'deviceAssetId'] = parameterToString(deviceAssetId);
@@ -1553,10 +1453,6 @@ class AssetApi {
       hasFields = true;
       hasFields = true;
       mp.fields[r'isVisible'] = parameterToString(isVisible);
       mp.fields[r'isVisible'] = parameterToString(isVisible);
     }
     }
-    if (fileExtension != null) {
-      hasFields = true;
-      mp.fields[r'fileExtension'] = parameterToString(fileExtension);
-    }
     if (duration != null) {
     if (duration != null) {
       hasFields = true;
       hasFields = true;
       mp.fields[r'duration'] = parameterToString(duration);
       mp.fields[r'duration'] = parameterToString(duration);
@@ -1582,6 +1478,8 @@ class AssetApi {
   ///
   ///
   /// * [MultipartFile] assetData (required):
   /// * [MultipartFile] assetData (required):
   ///
   ///
+  /// * [String] fileExtension (required):
+  ///
   /// * [String] deviceAssetId (required):
   /// * [String] deviceAssetId (required):
   ///
   ///
   /// * [String] deviceId (required):
   /// * [String] deviceId (required):
@@ -1592,21 +1490,21 @@ class AssetApi {
   ///
   ///
   /// * [bool] isFavorite (required):
   /// * [bool] isFavorite (required):
   ///
   ///
-  /// * [String] fileExtension (required):
-  ///
   /// * [String] key:
   /// * [String] key:
   ///
   ///
   /// * [MultipartFile] livePhotoData:
   /// * [MultipartFile] livePhotoData:
   ///
   ///
   /// * [MultipartFile] sidecarData:
   /// * [MultipartFile] sidecarData:
   ///
   ///
+  /// * [bool] isReadOnly:
+  ///
   /// * [bool] isArchived:
   /// * [bool] isArchived:
   ///
   ///
   /// * [bool] isVisible:
   /// * [bool] isVisible:
   ///
   ///
   /// * [String] duration:
   /// * [String] duration:
-  Future<AssetFileUploadResponseDto?> uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, String fileExtension, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isArchived, bool? isVisible, String? duration, }) async {
-    final response = await uploadFileWithHttpInfo(assetType, assetData, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite, fileExtension,  key: key, livePhotoData: livePhotoData, sidecarData: sidecarData, isArchived: isArchived, isVisible: isVisible, duration: duration, );
+  Future<AssetFileUploadResponseDto?> uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String fileExtension, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String? key, MultipartFile? livePhotoData, MultipartFile? sidecarData, bool? isReadOnly, bool? isArchived, bool? isVisible, String? duration, }) async {
+    final response = await uploadFileWithHttpInfo(assetType, assetData, fileExtension, deviceAssetId, deviceId, fileCreatedAt, fileModifiedAt, isFavorite,  key: key, livePhotoData: livePhotoData, sidecarData: sidecarData, isReadOnly: isReadOnly, isArchived: isArchived, isVisible: isVisible, duration: duration, );
     if (response.statusCode >= HttpStatus.badRequest) {
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
     }

+ 0 - 253
mobile/openapi/lib/api/share_api.dart

@@ -1,253 +0,0 @@
-//
-// 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 'GET /share' operation and returns the [Response].
-  Future<Response> getAllSharedLinksWithHttpInfo() async {
-    // ignore: prefer_const_declarations
-    final path = r'/share';
-
-    // ignore: prefer_final_locals
-    Object? postBody;
-
-    final queryParams = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    const contentTypes = <String>[];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'GET',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  Future<List<SharedLinkResponseDto>?> 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<SharedLinkResponseDto>') as List)
-        .cast<SharedLinkResponseDto>()
-        .toList();
-
-    }
-    return null;
-  }
-
-  /// Performs an HTTP 'GET /share/me' operation and returns the [Response].
-  /// Parameters:
-  ///
-  /// * [String] key:
-  Future<Response> getMySharedLinkWithHttpInfo({ String? key, }) async {
-    // ignore: prefer_const_declarations
-    final path = r'/share/me';
-
-    // ignore: prefer_final_locals
-    Object? postBody;
-
-    final queryParams = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    if (key != null) {
-      queryParams.addAll(_queryParams('', 'key', key));
-    }
-
-    const contentTypes = <String>[];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'GET',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  /// Parameters:
-  ///
-  /// * [String] key:
-  Future<SharedLinkResponseDto?> getMySharedLink({ String? key, }) async {
-    final response = await getMySharedLinkWithHttpInfo( key: key, );
-    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<Response> getSharedLinkByIdWithHttpInfo(String id,) async {
-    // ignore: prefer_const_declarations
-    final path = r'/share/{id}'
-      .replaceAll('{id}', id);
-
-    // ignore: prefer_final_locals
-    Object? postBody;
-
-    final queryParams = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    const contentTypes = <String>[];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'GET',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  /// Parameters:
-  ///
-  /// * [String] id (required):
-  Future<SharedLinkResponseDto?> 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<Response> removeSharedLinkWithHttpInfo(String id,) async {
-    // ignore: prefer_const_declarations
-    final path = r'/share/{id}'
-      .replaceAll('{id}', id);
-
-    // ignore: prefer_final_locals
-    Object? postBody;
-
-    final queryParams = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    const contentTypes = <String>[];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'DELETE',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  /// Parameters:
-  ///
-  /// * [String] id (required):
-  Future<void> removeSharedLink(String id,) async {
-    final response = await removeSharedLinkWithHttpInfo(id,);
-    if (response.statusCode >= HttpStatus.badRequest) {
-      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
-    }
-  }
-
-  /// Performs an HTTP 'PATCH /share/{id}' operation and returns the [Response].
-  /// Parameters:
-  ///
-  /// * [String] id (required):
-  ///
-  /// * [EditSharedLinkDto] editSharedLinkDto (required):
-  Future<Response> updateSharedLinkWithHttpInfo(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 = <QueryParam>[];
-    final headerParams = <String, String>{};
-    final formParams = <String, String>{};
-
-    const contentTypes = <String>['application/json'];
-
-
-    return apiClient.invokeAPI(
-      path,
-      'PATCH',
-      queryParams,
-      postBody,
-      headerParams,
-      formParams,
-      contentTypes.isEmpty ? null : contentTypes.first,
-    );
-  }
-
-  /// Parameters:
-  ///
-  /// * [String] id (required):
-  ///
-  /// * [EditSharedLinkDto] editSharedLinkDto (required):
-  Future<SharedLinkResponseDto?> updateSharedLink(String id, EditSharedLinkDto editSharedLinkDto,) async {
-    final response = await updateSharedLinkWithHttpInfo(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;
-  }
-}

+ 426 - 0
mobile/openapi/lib/api/shared_link_api.dart

@@ -0,0 +1,426 @@
+//
+// 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 SharedLinkApi {
+  SharedLinkApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
+
+  final ApiClient apiClient;
+
+  /// Performs an HTTP 'PUT /shared-link/{id}/assets' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [AssetIdsDto] assetIdsDto (required):
+  ///
+  /// * [String] key:
+  Future<Response> addSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, }) async {
+    // ignore: prefer_const_declarations
+    final path = r'/shared-link/{id}/assets'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody = assetIdsDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    if (key != null) {
+      queryParams.addAll(_queryParams('', 'key', key));
+    }
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PUT',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [AssetIdsDto] assetIdsDto (required):
+  ///
+  /// * [String] key:
+  Future<List<AssetIdsResponseDto>?> addSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, }) async {
+    final response = await addSharedLinkAssetsWithHttpInfo(id, assetIdsDto,  key: key, );
+    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<AssetIdsResponseDto>') as List)
+        .cast<AssetIdsResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'POST /shared-link' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [SharedLinkCreateDto] sharedLinkCreateDto (required):
+  Future<Response> createSharedLinkWithHttpInfo(SharedLinkCreateDto sharedLinkCreateDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/shared-link';
+
+    // ignore: prefer_final_locals
+    Object? postBody = sharedLinkCreateDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'POST',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [SharedLinkCreateDto] sharedLinkCreateDto (required):
+  Future<SharedLinkResponseDto?> createSharedLink(SharedLinkCreateDto sharedLinkCreateDto,) async {
+    final response = await createSharedLinkWithHttpInfo(sharedLinkCreateDto,);
+    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 /shared-link' operation and returns the [Response].
+  Future<Response> getAllSharedLinksWithHttpInfo() async {
+    // ignore: prefer_const_declarations
+    final path = r'/shared-link';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  Future<List<SharedLinkResponseDto>?> 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<SharedLinkResponseDto>') as List)
+        .cast<SharedLinkResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'GET /shared-link/me' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] key:
+  Future<Response> getMySharedLinkWithHttpInfo({ String? key, }) async {
+    // ignore: prefer_const_declarations
+    final path = r'/shared-link/me';
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    if (key != null) {
+      queryParams.addAll(_queryParams('', 'key', key));
+    }
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] key:
+  Future<SharedLinkResponseDto?> getMySharedLink({ String? key, }) async {
+    final response = await getMySharedLinkWithHttpInfo( key: key, );
+    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 /shared-link/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<Response> getSharedLinkByIdWithHttpInfo(String id,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/shared-link/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'GET',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<SharedLinkResponseDto?> 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 /shared-link/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<Response> removeSharedLinkWithHttpInfo(String id,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/shared-link/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>[];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  Future<void> removeSharedLink(String id,) async {
+    final response = await removeSharedLinkWithHttpInfo(id,);
+    if (response.statusCode >= HttpStatus.badRequest) {
+      throw ApiException(response.statusCode, await _decodeBodyBytes(response));
+    }
+  }
+
+  /// Performs an HTTP 'DELETE /shared-link/{id}/assets' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [AssetIdsDto] assetIdsDto (required):
+  ///
+  /// * [String] key:
+  Future<Response> removeSharedLinkAssetsWithHttpInfo(String id, AssetIdsDto assetIdsDto, { String? key, }) async {
+    // ignore: prefer_const_declarations
+    final path = r'/shared-link/{id}/assets'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody = assetIdsDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    if (key != null) {
+      queryParams.addAll(_queryParams('', 'key', key));
+    }
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'DELETE',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [AssetIdsDto] assetIdsDto (required):
+  ///
+  /// * [String] key:
+  Future<List<AssetIdsResponseDto>?> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String? key, }) async {
+    final response = await removeSharedLinkAssetsWithHttpInfo(id, assetIdsDto,  key: key, );
+    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<AssetIdsResponseDto>') as List)
+        .cast<AssetIdsResponseDto>()
+        .toList();
+
+    }
+    return null;
+  }
+
+  /// Performs an HTTP 'PATCH /shared-link/{id}' operation and returns the [Response].
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [SharedLinkEditDto] sharedLinkEditDto (required):
+  Future<Response> updateSharedLinkWithHttpInfo(String id, SharedLinkEditDto sharedLinkEditDto,) async {
+    // ignore: prefer_const_declarations
+    final path = r'/shared-link/{id}'
+      .replaceAll('{id}', id);
+
+    // ignore: prefer_final_locals
+    Object? postBody = sharedLinkEditDto;
+
+    final queryParams = <QueryParam>[];
+    final headerParams = <String, String>{};
+    final formParams = <String, String>{};
+
+    const contentTypes = <String>['application/json'];
+
+
+    return apiClient.invokeAPI(
+      path,
+      'PATCH',
+      queryParams,
+      postBody,
+      headerParams,
+      formParams,
+      contentTypes.isEmpty ? null : contentTypes.first,
+    );
+  }
+
+  /// Parameters:
+  ///
+  /// * [String] id (required):
+  ///
+  /// * [SharedLinkEditDto] sharedLinkEditDto (required):
+  Future<SharedLinkResponseDto?> updateSharedLink(String id, SharedLinkEditDto sharedLinkEditDto,) async {
+    final response = await updateSharedLinkWithHttpInfo(id, sharedLinkEditDto,);
+    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;
+  }
+}

+ 6 - 6
mobile/openapi/lib/api_client.dart

@@ -241,10 +241,6 @@ class ApiClient {
           return CheckExistingAssetsResponseDto.fromJson(value);
           return CheckExistingAssetsResponseDto.fromJson(value);
         case 'CreateAlbumDto':
         case 'CreateAlbumDto':
           return CreateAlbumDto.fromJson(value);
           return CreateAlbumDto.fromJson(value);
-        case 'CreateAlbumShareLinkDto':
-          return CreateAlbumShareLinkDto.fromJson(value);
-        case 'CreateAssetsShareLinkDto':
-          return CreateAssetsShareLinkDto.fromJson(value);
         case 'CreateProfileImageResponseDto':
         case 'CreateProfileImageResponseDto':
           return CreateProfileImageResponseDto.fromJson(value);
           return CreateProfileImageResponseDto.fromJson(value);
         case 'CreateTagDto':
         case 'CreateTagDto':
@@ -263,14 +259,14 @@ class ApiClient {
           return DeleteAssetStatusTypeTransformer().decode(value);
           return DeleteAssetStatusTypeTransformer().decode(value);
         case 'DownloadFilesDto':
         case 'DownloadFilesDto':
           return DownloadFilesDto.fromJson(value);
           return DownloadFilesDto.fromJson(value);
-        case 'EditSharedLinkDto':
-          return EditSharedLinkDto.fromJson(value);
         case 'ExifResponseDto':
         case 'ExifResponseDto':
           return ExifResponseDto.fromJson(value);
           return ExifResponseDto.fromJson(value);
         case 'GetAssetByTimeBucketDto':
         case 'GetAssetByTimeBucketDto':
           return GetAssetByTimeBucketDto.fromJson(value);
           return GetAssetByTimeBucketDto.fromJson(value);
         case 'GetAssetCountByTimeBucketDto':
         case 'GetAssetCountByTimeBucketDto':
           return GetAssetCountByTimeBucketDto.fromJson(value);
           return GetAssetCountByTimeBucketDto.fromJson(value);
+        case 'ImportAssetDto':
+          return ImportAssetDto.fromJson(value);
         case 'JobCommand':
         case 'JobCommand':
           return JobCommandTypeTransformer().decode(value);
           return JobCommandTypeTransformer().decode(value);
         case 'JobCommandDto':
         case 'JobCommandDto':
@@ -333,6 +329,10 @@ class ApiClient {
           return ServerStatsResponseDto.fromJson(value);
           return ServerStatsResponseDto.fromJson(value);
         case 'ServerVersionReponseDto':
         case 'ServerVersionReponseDto':
           return ServerVersionReponseDto.fromJson(value);
           return ServerVersionReponseDto.fromJson(value);
+        case 'SharedLinkCreateDto':
+          return SharedLinkCreateDto.fromJson(value);
+        case 'SharedLinkEditDto':
+          return SharedLinkEditDto.fromJson(value);
         case 'SharedLinkResponseDto':
         case 'SharedLinkResponseDto':
           return SharedLinkResponseDto.fromJson(value);
           return SharedLinkResponseDto.fromJson(value);
         case 'SharedLinkType':
         case 'SharedLinkType':

+ 20 - 3
mobile/openapi/lib/model/album_response_dto.dart

@@ -24,6 +24,7 @@ class AlbumResponseDto {
     this.sharedUsers = const [],
     this.sharedUsers = const [],
     this.assets = const [],
     this.assets = const [],
     required this.owner,
     required this.owner,
+    this.lastModifiedAssetTimestamp,
   });
   });
 
 
   int assetCount;
   int assetCount;
@@ -48,6 +49,14 @@ class AlbumResponseDto {
 
 
   UserResponseDto owner;
   UserResponseDto owner;
 
 
+  ///
+  /// 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.
+  ///
+  DateTime? lastModifiedAssetTimestamp;
+
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
   bool operator ==(Object other) => identical(this, other) || other is AlbumResponseDto &&
      other.assetCount == assetCount &&
      other.assetCount == assetCount &&
@@ -60,7 +69,8 @@ class AlbumResponseDto {
      other.shared == shared &&
      other.shared == shared &&
      other.sharedUsers == sharedUsers &&
      other.sharedUsers == sharedUsers &&
      other.assets == assets &&
      other.assets == assets &&
-     other.owner == owner;
+     other.owner == owner &&
+     other.lastModifiedAssetTimestamp == lastModifiedAssetTimestamp;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
@@ -75,10 +85,11 @@ class AlbumResponseDto {
     (shared.hashCode) +
     (shared.hashCode) +
     (sharedUsers.hashCode) +
     (sharedUsers.hashCode) +
     (assets.hashCode) +
     (assets.hashCode) +
-    (owner.hashCode);
+    (owner.hashCode) +
+    (lastModifiedAssetTimestamp == null ? 0 : lastModifiedAssetTimestamp!.hashCode);
 
 
   @override
   @override
-  String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, updatedAt=$updatedAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets, owner=$owner]';
+  String toString() => 'AlbumResponseDto[assetCount=$assetCount, id=$id, ownerId=$ownerId, albumName=$albumName, createdAt=$createdAt, updatedAt=$updatedAt, albumThumbnailAssetId=$albumThumbnailAssetId, shared=$shared, sharedUsers=$sharedUsers, assets=$assets, owner=$owner, lastModifiedAssetTimestamp=$lastModifiedAssetTimestamp]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -97,6 +108,11 @@ class AlbumResponseDto {
       json[r'sharedUsers'] = this.sharedUsers;
       json[r'sharedUsers'] = this.sharedUsers;
       json[r'assets'] = this.assets;
       json[r'assets'] = this.assets;
       json[r'owner'] = this.owner;
       json[r'owner'] = this.owner;
+    if (this.lastModifiedAssetTimestamp != null) {
+      json[r'lastModifiedAssetTimestamp'] = this.lastModifiedAssetTimestamp!.toUtc().toIso8601String();
+    } else {
+      // json[r'lastModifiedAssetTimestamp'] = null;
+    }
     return json;
     return json;
   }
   }
 
 
@@ -130,6 +146,7 @@ class AlbumResponseDto {
         sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers']),
         sharedUsers: UserResponseDto.listFromJson(json[r'sharedUsers']),
         assets: AssetResponseDto.listFromJson(json[r'assets']),
         assets: AssetResponseDto.listFromJson(json[r'assets']),
         owner: UserResponseDto.fromJson(json[r'owner'])!,
         owner: UserResponseDto.fromJson(json[r'owner'])!,
+        lastModifiedAssetTimestamp: mapDateTime(json, r'lastModifiedAssetTimestamp', ''),
       );
       );
     }
     }
     return null;
     return null;

+ 0 - 194
mobile/openapi/lib/model/create_album_share_link_dto.dart

@@ -1,194 +0,0 @@
-//
-// 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.expiresAt,
-    this.allowUpload,
-    this.allowDownload,
-    this.showExif,
-    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.
-  ///
-  DateTime? expiresAt;
-
-  ///
-  /// 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? allowDownload;
-
-  ///
-  /// 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? showExif;
-
-  ///
-  /// 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.expiresAt == expiresAt &&
-     other.allowUpload == allowUpload &&
-     other.allowDownload == allowDownload &&
-     other.showExif == showExif &&
-     other.description == description;
-
-  @override
-  int get hashCode =>
-    // ignore: unnecessary_parenthesis
-    (albumId.hashCode) +
-    (expiresAt == null ? 0 : expiresAt!.hashCode) +
-    (allowUpload == null ? 0 : allowUpload!.hashCode) +
-    (allowDownload == null ? 0 : allowDownload!.hashCode) +
-    (showExif == null ? 0 : showExif!.hashCode) +
-    (description == null ? 0 : description!.hashCode);
-
-  @override
-  String toString() => 'CreateAlbumShareLinkDto[albumId=$albumId, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
-
-  Map<String, dynamic> toJson() {
-    final json = <String, dynamic>{};
-      json[r'albumId'] = this.albumId;
-    if (this.expiresAt != null) {
-      json[r'expiresAt'] = this.expiresAt!.toUtc().toIso8601String();
-    } else {
-      // json[r'expiresAt'] = null;
-    }
-    if (this.allowUpload != null) {
-      json[r'allowUpload'] = this.allowUpload;
-    } else {
-      // json[r'allowUpload'] = null;
-    }
-    if (this.allowDownload != null) {
-      json[r'allowDownload'] = this.allowDownload;
-    } else {
-      // json[r'allowDownload'] = null;
-    }
-    if (this.showExif != null) {
-      json[r'showExif'] = this.showExif;
-    } else {
-      // json[r'showExif'] = null;
-    }
-    if (this.description != null) {
-      json[r'description'] = this.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<String, dynamic>();
-
-      // Ensure that the map contains the required keys.
-      // Note 1: the values aren't checked for validity beyond being non-null.
-      // Note 2: this code is stripped in release mode!
-      assert(() {
-        requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "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<String>(json, r'albumId')!,
-        expiresAt: mapDateTime(json, r'expiresAt', ''),
-        allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
-        allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
-        showExif: mapValueOfType<bool>(json, r'showExif'),
-        description: mapValueOfType<String>(json, r'description'),
-      );
-    }
-    return null;
-  }
-
-  static List<CreateAlbumShareLinkDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <CreateAlbumShareLinkDto>[];
-    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<String, CreateAlbumShareLinkDto> mapFromJson(dynamic json) {
-    final map = <String, CreateAlbumShareLinkDto>{};
-    if (json is Map && json.isNotEmpty) {
-      json = json.cast<String, dynamic>(); // 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<String, List<CreateAlbumShareLinkDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<CreateAlbumShareLinkDto>>{};
-    if (json is Map && json.isNotEmpty) {
-      // ignore: parameter_assignments
-      json = json.cast<String, dynamic>();
-      for (final entry in json.entries) {
-        map[entry.key] = CreateAlbumShareLinkDto.listFromJson(entry.value, growable: growable,);
-      }
-    }
-    return map;
-  }
-
-  /// The list of required keys that must be present in a JSON.
-  static const requiredKeys = <String>{
-    'albumId',
-  };
-}
-

+ 14 - 3
mobile/openapi/lib/model/create_user_dto.dart

@@ -18,6 +18,7 @@ class CreateUserDto {
     required this.firstName,
     required this.firstName,
     required this.lastName,
     required this.lastName,
     this.storageLabel,
     this.storageLabel,
+    this.externalPath,
   });
   });
 
 
   String email;
   String email;
@@ -30,13 +31,16 @@ class CreateUserDto {
 
 
   String? storageLabel;
   String? storageLabel;
 
 
+  String? externalPath;
+
   @override
   @override
   bool operator ==(Object other) => identical(this, other) || other is CreateUserDto &&
   bool operator ==(Object other) => identical(this, other) || other is CreateUserDto &&
      other.email == email &&
      other.email == email &&
      other.password == password &&
      other.password == password &&
      other.firstName == firstName &&
      other.firstName == firstName &&
      other.lastName == lastName &&
      other.lastName == lastName &&
-     other.storageLabel == storageLabel;
+     other.storageLabel == storageLabel &&
+     other.externalPath == externalPath;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
@@ -45,10 +49,11 @@ class CreateUserDto {
     (password.hashCode) +
     (password.hashCode) +
     (firstName.hashCode) +
     (firstName.hashCode) +
     (lastName.hashCode) +
     (lastName.hashCode) +
-    (storageLabel == null ? 0 : storageLabel!.hashCode);
+    (storageLabel == null ? 0 : storageLabel!.hashCode) +
+    (externalPath == null ? 0 : externalPath!.hashCode);
 
 
   @override
   @override
-  String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel]';
+  String toString() => 'CreateUserDto[email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, externalPath=$externalPath]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -61,6 +66,11 @@ class CreateUserDto {
     } else {
     } else {
       // json[r'storageLabel'] = null;
       // json[r'storageLabel'] = null;
     }
     }
+    if (this.externalPath != null) {
+      json[r'externalPath'] = this.externalPath;
+    } else {
+      // json[r'externalPath'] = null;
+    }
     return json;
     return json;
   }
   }
 
 
@@ -88,6 +98,7 @@ class CreateUserDto {
         firstName: mapValueOfType<String>(json, r'firstName')!,
         firstName: mapValueOfType<String>(json, r'firstName')!,
         lastName: mapValueOfType<String>(json, r'lastName')!,
         lastName: mapValueOfType<String>(json, r'lastName')!,
         storageLabel: mapValueOfType<String>(json, r'storageLabel'),
         storageLabel: mapValueOfType<String>(json, r'storageLabel'),
+        externalPath: mapValueOfType<String>(json, r'externalPath'),
       );
       );
     }
     }
     return null;
     return null;

+ 232 - 0
mobile/openapi/lib/model/import_asset_dto.dart

@@ -0,0 +1,232 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class ImportAssetDto {
+  /// Returns a new [ImportAssetDto] instance.
+  ImportAssetDto({
+    required this.assetType,
+    this.isReadOnly = true,
+    required this.assetPath,
+    this.sidecarPath,
+    required this.deviceAssetId,
+    required this.deviceId,
+    required this.fileCreatedAt,
+    required this.fileModifiedAt,
+    required this.isFavorite,
+    this.isArchived,
+    this.isVisible,
+    this.duration,
+  });
+
+  AssetTypeEnum assetType;
+
+  bool isReadOnly;
+
+  String assetPath;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? sidecarPath;
+
+  String deviceAssetId;
+
+  String deviceId;
+
+  DateTime fileCreatedAt;
+
+  DateTime fileModifiedAt;
+
+  bool isFavorite;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  bool? isArchived;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  bool? isVisible;
+
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? duration;
+
+  @override
+  bool operator ==(Object other) => identical(this, other) || other is ImportAssetDto &&
+     other.assetType == assetType &&
+     other.isReadOnly == isReadOnly &&
+     other.assetPath == assetPath &&
+     other.sidecarPath == sidecarPath &&
+     other.deviceAssetId == deviceAssetId &&
+     other.deviceId == deviceId &&
+     other.fileCreatedAt == fileCreatedAt &&
+     other.fileModifiedAt == fileModifiedAt &&
+     other.isFavorite == isFavorite &&
+     other.isArchived == isArchived &&
+     other.isVisible == isVisible &&
+     other.duration == duration;
+
+  @override
+  int get hashCode =>
+    // ignore: unnecessary_parenthesis
+    (assetType.hashCode) +
+    (isReadOnly.hashCode) +
+    (assetPath.hashCode) +
+    (sidecarPath == null ? 0 : sidecarPath!.hashCode) +
+    (deviceAssetId.hashCode) +
+    (deviceId.hashCode) +
+    (fileCreatedAt.hashCode) +
+    (fileModifiedAt.hashCode) +
+    (isFavorite.hashCode) +
+    (isArchived == null ? 0 : isArchived!.hashCode) +
+    (isVisible == null ? 0 : isVisible!.hashCode) +
+    (duration == null ? 0 : duration!.hashCode);
+
+  @override
+  String toString() => 'ImportAssetDto[assetType=$assetType, isReadOnly=$isReadOnly, assetPath=$assetPath, sidecarPath=$sidecarPath, deviceAssetId=$deviceAssetId, deviceId=$deviceId, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, isFavorite=$isFavorite, isArchived=$isArchived, isVisible=$isVisible, duration=$duration]';
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+      json[r'assetType'] = this.assetType;
+      json[r'isReadOnly'] = this.isReadOnly;
+      json[r'assetPath'] = this.assetPath;
+    if (this.sidecarPath != null) {
+      json[r'sidecarPath'] = this.sidecarPath;
+    } else {
+      // json[r'sidecarPath'] = null;
+    }
+      json[r'deviceAssetId'] = this.deviceAssetId;
+      json[r'deviceId'] = this.deviceId;
+      json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
+      json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
+      json[r'isFavorite'] = this.isFavorite;
+    if (this.isArchived != null) {
+      json[r'isArchived'] = this.isArchived;
+    } else {
+      // json[r'isArchived'] = null;
+    }
+    if (this.isVisible != null) {
+      json[r'isVisible'] = this.isVisible;
+    } else {
+      // json[r'isVisible'] = null;
+    }
+    if (this.duration != null) {
+      json[r'duration'] = this.duration;
+    } else {
+      // json[r'duration'] = null;
+    }
+    return json;
+  }
+
+  /// Returns a new [ImportAssetDto] instance and imports its values from
+  /// [value] if it's a [Map], null otherwise.
+  // ignore: prefer_constructors_over_static_methods
+  static ImportAssetDto? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+
+      // Ensure that the map contains the required keys.
+      // Note 1: the values aren't checked for validity beyond being non-null.
+      // Note 2: this code is stripped in release mode!
+      assert(() {
+        requiredKeys.forEach((key) {
+          assert(json.containsKey(key), 'Required key "ImportAssetDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "ImportAssetDto[$key]" has a null value in JSON.');
+        });
+        return true;
+      }());
+
+      return ImportAssetDto(
+        assetType: AssetTypeEnum.fromJson(json[r'assetType'])!,
+        isReadOnly: mapValueOfType<bool>(json, r'isReadOnly') ?? true,
+        assetPath: mapValueOfType<String>(json, r'assetPath')!,
+        sidecarPath: mapValueOfType<String>(json, r'sidecarPath'),
+        deviceAssetId: mapValueOfType<String>(json, r'deviceAssetId')!,
+        deviceId: mapValueOfType<String>(json, r'deviceId')!,
+        fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!,
+        fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!,
+        isFavorite: mapValueOfType<bool>(json, r'isFavorite')!,
+        isArchived: mapValueOfType<bool>(json, r'isArchived'),
+        isVisible: mapValueOfType<bool>(json, r'isVisible'),
+        duration: mapValueOfType<String>(json, r'duration'),
+      );
+    }
+    return null;
+  }
+
+  static List<ImportAssetDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <ImportAssetDto>[];
+    if (json is List && json.isNotEmpty) {
+      for (final row in json) {
+        final value = ImportAssetDto.fromJson(row);
+        if (value != null) {
+          result.add(value);
+        }
+      }
+    }
+    return result.toList(growable: growable);
+  }
+
+  static Map<String, ImportAssetDto> mapFromJson(dynamic json) {
+    final map = <String, ImportAssetDto>{};
+    if (json is Map && json.isNotEmpty) {
+      json = json.cast<String, dynamic>(); // ignore: parameter_assignments
+      for (final entry in json.entries) {
+        final value = ImportAssetDto.fromJson(entry.value);
+        if (value != null) {
+          map[entry.key] = value;
+        }
+      }
+    }
+    return map;
+  }
+
+  // maps a json object with a list of ImportAssetDto-objects as value to a dart map
+  static Map<String, List<ImportAssetDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<ImportAssetDto>>{};
+    if (json is Map && json.isNotEmpty) {
+      // ignore: parameter_assignments
+      json = json.cast<String, dynamic>();
+      for (final entry in json.entries) {
+        map[entry.key] = ImportAssetDto.listFromJson(entry.value, growable: growable,);
+      }
+    }
+    return map;
+  }
+
+  /// The list of required keys that must be present in a JSON.
+  static const requiredKeys = <String>{
+    'assetType',
+    'assetPath',
+    'deviceAssetId',
+    'deviceId',
+    'fileCreatedAt',
+    'fileModifiedAt',
+    'isFavorite',
+  };
+}
+

+ 63 - 75
mobile/openapi/lib/model/create_assets_share_link_dto.dart → mobile/openapi/lib/model/shared_link_create_dto.dart

@@ -10,17 +10,21 @@
 
 
 part of openapi.api;
 part of openapi.api;
 
 
-class CreateAssetsShareLinkDto {
-  /// Returns a new [CreateAssetsShareLinkDto] instance.
-  CreateAssetsShareLinkDto({
+class SharedLinkCreateDto {
+  /// Returns a new [SharedLinkCreateDto] instance.
+  SharedLinkCreateDto({
+    required this.type,
     this.assetIds = const [],
     this.assetIds = const [],
-    this.expiresAt,
-    this.allowUpload,
-    this.allowDownload,
-    this.showExif,
+    this.albumId,
     this.description,
     this.description,
+    this.expiresAt,
+    this.allowUpload = false,
+    this.allowDownload = true,
+    this.showExif = true,
   });
   });
 
 
+  SharedLinkType type;
+
   List<String> assetIds;
   List<String> assetIds;
 
 
   ///
   ///
@@ -29,7 +33,7 @@ class CreateAssetsShareLinkDto {
   /// source code must fall back to having a nullable type.
   /// source code must fall back to having a nullable type.
   /// Consider adding a "default:" property in the specification file to hide this note.
   /// Consider adding a "default:" property in the specification file to hide this note.
   ///
   ///
-  DateTime? expiresAt;
+  String? albumId;
 
 
   ///
   ///
   /// Please note: This property should have been non-nullable! Since the specification file
   /// Please note: This property should have been non-nullable! Since the specification file
@@ -37,89 +41,71 @@ class CreateAssetsShareLinkDto {
   /// source code must fall back to having a nullable type.
   /// source code must fall back to having a nullable type.
   /// Consider adding a "default:" property in the specification file to hide this note.
   /// Consider adding a "default:" property in the specification file to hide this note.
   ///
   ///
-  bool? allowUpload;
+  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.
-  ///
-  bool? allowDownload;
+  DateTime? expiresAt;
 
 
-  ///
-  /// 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? showExif;
+  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;
+  bool allowDownload;
+
+  bool showExif;
 
 
   @override
   @override
-  bool operator ==(Object other) => identical(this, other) || other is CreateAssetsShareLinkDto &&
+  bool operator ==(Object other) => identical(this, other) || other is SharedLinkCreateDto &&
+     other.type == type &&
      other.assetIds == assetIds &&
      other.assetIds == assetIds &&
+     other.albumId == albumId &&
+     other.description == description &&
      other.expiresAt == expiresAt &&
      other.expiresAt == expiresAt &&
      other.allowUpload == allowUpload &&
      other.allowUpload == allowUpload &&
      other.allowDownload == allowDownload &&
      other.allowDownload == allowDownload &&
-     other.showExif == showExif &&
-     other.description == description;
+     other.showExif == showExif;
 
 
   @override
   @override
   int get hashCode =>
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     // ignore: unnecessary_parenthesis
+    (type.hashCode) +
     (assetIds.hashCode) +
     (assetIds.hashCode) +
+    (albumId == null ? 0 : albumId!.hashCode) +
+    (description == null ? 0 : description!.hashCode) +
     (expiresAt == null ? 0 : expiresAt!.hashCode) +
     (expiresAt == null ? 0 : expiresAt!.hashCode) +
-    (allowUpload == null ? 0 : allowUpload!.hashCode) +
-    (allowDownload == null ? 0 : allowDownload!.hashCode) +
-    (showExif == null ? 0 : showExif!.hashCode) +
-    (description == null ? 0 : description!.hashCode);
+    (allowUpload.hashCode) +
+    (allowDownload.hashCode) +
+    (showExif.hashCode);
 
 
   @override
   @override
-  String toString() => 'CreateAssetsShareLinkDto[assetIds=$assetIds, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
+  String toString() => 'SharedLinkCreateDto[type=$type, assetIds=$assetIds, albumId=$albumId, description=$description, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
+      json[r'type'] = this.type;
       json[r'assetIds'] = this.assetIds;
       json[r'assetIds'] = this.assetIds;
+    if (this.albumId != null) {
+      json[r'albumId'] = this.albumId;
+    } else {
+      // json[r'albumId'] = null;
+    }
+    if (this.description != null) {
+      json[r'description'] = this.description;
+    } else {
+      // json[r'description'] = null;
+    }
     if (this.expiresAt != null) {
     if (this.expiresAt != null) {
       json[r'expiresAt'] = this.expiresAt!.toUtc().toIso8601String();
       json[r'expiresAt'] = this.expiresAt!.toUtc().toIso8601String();
     } else {
     } else {
       // json[r'expiresAt'] = null;
       // json[r'expiresAt'] = null;
     }
     }
-    if (this.allowUpload != null) {
       json[r'allowUpload'] = this.allowUpload;
       json[r'allowUpload'] = this.allowUpload;
-    } else {
-      // json[r'allowUpload'] = null;
-    }
-    if (this.allowDownload != null) {
       json[r'allowDownload'] = this.allowDownload;
       json[r'allowDownload'] = this.allowDownload;
-    } else {
-      // json[r'allowDownload'] = null;
-    }
-    if (this.showExif != null) {
       json[r'showExif'] = this.showExif;
       json[r'showExif'] = this.showExif;
-    } else {
-      // json[r'showExif'] = null;
-    }
-    if (this.description != null) {
-      json[r'description'] = this.description;
-    } else {
-      // json[r'description'] = null;
-    }
     return json;
     return json;
   }
   }
 
 
-  /// Returns a new [CreateAssetsShareLinkDto] instance and imports its values from
+  /// Returns a new [SharedLinkCreateDto] instance and imports its values from
   /// [value] if it's a [Map], null otherwise.
   /// [value] if it's a [Map], null otherwise.
   // ignore: prefer_constructors_over_static_methods
   // ignore: prefer_constructors_over_static_methods
-  static CreateAssetsShareLinkDto? fromJson(dynamic value) {
+  static SharedLinkCreateDto? fromJson(dynamic value) {
     if (value is Map) {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
       final json = value.cast<String, dynamic>();
 
 
@@ -128,31 +114,33 @@ class CreateAssetsShareLinkDto {
       // Note 2: this code is stripped in release mode!
       // Note 2: this code is stripped in release mode!
       assert(() {
       assert(() {
         requiredKeys.forEach((key) {
         requiredKeys.forEach((key) {
-          assert(json.containsKey(key), 'Required key "CreateAssetsShareLinkDto[$key]" is missing from JSON.');
-          assert(json[key] != null, 'Required key "CreateAssetsShareLinkDto[$key]" has a null value in JSON.');
+          assert(json.containsKey(key), 'Required key "SharedLinkCreateDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "SharedLinkCreateDto[$key]" has a null value in JSON.');
         });
         });
         return true;
         return true;
       }());
       }());
 
 
-      return CreateAssetsShareLinkDto(
+      return SharedLinkCreateDto(
+        type: SharedLinkType.fromJson(json[r'type'])!,
         assetIds: json[r'assetIds'] is Iterable
         assetIds: json[r'assetIds'] is Iterable
             ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
             ? (json[r'assetIds'] as Iterable).cast<String>().toList(growable: false)
             : const [],
             : const [],
-        expiresAt: mapDateTime(json, r'expiresAt', ''),
-        allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
-        allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
-        showExif: mapValueOfType<bool>(json, r'showExif'),
+        albumId: mapValueOfType<String>(json, r'albumId'),
         description: mapValueOfType<String>(json, r'description'),
         description: mapValueOfType<String>(json, r'description'),
+        expiresAt: mapDateTime(json, r'expiresAt', ''),
+        allowUpload: mapValueOfType<bool>(json, r'allowUpload') ?? false,
+        allowDownload: mapValueOfType<bool>(json, r'allowDownload') ?? true,
+        showExif: mapValueOfType<bool>(json, r'showExif') ?? true,
       );
       );
     }
     }
     return null;
     return null;
   }
   }
 
 
-  static List<CreateAssetsShareLinkDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <CreateAssetsShareLinkDto>[];
+  static List<SharedLinkCreateDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SharedLinkCreateDto>[];
     if (json is List && json.isNotEmpty) {
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
       for (final row in json) {
-        final value = CreateAssetsShareLinkDto.fromJson(row);
+        final value = SharedLinkCreateDto.fromJson(row);
         if (value != null) {
         if (value != null) {
           result.add(value);
           result.add(value);
         }
         }
@@ -161,12 +149,12 @@ class CreateAssetsShareLinkDto {
     return result.toList(growable: growable);
     return result.toList(growable: growable);
   }
   }
 
 
-  static Map<String, CreateAssetsShareLinkDto> mapFromJson(dynamic json) {
-    final map = <String, CreateAssetsShareLinkDto>{};
+  static Map<String, SharedLinkCreateDto> mapFromJson(dynamic json) {
+    final map = <String, SharedLinkCreateDto>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        final value = CreateAssetsShareLinkDto.fromJson(entry.value);
+        final value = SharedLinkCreateDto.fromJson(entry.value);
         if (value != null) {
         if (value != null) {
           map[entry.key] = value;
           map[entry.key] = value;
         }
         }
@@ -175,14 +163,14 @@ class CreateAssetsShareLinkDto {
     return map;
     return map;
   }
   }
 
 
-  // maps a json object with a list of CreateAssetsShareLinkDto-objects as value to a dart map
-  static Map<String, List<CreateAssetsShareLinkDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<CreateAssetsShareLinkDto>>{};
+  // maps a json object with a list of SharedLinkCreateDto-objects as value to a dart map
+  static Map<String, List<SharedLinkCreateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SharedLinkCreateDto>>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       // ignore: parameter_assignments
       // ignore: parameter_assignments
       json = json.cast<String, dynamic>();
       json = json.cast<String, dynamic>();
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        map[entry.key] = CreateAssetsShareLinkDto.listFromJson(entry.value, growable: growable,);
+        map[entry.key] = SharedLinkCreateDto.listFromJson(entry.value, growable: growable,);
       }
       }
     }
     }
     return map;
     return map;
@@ -190,7 +178,7 @@ class CreateAssetsShareLinkDto {
 
 
   /// The list of required keys that must be present in a JSON.
   /// The list of required keys that must be present in a JSON.
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
-    'assetIds',
+    'type',
   };
   };
 }
 }
 
 

+ 20 - 20
mobile/openapi/lib/model/edit_shared_link_dto.dart → mobile/openapi/lib/model/shared_link_edit_dto.dart

@@ -10,9 +10,9 @@
 
 
 part of openapi.api;
 part of openapi.api;
 
 
-class EditSharedLinkDto {
-  /// Returns a new [EditSharedLinkDto] instance.
-  EditSharedLinkDto({
+class SharedLinkEditDto {
+  /// Returns a new [SharedLinkEditDto] instance.
+  SharedLinkEditDto({
     this.description,
     this.description,
     this.expiresAt,
     this.expiresAt,
     this.allowUpload,
     this.allowUpload,
@@ -55,7 +55,7 @@ class EditSharedLinkDto {
   bool? showExif;
   bool? showExif;
 
 
   @override
   @override
-  bool operator ==(Object other) => identical(this, other) || other is EditSharedLinkDto &&
+  bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto &&
      other.description == description &&
      other.description == description &&
      other.expiresAt == expiresAt &&
      other.expiresAt == expiresAt &&
      other.allowUpload == allowUpload &&
      other.allowUpload == allowUpload &&
@@ -72,7 +72,7 @@ class EditSharedLinkDto {
     (showExif == null ? 0 : showExif!.hashCode);
     (showExif == null ? 0 : showExif!.hashCode);
 
 
   @override
   @override
-  String toString() => 'EditSharedLinkDto[description=$description, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif]';
+  String toString() => 'SharedLinkEditDto[description=$description, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -104,10 +104,10 @@ class EditSharedLinkDto {
     return json;
     return json;
   }
   }
 
 
-  /// Returns a new [EditSharedLinkDto] instance and imports its values from
+  /// Returns a new [SharedLinkEditDto] instance and imports its values from
   /// [value] if it's a [Map], null otherwise.
   /// [value] if it's a [Map], null otherwise.
   // ignore: prefer_constructors_over_static_methods
   // ignore: prefer_constructors_over_static_methods
-  static EditSharedLinkDto? fromJson(dynamic value) {
+  static SharedLinkEditDto? fromJson(dynamic value) {
     if (value is Map) {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
       final json = value.cast<String, dynamic>();
 
 
@@ -116,13 +116,13 @@ class EditSharedLinkDto {
       // Note 2: this code is stripped in release mode!
       // Note 2: this code is stripped in release mode!
       assert(() {
       assert(() {
         requiredKeys.forEach((key) {
         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.');
+          assert(json.containsKey(key), 'Required key "SharedLinkEditDto[$key]" is missing from JSON.');
+          assert(json[key] != null, 'Required key "SharedLinkEditDto[$key]" has a null value in JSON.');
         });
         });
         return true;
         return true;
       }());
       }());
 
 
-      return EditSharedLinkDto(
+      return SharedLinkEditDto(
         description: mapValueOfType<String>(json, r'description'),
         description: mapValueOfType<String>(json, r'description'),
         expiresAt: mapDateTime(json, r'expiresAt', ''),
         expiresAt: mapDateTime(json, r'expiresAt', ''),
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
@@ -133,11 +133,11 @@ class EditSharedLinkDto {
     return null;
     return null;
   }
   }
 
 
-  static List<EditSharedLinkDto> listFromJson(dynamic json, {bool growable = false,}) {
-    final result = <EditSharedLinkDto>[];
+  static List<SharedLinkEditDto> listFromJson(dynamic json, {bool growable = false,}) {
+    final result = <SharedLinkEditDto>[];
     if (json is List && json.isNotEmpty) {
     if (json is List && json.isNotEmpty) {
       for (final row in json) {
       for (final row in json) {
-        final value = EditSharedLinkDto.fromJson(row);
+        final value = SharedLinkEditDto.fromJson(row);
         if (value != null) {
         if (value != null) {
           result.add(value);
           result.add(value);
         }
         }
@@ -146,12 +146,12 @@ class EditSharedLinkDto {
     return result.toList(growable: growable);
     return result.toList(growable: growable);
   }
   }
 
 
-  static Map<String, EditSharedLinkDto> mapFromJson(dynamic json) {
-    final map = <String, EditSharedLinkDto>{};
+  static Map<String, SharedLinkEditDto> mapFromJson(dynamic json) {
+    final map = <String, SharedLinkEditDto>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       json = json.cast<String, dynamic>(); // ignore: parameter_assignments
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        final value = EditSharedLinkDto.fromJson(entry.value);
+        final value = SharedLinkEditDto.fromJson(entry.value);
         if (value != null) {
         if (value != null) {
           map[entry.key] = value;
           map[entry.key] = value;
         }
         }
@@ -160,14 +160,14 @@ class EditSharedLinkDto {
     return map;
     return map;
   }
   }
 
 
-  // maps a json object with a list of EditSharedLinkDto-objects as value to a dart map
-  static Map<String, List<EditSharedLinkDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
-    final map = <String, List<EditSharedLinkDto>>{};
+  // maps a json object with a list of SharedLinkEditDto-objects as value to a dart map
+  static Map<String, List<SharedLinkEditDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
+    final map = <String, List<SharedLinkEditDto>>{};
     if (json is Map && json.isNotEmpty) {
     if (json is Map && json.isNotEmpty) {
       // ignore: parameter_assignments
       // ignore: parameter_assignments
       json = json.cast<String, dynamic>();
       json = json.cast<String, dynamic>();
       for (final entry in json.entries) {
       for (final entry in json.entries) {
-        map[entry.key] = EditSharedLinkDto.listFromJson(entry.value, growable: growable,);
+        map[entry.key] = SharedLinkEditDto.listFromJson(entry.value, growable: growable,);
       }
       }
     }
     }
     return map;
     return map;

+ 2 - 7
mobile/openapi/lib/model/shared_link_response_dto.dart

@@ -15,7 +15,7 @@ class SharedLinkResponseDto {
   SharedLinkResponseDto({
   SharedLinkResponseDto({
     required this.type,
     required this.type,
     required this.id,
     required this.id,
-    this.description,
+    required this.description,
     required this.userId,
     required this.userId,
     required this.key,
     required this.key,
     required this.createdAt,
     required this.createdAt,
@@ -31,12 +31,6 @@ class SharedLinkResponseDto {
 
 
   String id;
   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? description;
 
 
   String userId;
   String userId;
@@ -206,6 +200,7 @@ class SharedLinkResponseDto {
   static const requiredKeys = <String>{
   static const requiredKeys = <String>{
     'type',
     'type',
     'id',
     'id',
+    'description',
     'userId',
     'userId',
     'key',
     'key',
     'createdAt',
     'createdAt',

+ 18 - 1
mobile/openapi/lib/model/update_user_dto.dart

@@ -19,6 +19,7 @@ class UpdateUserDto {
     this.firstName,
     this.firstName,
     this.lastName,
     this.lastName,
     this.storageLabel,
     this.storageLabel,
+    this.externalPath,
     this.isAdmin,
     this.isAdmin,
     this.shouldChangePassword,
     this.shouldChangePassword,
   });
   });
@@ -65,6 +66,14 @@ class UpdateUserDto {
   ///
   ///
   String? storageLabel;
   String? storageLabel;
 
 
+  ///
+  /// Please note: This property should have been non-nullable! Since the specification file
+  /// does not include a default value (using the "default:" property), however, the generated
+  /// source code must fall back to having a nullable type.
+  /// Consider adding a "default:" property in the specification file to hide this note.
+  ///
+  String? externalPath;
+
   ///
   ///
   /// Please note: This property should have been non-nullable! Since the specification file
   /// 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
   /// does not include a default value (using the "default:" property), however, the generated
@@ -89,6 +98,7 @@ class UpdateUserDto {
      other.firstName == firstName &&
      other.firstName == firstName &&
      other.lastName == lastName &&
      other.lastName == lastName &&
      other.storageLabel == storageLabel &&
      other.storageLabel == storageLabel &&
+     other.externalPath == externalPath &&
      other.isAdmin == isAdmin &&
      other.isAdmin == isAdmin &&
      other.shouldChangePassword == shouldChangePassword;
      other.shouldChangePassword == shouldChangePassword;
 
 
@@ -101,11 +111,12 @@ class UpdateUserDto {
     (firstName == null ? 0 : firstName!.hashCode) +
     (firstName == null ? 0 : firstName!.hashCode) +
     (lastName == null ? 0 : lastName!.hashCode) +
     (lastName == null ? 0 : lastName!.hashCode) +
     (storageLabel == null ? 0 : storageLabel!.hashCode) +
     (storageLabel == null ? 0 : storageLabel!.hashCode) +
+    (externalPath == null ? 0 : externalPath!.hashCode) +
     (isAdmin == null ? 0 : isAdmin!.hashCode) +
     (isAdmin == null ? 0 : isAdmin!.hashCode) +
     (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode);
     (shouldChangePassword == null ? 0 : shouldChangePassword!.hashCode);
 
 
   @override
   @override
-  String toString() => 'UpdateUserDto[id=$id, email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]';
+  String toString() => 'UpdateUserDto[id=$id, email=$email, password=$password, firstName=$firstName, lastName=$lastName, storageLabel=$storageLabel, externalPath=$externalPath, isAdmin=$isAdmin, shouldChangePassword=$shouldChangePassword]';
 
 
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     final json = <String, dynamic>{};
@@ -135,6 +146,11 @@ class UpdateUserDto {
     } else {
     } else {
       // json[r'storageLabel'] = null;
       // json[r'storageLabel'] = null;
     }
     }
+    if (this.externalPath != null) {
+      json[r'externalPath'] = this.externalPath;
+    } else {
+      // json[r'externalPath'] = null;
+    }
     if (this.isAdmin != null) {
     if (this.isAdmin != null) {
       json[r'isAdmin'] = this.isAdmin;
       json[r'isAdmin'] = this.isAdmin;
     } else {
     } else {
@@ -173,6 +189,7 @@ class UpdateUserDto {
         firstName: mapValueOfType<String>(json, r'firstName'),
         firstName: mapValueOfType<String>(json, r'firstName'),
         lastName: mapValueOfType<String>(json, r'lastName'),
         lastName: mapValueOfType<String>(json, r'lastName'),
         storageLabel: mapValueOfType<String>(json, r'storageLabel'),
         storageLabel: mapValueOfType<String>(json, r'storageLabel'),
+        externalPath: mapValueOfType<String>(json, r'externalPath'),
         isAdmin: mapValueOfType<bool>(json, r'isAdmin'),
         isAdmin: mapValueOfType<bool>(json, r'isAdmin'),
         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword'),
       );
       );

+ 0 - 5
mobile/openapi/test/album_api_test.dart

@@ -32,11 +32,6 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<SharedLinkResponseDto> createAlbumSharedLink(CreateAlbumShareLinkDto createAlbumShareLinkDto) async
-    test('test createAlbumSharedLink', () async {
-      // TODO
-    });
-
     //Future deleteAlbum(String id) async
     //Future deleteAlbum(String id) async
     test('test deleteAlbum', () async {
     test('test deleteAlbum', () async {
       // TODO
       // TODO

+ 5 - 0
mobile/openapi/test/album_response_dto_test.dart

@@ -71,6 +71,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // DateTime lastModifiedAssetTimestamp
+    test('to test the property `lastModifiedAssetTimestamp`', () async {
+      // TODO
+    });
+
 
 
   });
   });
 
 

+ 3 - 13
mobile/openapi/test/asset_api_test.dart

@@ -17,11 +17,6 @@ void main() {
   // final instance = AssetApi();
   // final instance = AssetApi();
 
 
   group('tests for AssetApi', () {
   group('tests for AssetApi', () {
-    //Future<SharedLinkResponseDto> addAssetsToSharedLink(AddAssetsDto addAssetsDto, { String key }) async
-    test('test addAssetsToSharedLink', () async {
-      // TODO
-    });
-
     // Checks if assets exist by checksums
     // Checks if assets exist by checksums
     //
     //
     //Future<AssetBulkUploadCheckResponseDto> bulkUploadCheck(AssetBulkUploadCheckDto assetBulkUploadCheckDto) async
     //Future<AssetBulkUploadCheckResponseDto> bulkUploadCheck(AssetBulkUploadCheckDto assetBulkUploadCheckDto) async
@@ -43,11 +38,6 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<SharedLinkResponseDto> createAssetsSharedLink(CreateAssetsShareLinkDto createAssetsShareLinkDto) async
-    test('test createAssetsSharedLink', () async {
-      // TODO
-    });
-
     //Future<List<DeleteAssetResponseDto>> deleteAsset(DeleteAssetDto deleteAssetDto) async
     //Future<List<DeleteAssetResponseDto>> deleteAsset(DeleteAssetDto deleteAssetDto) async
     test('test deleteAsset', () async {
     test('test deleteAsset', () async {
       // TODO
       // TODO
@@ -141,8 +131,8 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<SharedLinkResponseDto> removeAssetsFromSharedLink(RemoveAssetsDto removeAssetsDto, { String key }) async
-    test('test removeAssetsFromSharedLink', () async {
+    //Future<AssetFileUploadResponseDto> importFile(ImportAssetDto importAssetDto) async
+    test('test importFile', () async {
       // TODO
       // TODO
     });
     });
 
 
@@ -163,7 +153,7 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<AssetFileUploadResponseDto> uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, String fileExtension, { String key, MultipartFile livePhotoData, MultipartFile sidecarData, bool isArchived, bool isVisible, String duration }) async
+    //Future<AssetFileUploadResponseDto> uploadFile(AssetTypeEnum assetType, MultipartFile assetData, String fileExtension, String deviceAssetId, String deviceId, DateTime fileCreatedAt, DateTime fileModifiedAt, bool isFavorite, { String key, MultipartFile livePhotoData, MultipartFile sidecarData, bool isReadOnly, bool isArchived, bool isVisible, String duration }) async
     test('test uploadFile', () async {
     test('test uploadFile', () async {
       // TODO
       // TODO
     });
     });

+ 0 - 52
mobile/openapi/test/create_album_share_link_dto_test.dart

@@ -1,52 +0,0 @@
-//
-// 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
-    });
-
-    // DateTime expiresAt
-    test('to test the property `expiresAt`', () async {
-      // TODO
-    });
-
-    // bool allowUpload
-    test('to test the property `allowUpload`', () async {
-      // TODO
-    });
-
-    // bool allowDownload
-    test('to test the property `allowDownload`', () async {
-      // TODO
-    });
-
-    // bool showExif
-    test('to test the property `showExif`', () async {
-      // TODO
-    });
-
-    // String description
-    test('to test the property `description`', () async {
-      // TODO
-    });
-
-
-  });
-
-}

+ 5 - 0
mobile/openapi/test/create_user_dto_test.dart

@@ -41,6 +41,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // String externalPath
+    test('to test the property `externalPath`', () async {
+      // TODO
+    });
+
 
 
   });
   });
 
 

+ 82 - 0
mobile/openapi/test/import_asset_dto_test.dart

@@ -0,0 +1,82 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.12
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+import 'package:openapi/api.dart';
+import 'package:test/test.dart';
+
+// tests for ImportAssetDto
+void main() {
+  // final instance = ImportAssetDto();
+
+  group('test ImportAssetDto', () {
+    // AssetTypeEnum assetType
+    test('to test the property `assetType`', () async {
+      // TODO
+    });
+
+    // bool isReadOnly (default value: true)
+    test('to test the property `isReadOnly`', () async {
+      // TODO
+    });
+
+    // String assetPath
+    test('to test the property `assetPath`', () async {
+      // TODO
+    });
+
+    // String sidecarPath
+    test('to test the property `sidecarPath`', () async {
+      // TODO
+    });
+
+    // String deviceAssetId
+    test('to test the property `deviceAssetId`', () async {
+      // TODO
+    });
+
+    // String deviceId
+    test('to test the property `deviceId`', () async {
+      // TODO
+    });
+
+    // DateTime fileCreatedAt
+    test('to test the property `fileCreatedAt`', () async {
+      // TODO
+    });
+
+    // DateTime fileModifiedAt
+    test('to test the property `fileModifiedAt`', () async {
+      // TODO
+    });
+
+    // bool isFavorite
+    test('to test the property `isFavorite`', () async {
+      // TODO
+    });
+
+    // bool isArchived
+    test('to test the property `isArchived`', () async {
+      // TODO
+    });
+
+    // bool isVisible
+    test('to test the property `isVisible`', () async {
+      // TODO
+    });
+
+    // String duration
+    test('to test the property `duration`', () async {
+      // TODO
+    });
+
+
+  });
+
+}

+ 19 - 4
mobile/openapi/test/share_api_test.dart → mobile/openapi/test/shared_link_api_test.dart

@@ -12,11 +12,21 @@ import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 import 'package:test/test.dart';
 
 
 
 
-/// tests for ShareApi
+/// tests for SharedLinkApi
 void main() {
 void main() {
-  // final instance = ShareApi();
+  // final instance = SharedLinkApi();
+
+  group('tests for SharedLinkApi', () {
+    //Future<List<AssetIdsResponseDto>> addSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String key }) async
+    test('test addSharedLinkAssets', () async {
+      // TODO
+    });
+
+    //Future<SharedLinkResponseDto> createSharedLink(SharedLinkCreateDto sharedLinkCreateDto) async
+    test('test createSharedLink', () async {
+      // TODO
+    });
 
 
-  group('tests for ShareApi', () {
     //Future<List<SharedLinkResponseDto>> getAllSharedLinks() async
     //Future<List<SharedLinkResponseDto>> getAllSharedLinks() async
     test('test getAllSharedLinks', () async {
     test('test getAllSharedLinks', () async {
       // TODO
       // TODO
@@ -37,7 +47,12 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
-    //Future<SharedLinkResponseDto> updateSharedLink(String id, EditSharedLinkDto editSharedLinkDto) async
+    //Future<List<AssetIdsResponseDto>> removeSharedLinkAssets(String id, AssetIdsDto assetIdsDto, { String key }) async
+    test('test removeSharedLinkAssets', () async {
+      // TODO
+    });
+
+    //Future<SharedLinkResponseDto> updateSharedLink(String id, SharedLinkEditDto sharedLinkEditDto) async
     test('test updateSharedLink', () async {
     test('test updateSharedLink', () async {
       // TODO
       // TODO
     });
     });

+ 21 - 11
mobile/openapi/test/create_assets_share_link_dto_test.dart → mobile/openapi/test/shared_link_create_dto_test.dart

@@ -11,41 +11,51 @@
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 import 'package:test/test.dart';
 
 
-// tests for CreateAssetsShareLinkDto
+// tests for SharedLinkCreateDto
 void main() {
 void main() {
-  // final instance = CreateAssetsShareLinkDto();
+  // final instance = SharedLinkCreateDto();
+
+  group('test SharedLinkCreateDto', () {
+    // SharedLinkType type
+    test('to test the property `type`', () async {
+      // TODO
+    });
 
 
-  group('test CreateAssetsShareLinkDto', () {
     // List<String> assetIds (default value: const [])
     // List<String> assetIds (default value: const [])
     test('to test the property `assetIds`', () async {
     test('to test the property `assetIds`', () async {
       // TODO
       // TODO
     });
     });
 
 
+    // String albumId
+    test('to test the property `albumId`', () async {
+      // TODO
+    });
+
+    // String description
+    test('to test the property `description`', () async {
+      // TODO
+    });
+
     // DateTime expiresAt
     // DateTime expiresAt
     test('to test the property `expiresAt`', () async {
     test('to test the property `expiresAt`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // bool allowUpload
+    // bool allowUpload (default value: false)
     test('to test the property `allowUpload`', () async {
     test('to test the property `allowUpload`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // bool allowDownload
+    // bool allowDownload (default value: true)
     test('to test the property `allowDownload`', () async {
     test('to test the property `allowDownload`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // bool showExif
+    // bool showExif (default value: true)
     test('to test the property `showExif`', () async {
     test('to test the property `showExif`', () async {
       // TODO
       // TODO
     });
     });
 
 
-    // String description
-    test('to test the property `description`', () async {
-      // TODO
-    });
-
 
 
   });
   });
 
 

+ 3 - 3
mobile/openapi/test/edit_shared_link_dto_test.dart → mobile/openapi/test/shared_link_edit_dto_test.dart

@@ -11,11 +11,11 @@
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:test/test.dart';
 import 'package:test/test.dart';
 
 
-// tests for EditSharedLinkDto
+// tests for SharedLinkEditDto
 void main() {
 void main() {
-  // final instance = EditSharedLinkDto();
+  // final instance = SharedLinkEditDto();
 
 
-  group('test EditSharedLinkDto', () {
+  group('test SharedLinkEditDto', () {
     // String description
     // String description
     test('to test the property `description`', () async {
     test('to test the property `description`', () async {
       // TODO
       // TODO

+ 5 - 0
mobile/openapi/test/update_user_dto_test.dart

@@ -46,6 +46,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // String externalPath
+    test('to test the property `externalPath`', () async {
+      // TODO
+    });
+
     // bool isAdmin
     // bool isAdmin
     test('to test the property `isAdmin`', () async {
     test('to test the property `isAdmin`', () async {
       // TODO
       // TODO

+ 5 - 0
mobile/openapi/test/user_response_dto_test.dart

@@ -41,6 +41,11 @@ void main() {
       // TODO
       // TODO
     });
     });
 
 
+    // String externalPath
+    test('to test the property `externalPath`', () async {
+      // TODO
+    });
+
     // String profileImagePath
     // String profileImagePath
     test('to test the property `profileImagePath`', () async {
     test('to test the property `profileImagePath`', () async {
       // TODO
       // TODO

+ 2 - 2
server/Dockerfile

@@ -2,7 +2,7 @@ FROM node:18.16.0-alpine3.18@sha256:f41850f74ff16a33daff988e2ea06ef8f5daeb6fb849
 
 
 WORKDIR /usr/src/app
 WORKDIR /usr/src/app
 
 
-RUN apk add --update-cache build-base imagemagick-dev python3 ffmpeg libraw-dev perl vips-dev vips-heif vips-magick
+RUN apk add --update-cache build-base imagemagick-dev python3 ffmpeg libraw-dev perl vips-dev vips-heif vips-jxl vips-magick
 
 
 COPY package.json package-lock.json ./
 COPY package.json package-lock.json ./
 
 
@@ -23,7 +23,7 @@ ENV NODE_ENV=production
 
 
 WORKDIR /usr/src/app
 WORKDIR /usr/src/app
 
 
-RUN apk add --no-cache ffmpeg imagemagick-dev libraw-dev perl vips-dev vips-heif vips-magick
+RUN apk add --no-cache ffmpeg imagemagick-dev libraw-dev perl vips-dev vips-heif vips-jxl vips-magick
 
 
 COPY --from=prod /usr/src/app/node_modules ./node_modules
 COPY --from=prod /usr/src/app/node_modules ./node_modules
 COPY --from=prod /usr/src/app/dist ./dist
 COPY --from=prod /usr/src/app/dist ./dist

+ 13 - 4
server/e2e/album.e2e-spec.ts

@@ -1,7 +1,14 @@
-import { AlbumResponseDto, AuthService, CreateAlbumDto, SharedLinkResponseDto, UserService } from '@app/domain';
-import { CreateAlbumShareLinkDto } from '@app/immich/api-v1/album/dto/create-album-shared-link.dto';
+import {
+  AlbumResponseDto,
+  AuthService,
+  CreateAlbumDto,
+  SharedLinkCreateDto,
+  SharedLinkResponseDto,
+  UserService,
+} from '@app/domain';
 import { AppModule } from '@app/immich/app.module';
 import { AppModule } from '@app/immich/app.module';
 import { AuthUserDto } from '@app/immich/decorators/auth-user.decorator';
 import { AuthUserDto } from '@app/immich/decorators/auth-user.decorator';
+import { SharedLinkType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { INestApplication } from '@nestjs/common';
 import { Test, TestingModule } from '@nestjs/testing';
 import { Test, TestingModule } from '@nestjs/testing';
 import request from 'supertest';
 import request from 'supertest';
@@ -14,8 +21,10 @@ async function _createAlbum(app: INestApplication, data: CreateAlbumDto) {
   return res.body as AlbumResponseDto;
   return res.body as AlbumResponseDto;
 }
 }
 
 
-async function _createAlbumSharedLink(app: INestApplication, data: CreateAlbumShareLinkDto) {
-  const res = await request(app.getHttpServer()).post('/album/create-shared-link').send(data);
+async function _createAlbumSharedLink(app: INestApplication, data: Omit<SharedLinkCreateDto, 'type'>) {
+  const res = await request(app.getHttpServer())
+    .post('/shared-link')
+    .send({ ...data, type: SharedLinkType.ALBUM });
   expect(res.status).toEqual(201);
   expect(res.status).toEqual(201);
   return res.body as SharedLinkResponseDto;
   return res.body as SharedLinkResponseDto;
 }
 }

+ 3 - 0
server/e2e/user.e2e-spec.ts

@@ -105,6 +105,7 @@ describe('User', () => {
               updatedAt: expect.anything(),
               updatedAt: expect.anything(),
               oauthId: '',
               oauthId: '',
               storageLabel: null,
               storageLabel: null,
+              externalPath: null,
             },
             },
             {
             {
               email: userTwoEmail,
               email: userTwoEmail,
@@ -119,6 +120,7 @@ describe('User', () => {
               updatedAt: expect.anything(),
               updatedAt: expect.anything(),
               oauthId: '',
               oauthId: '',
               storageLabel: null,
               storageLabel: null,
+              externalPath: null,
             },
             },
             {
             {
               email: authUserEmail,
               email: authUserEmail,
@@ -133,6 +135,7 @@ describe('User', () => {
               updatedAt: expect.anything(),
               updatedAt: expect.anything(),
               oauthId: '',
               oauthId: '',
               storageLabel: 'admin',
               storageLabel: 'admin',
+              externalPath: null,
             },
             },
           ]),
           ]),
         );
         );

+ 364 - 290
server/immich-openapi-specs.json

@@ -127,48 +127,6 @@
         ]
         ]
       }
       }
     },
     },
-    "/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": []
-          },
-          {
-            "cookie": []
-          },
-          {
-            "api_key": []
-          }
-        ]
-      }
-    },
     "/album/{id}": {
     "/album/{id}": {
       "patch": {
       "patch": {
         "operationId": "updateAlbumInfo",
         "operationId": "updateAlbumInfo",
@@ -1472,6 +1430,48 @@
         ]
         ]
       }
       }
     },
     },
+    "/asset/import": {
+      "post": {
+        "operationId": "importFile",
+        "parameters": [],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/ImportAssetDto"
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/AssetFileUploadResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Asset"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ]
+      }
+    },
     "/asset/map-marker": {
     "/asset/map-marker": {
       "get": {
       "get": {
         "operationId": "getMapMarkers",
         "operationId": "getMapMarkers",
@@ -1660,150 +1660,6 @@
         ]
         ]
       }
       }
     },
     },
-    "/asset/shared-link": {
-      "post": {
-        "operationId": "createAssetsSharedLink",
-        "parameters": [],
-        "requestBody": {
-          "required": true,
-          "content": {
-            "application/json": {
-              "schema": {
-                "$ref": "#/components/schemas/CreateAssetsShareLinkDto"
-              }
-            }
-          }
-        },
-        "responses": {
-          "201": {
-            "description": "",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/SharedLinkResponseDto"
-                }
-              }
-            }
-          }
-        },
-        "tags": [
-          "Asset"
-        ],
-        "security": [
-          {
-            "bearer": []
-          },
-          {
-            "cookie": []
-          },
-          {
-            "api_key": []
-          }
-        ]
-      }
-    },
-    "/asset/shared-link/add": {
-      "patch": {
-        "operationId": "addAssetsToSharedLink",
-        "parameters": [
-          {
-            "name": "key",
-            "required": false,
-            "in": "query",
-            "schema": {
-              "type": "string"
-            }
-          }
-        ],
-        "requestBody": {
-          "required": true,
-          "content": {
-            "application/json": {
-              "schema": {
-                "$ref": "#/components/schemas/AddAssetsDto"
-              }
-            }
-          }
-        },
-        "responses": {
-          "200": {
-            "description": "",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/SharedLinkResponseDto"
-                }
-              }
-            }
-          }
-        },
-        "tags": [
-          "Asset"
-        ],
-        "security": [
-          {
-            "bearer": []
-          },
-          {
-            "cookie": []
-          },
-          {
-            "api_key": []
-          }
-        ]
-      }
-    },
-    "/asset/shared-link/remove": {
-      "patch": {
-        "operationId": "removeAssetsFromSharedLink",
-        "parameters": [
-          {
-            "name": "key",
-            "required": false,
-            "in": "query",
-            "schema": {
-              "type": "string"
-            }
-          }
-        ],
-        "requestBody": {
-          "required": true,
-          "content": {
-            "application/json": {
-              "schema": {
-                "$ref": "#/components/schemas/RemoveAssetsDto"
-              }
-            }
-          }
-        },
-        "responses": {
-          "200": {
-            "description": "",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/SharedLinkResponseDto"
-                }
-              }
-            }
-          }
-        },
-        "tags": [
-          "Asset"
-        ],
-        "security": [
-          {
-            "bearer": []
-          },
-          {
-            "cookie": []
-          },
-          {
-            "api_key": []
-          }
-        ]
-      }
-    },
     "/asset/stat/archive": {
     "/asset/stat/archive": {
       "get": {
       "get": {
         "operationId": "getArchivedAssetCountByUserId",
         "operationId": "getArchivedAssetCountByUserId",
@@ -3264,7 +3120,7 @@
         ]
         ]
       }
       }
     },
     },
-    "/share": {
+    "/shared-link": {
       "get": {
       "get": {
         "operationId": "getAllSharedLinks",
         "operationId": "getAllSharedLinks",
         "parameters": [],
         "parameters": [],
@@ -3284,7 +3140,47 @@
           }
           }
         },
         },
         "tags": [
         "tags": [
-          "share"
+          "Shared Link"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ]
+      },
+      "post": {
+        "operationId": "createSharedLink",
+        "parameters": [],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/SharedLinkCreateDto"
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/SharedLinkResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Shared Link"
         ],
         ],
         "security": [
         "security": [
           {
           {
@@ -3299,7 +3195,7 @@
         ]
         ]
       }
       }
     },
     },
-    "/share/me": {
+    "/shared-link/me": {
       "get": {
       "get": {
         "operationId": "getMySharedLink",
         "operationId": "getMySharedLink",
         "parameters": [
         "parameters": [
@@ -3325,7 +3221,7 @@
           }
           }
         },
         },
         "tags": [
         "tags": [
-          "share"
+          "Shared Link"
         ],
         ],
         "security": [
         "security": [
           {
           {
@@ -3340,7 +3236,7 @@
         ]
         ]
       }
       }
     },
     },
-    "/share/{id}": {
+    "/shared-link/{id}": {
       "get": {
       "get": {
         "operationId": "getSharedLinkById",
         "operationId": "getSharedLinkById",
         "parameters": [
         "parameters": [
@@ -3367,7 +3263,7 @@
           }
           }
         },
         },
         "tags": [
         "tags": [
-          "share"
+          "Shared Link"
         ],
         ],
         "security": [
         "security": [
           {
           {
@@ -3399,7 +3295,100 @@
           "content": {
           "content": {
             "application/json": {
             "application/json": {
               "schema": {
               "schema": {
-                "$ref": "#/components/schemas/EditSharedLinkDto"
+                "$ref": "#/components/schemas/SharedLinkEditDto"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/SharedLinkResponseDto"
+                }
+              }
+            }
+          }
+        },
+        "tags": [
+          "Shared Link"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ]
+      },
+      "delete": {
+        "operationId": "removeSharedLink",
+        "parameters": [
+          {
+            "name": "id",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": ""
+          }
+        },
+        "tags": [
+          "Shared Link"
+        ],
+        "security": [
+          {
+            "bearer": []
+          },
+          {
+            "cookie": []
+          },
+          {
+            "api_key": []
+          }
+        ]
+      }
+    },
+    "/shared-link/{id}/assets": {
+      "put": {
+        "operationId": "addSharedLinkAssets",
+        "parameters": [
+          {
+            "name": "id",
+            "required": true,
+            "in": "path",
+            "schema": {
+              "format": "uuid",
+              "type": "string"
+            }
+          },
+          {
+            "name": "key",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
+          }
+        ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/AssetIdsDto"
               }
               }
             }
             }
           }
           }
@@ -3410,14 +3399,17 @@
             "content": {
             "content": {
               "application/json": {
               "application/json": {
                 "schema": {
                 "schema": {
-                  "$ref": "#/components/schemas/SharedLinkResponseDto"
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/components/schemas/AssetIdsResponseDto"
+                  }
                 }
                 }
               }
               }
             }
             }
           }
           }
         },
         },
         "tags": [
         "tags": [
-          "share"
+          "Shared Link"
         ],
         ],
         "security": [
         "security": [
           {
           {
@@ -3432,7 +3424,7 @@
         ]
         ]
       },
       },
       "delete": {
       "delete": {
-        "operationId": "removeSharedLink",
+        "operationId": "removeSharedLinkAssets",
         "parameters": [
         "parameters": [
           {
           {
             "name": "id",
             "name": "id",
@@ -3442,15 +3434,43 @@
               "format": "uuid",
               "format": "uuid",
               "type": "string"
               "type": "string"
             }
             }
+          },
+          {
+            "name": "key",
+            "required": false,
+            "in": "query",
+            "schema": {
+              "type": "string"
+            }
           }
           }
         ],
         ],
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/AssetIdsDto"
+              }
+            }
+          }
+        },
         "responses": {
         "responses": {
           "200": {
           "200": {
-            "description": ""
+            "description": "",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/components/schemas/AssetIdsResponseDto"
+                  }
+                }
+              }
+            }
           }
           }
         },
         },
         "tags": [
         "tags": [
-          "share"
+          "Shared Link"
         ],
         ],
         "security": [
         "security": [
           {
           {
@@ -4584,6 +4604,10 @@
           },
           },
           "owner": {
           "owner": {
             "$ref": "#/components/schemas/UserResponseDto"
             "$ref": "#/components/schemas/UserResponseDto"
+          },
+          "lastModifiedAssetTimestamp": {
+            "format": "date-time",
+            "type": "string"
           }
           }
         },
         },
         "required": [
         "required": [
@@ -5085,34 +5109,6 @@
           "albumName"
           "albumName"
         ]
         ]
       },
       },
-      "CreateAlbumShareLinkDto": {
-        "type": "object",
-        "properties": {
-          "albumId": {
-            "type": "string",
-            "format": "uuid"
-          },
-          "expiresAt": {
-            "format": "date-time",
-            "type": "string"
-          },
-          "allowUpload": {
-            "type": "boolean"
-          },
-          "allowDownload": {
-            "type": "boolean"
-          },
-          "showExif": {
-            "type": "boolean"
-          },
-          "description": {
-            "type": "string"
-          }
-        },
-        "required": [
-          "albumId"
-        ]
-      },
       "CreateAssetDto": {
       "CreateAssetDto": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {
@@ -5131,6 +5127,13 @@
             "type": "string",
             "type": "string",
             "format": "binary"
             "format": "binary"
           },
           },
+          "isReadOnly": {
+            "type": "boolean",
+            "default": false
+          },
+          "fileExtension": {
+            "type": "string"
+          },
           "deviceAssetId": {
           "deviceAssetId": {
             "type": "string"
             "type": "string"
           },
           },
@@ -5154,9 +5157,6 @@
           "isVisible": {
           "isVisible": {
             "type": "boolean"
             "type": "boolean"
           },
           },
-          "fileExtension": {
-            "type": "string"
-          },
           "duration": {
           "duration": {
             "type": "string"
             "type": "string"
           }
           }
@@ -5164,48 +5164,12 @@
         "required": [
         "required": [
           "assetType",
           "assetType",
           "assetData",
           "assetData",
+          "fileExtension",
           "deviceAssetId",
           "deviceAssetId",
           "deviceId",
           "deviceId",
           "fileCreatedAt",
           "fileCreatedAt",
           "fileModifiedAt",
           "fileModifiedAt",
-          "isFavorite",
-          "fileExtension"
-        ]
-      },
-      "CreateAssetsShareLinkDto": {
-        "type": "object",
-        "properties": {
-          "assetIds": {
-            "title": "Array asset IDs to be shared",
-            "example": [
-              "bf973405-3f2a-48d2-a687-2ed4167164be",
-              "dd41870b-5d00-46d2-924e-1d8489a0aa0f",
-              "fad77c3f-deef-4e7e-9608-14c1aa4e559a"
-            ],
-            "type": "array",
-            "items": {
-              "type": "string"
-            }
-          },
-          "expiresAt": {
-            "format": "date-time",
-            "type": "string"
-          },
-          "allowUpload": {
-            "type": "boolean"
-          },
-          "allowDownload": {
-            "type": "boolean"
-          },
-          "showExif": {
-            "type": "boolean"
-          },
-          "description": {
-            "type": "string"
-          }
-        },
-        "required": [
-          "assetIds"
+          "isFavorite"
         ]
         ]
       },
       },
       "CreateProfileImageDto": {
       "CreateProfileImageDto": {
@@ -5268,6 +5232,10 @@
           "storageLabel": {
           "storageLabel": {
             "type": "string",
             "type": "string",
             "nullable": true
             "nullable": true
+          },
+          "externalPath": {
+            "type": "string",
+            "nullable": true
           }
           }
         },
         },
         "required": [
         "required": [
@@ -5388,28 +5356,6 @@
           "assetIds"
           "assetIds"
         ]
         ]
       },
       },
-      "EditSharedLinkDto": {
-        "type": "object",
-        "properties": {
-          "description": {
-            "type": "string"
-          },
-          "expiresAt": {
-            "format": "date-time",
-            "type": "string",
-            "nullable": true
-          },
-          "allowUpload": {
-            "type": "boolean"
-          },
-          "allowDownload": {
-            "type": "boolean"
-          },
-          "showExif": {
-            "type": "boolean"
-          }
-        }
-      },
       "ExifResponseDto": {
       "ExifResponseDto": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {
@@ -5565,6 +5511,59 @@
           "timeGroup"
           "timeGroup"
         ]
         ]
       },
       },
+      "ImportAssetDto": {
+        "type": "object",
+        "properties": {
+          "assetType": {
+            "$ref": "#/components/schemas/AssetTypeEnum"
+          },
+          "isReadOnly": {
+            "type": "boolean",
+            "default": true
+          },
+          "assetPath": {
+            "type": "string"
+          },
+          "sidecarPath": {
+            "type": "string"
+          },
+          "deviceAssetId": {
+            "type": "string"
+          },
+          "deviceId": {
+            "type": "string"
+          },
+          "fileCreatedAt": {
+            "format": "date-time",
+            "type": "string"
+          },
+          "fileModifiedAt": {
+            "format": "date-time",
+            "type": "string"
+          },
+          "isFavorite": {
+            "type": "boolean"
+          },
+          "isArchived": {
+            "type": "boolean"
+          },
+          "isVisible": {
+            "type": "boolean"
+          },
+          "duration": {
+            "type": "string"
+          }
+        },
+        "required": [
+          "assetType",
+          "assetPath",
+          "deviceAssetId",
+          "deviceId",
+          "fileCreatedAt",
+          "fileModifiedAt",
+          "isFavorite"
+        ]
+      },
       "JobCommand": {
       "JobCommand": {
         "type": "string",
         "type": "string",
         "enum": [
         "enum": [
@@ -6156,6 +6155,71 @@
           "patch"
           "patch"
         ]
         ]
       },
       },
+      "SharedLinkCreateDto": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "$ref": "#/components/schemas/SharedLinkType"
+          },
+          "assetIds": {
+            "type": "array",
+            "items": {
+              "type": "string",
+              "format": "uuid"
+            }
+          },
+          "albumId": {
+            "type": "string",
+            "format": "uuid"
+          },
+          "description": {
+            "type": "string"
+          },
+          "expiresAt": {
+            "format": "date-time",
+            "type": "string",
+            "nullable": true,
+            "default": null
+          },
+          "allowUpload": {
+            "type": "boolean",
+            "default": false
+          },
+          "allowDownload": {
+            "type": "boolean",
+            "default": true
+          },
+          "showExif": {
+            "type": "boolean",
+            "default": true
+          }
+        },
+        "required": [
+          "type"
+        ]
+      },
+      "SharedLinkEditDto": {
+        "type": "object",
+        "properties": {
+          "description": {
+            "type": "string"
+          },
+          "expiresAt": {
+            "format": "date-time",
+            "type": "string",
+            "nullable": true
+          },
+          "allowUpload": {
+            "type": "boolean"
+          },
+          "allowDownload": {
+            "type": "boolean"
+          },
+          "showExif": {
+            "type": "boolean"
+          }
+        }
+      },
       "SharedLinkResponseDto": {
       "SharedLinkResponseDto": {
         "type": "object",
         "type": "object",
         "properties": {
         "properties": {
@@ -6166,7 +6230,8 @@
             "type": "string"
             "type": "string"
           },
           },
           "description": {
           "description": {
-            "type": "string"
+            "type": "string",
+            "nullable": true
           },
           },
           "userId": {
           "userId": {
             "type": "string"
             "type": "string"
@@ -6205,6 +6270,7 @@
         "required": [
         "required": [
           "type",
           "type",
           "id",
           "id",
+          "description",
           "userId",
           "userId",
           "key",
           "key",
           "createdAt",
           "createdAt",
@@ -6629,6 +6695,9 @@
           "storageLabel": {
           "storageLabel": {
             "type": "string"
             "type": "string"
           },
           },
+          "externalPath": {
+            "type": "string"
+          },
           "isAdmin": {
           "isAdmin": {
             "type": "boolean"
             "type": "boolean"
           },
           },
@@ -6702,6 +6771,10 @@
             "type": "string",
             "type": "string",
             "nullable": true
             "nullable": true
           },
           },
+          "externalPath": {
+            "type": "string",
+            "nullable": true
+          },
           "profileImagePath": {
           "profileImagePath": {
             "type": "string"
             "type": "string"
           },
           },
@@ -6734,6 +6807,7 @@
           "firstName",
           "firstName",
           "lastName",
           "lastName",
           "storageLabel",
           "storageLabel",
+          "externalPath",
           "profileImagePath",
           "profileImagePath",
           "shouldChangePassword",
           "shouldChangePassword",
           "isAdmin",
           "isAdmin",

+ 12 - 0
server/package-lock.json

@@ -21,6 +21,7 @@
         "@nestjs/typeorm": "^9.0.1",
         "@nestjs/typeorm": "^9.0.1",
         "@nestjs/websockets": "^9.2.1",
         "@nestjs/websockets": "^9.2.1",
         "@socket.io/redis-adapter": "^8.0.1",
         "@socket.io/redis-adapter": "^8.0.1",
+        "@types/mime-types": "^2.1.1",
         "archiver": "^5.3.1",
         "archiver": "^5.3.1",
         "axios": "^0.26.0",
         "axios": "^0.26.0",
         "bcrypt": "^5.0.1",
         "bcrypt": "^5.0.1",
@@ -38,6 +39,7 @@
         "local-reverse-geocoder": "0.12.5",
         "local-reverse-geocoder": "0.12.5",
         "lodash": "^4.17.21",
         "lodash": "^4.17.21",
         "luxon": "^3.0.3",
         "luxon": "^3.0.3",
+        "mime-types": "^2.1.35",
         "mv": "^2.1.1",
         "mv": "^2.1.1",
         "nest-commander": "^3.3.0",
         "nest-commander": "^3.3.0",
         "openid-client": "^5.2.1",
         "openid-client": "^5.2.1",
@@ -3018,6 +3020,11 @@
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/@types/mime-types": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
+      "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
+    },
     "node_modules/@types/multer": {
     "node_modules/@types/multer": {
       "version": "1.4.7",
       "version": "1.4.7",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
@@ -14296,6 +14303,11 @@
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
       "dev": true
       "dev": true
     },
     },
+    "@types/mime-types": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/@types/mime-types/-/mime-types-2.1.1.tgz",
+      "integrity": "sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw=="
+    },
     "@types/multer": {
     "@types/multer": {
       "version": "1.4.7",
       "version": "1.4.7",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
       "resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",

+ 2 - 0
server/package.json

@@ -50,6 +50,7 @@
     "@nestjs/typeorm": "^9.0.1",
     "@nestjs/typeorm": "^9.0.1",
     "@nestjs/websockets": "^9.2.1",
     "@nestjs/websockets": "^9.2.1",
     "@socket.io/redis-adapter": "^8.0.1",
     "@socket.io/redis-adapter": "^8.0.1",
+    "@types/mime-types": "^2.1.1",
     "archiver": "^5.3.1",
     "archiver": "^5.3.1",
     "axios": "^0.26.0",
     "axios": "^0.26.0",
     "bcrypt": "^5.0.1",
     "bcrypt": "^5.0.1",
@@ -67,6 +68,7 @@
     "local-reverse-geocoder": "0.12.5",
     "local-reverse-geocoder": "0.12.5",
     "lodash": "^4.17.21",
     "lodash": "^4.17.21",
     "luxon": "^3.0.3",
     "luxon": "^3.0.3",
+    "mime-types": "^2.1.35",
     "mv": "^2.1.1",
     "mv": "^2.1.1",
     "nest-commander": "^3.3.0",
     "nest-commander": "^3.3.0",
     "openid-client": "^5.2.1",
     "openid-client": "^5.2.1",

+ 3 - 0
server/src/domain/access/access.repository.ts

@@ -2,8 +2,11 @@ export const IAccessRepository = 'IAccessRepository';
 
 
 export interface IAccessRepository {
 export interface IAccessRepository {
   hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
   hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
+
   hasAlbumAssetAccess(userId: string, assetId: string): Promise<boolean>;
   hasAlbumAssetAccess(userId: string, assetId: string): Promise<boolean>;
   hasOwnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
   hasOwnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
   hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
   hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
   hasSharedLinkAssetAccess(userId: string, assetId: string): Promise<boolean>;
   hasSharedLinkAssetAccess(userId: string, assetId: string): Promise<boolean>;
+
+  hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>;
 }
 }

+ 1 - 0
server/src/domain/album/album-response.dto.ts

@@ -16,6 +16,7 @@ export class AlbumResponseDto {
   owner!: UserResponseDto;
   owner!: UserResponseDto;
   @ApiProperty({ type: 'integer' })
   @ApiProperty({ type: 'integer' })
   assetCount!: number;
   assetCount!: number;
+  lastModifiedAssetTimestamp?: Date;
 }
 }
 
 
 export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
 export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {

+ 1 - 0
server/src/domain/album/album.service.spec.ts

@@ -169,6 +169,7 @@ describe(AlbumService.name, () => {
           createdAt: new Date('2021-01-01'),
           createdAt: new Date('2021-01-01'),
           deletedAt: null,
           deletedAt: null,
           updatedAt: new Date('2021-01-01'),
           updatedAt: new Date('2021-01-01'),
+          externalPath: null,
         },
         },
         ownerId: 'admin_id',
         ownerId: 'admin_id',
         shared: false,
         shared: false,

+ 13 - 9
server/src/domain/album/album.service.ts

@@ -53,15 +53,19 @@ export class AlbumService {
       return obj;
       return obj;
     }, {});
     }, {});
 
 
-    return albums.map((album) => {
-      return {
-        ...album,
-        assets: album?.assets?.map(mapAsset),
-        sharedLinks: undefined, // Don't return shared links
-        shared: album.sharedLinks?.length > 0 || album.sharedUsers?.length > 0,
-        assetCount: albumsAssetCountObj[album.id],
-      } as AlbumResponseDto;
-    });
+    return Promise.all(
+      albums.map(async (album) => {
+        const lastModifiedAsset = await this.assetRepository.getLastUpdatedAssetForAlbumId(album.id);
+        return {
+          ...album,
+          assets: album?.assets?.map(mapAsset),
+          sharedLinks: undefined, // Don't return shared links
+          shared: album.sharedLinks?.length > 0 || album.sharedUsers?.length > 0,
+          assetCount: albumsAssetCountObj[album.id],
+          lastModifiedAssetTimestamp: lastModifiedAsset?.fileModifiedAt,
+        } as AlbumResponseDto;
+      }),
+    );
   }
   }
 
 
   private async updateInvalidThumbnails(): Promise<number> {
   private async updateInvalidThumbnails(): Promise<number> {

+ 1 - 0
server/src/domain/api-key/api-key.core.ts

@@ -19,6 +19,7 @@ export class APIKeyCore {
         isAdmin: user.isAdmin,
         isAdmin: user.isAdmin,
         isPublicUser: false,
         isPublicUser: false,
         isAllowUpload: true,
         isAllowUpload: true,
+        externalPath: user.externalPath,
       };
       };
     }
     }
 
 

+ 1 - 0
server/src/domain/asset/asset.repository.ts

@@ -47,6 +47,7 @@ export interface IAssetRepository {
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWithout(pagination: PaginationOptions, property: WithoutProperty): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;
   getWith(pagination: PaginationOptions, property: WithProperty): Paginated<AssetEntity>;
   getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   getFirstAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
+  getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null>;
   deleteAll(ownerId: string): Promise<void>;
   deleteAll(ownerId: string): Promise<void>;
   getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
   getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
   save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
   save(asset: Partial<AssetEntity>): Promise<AssetEntity>;

+ 28 - 5
server/src/domain/auth/auth.service.ts

@@ -13,7 +13,7 @@ import { IKeyRepository } from '../api-key';
 import { APIKeyCore } from '../api-key/api-key.core';
 import { APIKeyCore } from '../api-key/api-key.core';
 import { ICryptoRepository } from '../crypto/crypto.repository';
 import { ICryptoRepository } from '../crypto/crypto.repository';
 import { OAuthCore } from '../oauth/oauth.core';
 import { OAuthCore } from '../oauth/oauth.core';
-import { ISharedLinkRepository, SharedLinkCore } from '../shared-link';
+import { ISharedLinkRepository } from '../shared-link';
 import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
 import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
 import { IUserRepository, UserCore } from '../user';
 import { IUserRepository, UserCore } from '../user';
 import { IUserTokenRepository, UserTokenCore } from '../user-token';
 import { IUserTokenRepository, UserTokenCore } from '../user-token';
@@ -35,7 +35,6 @@ export class AuthService {
   private authCore: AuthCore;
   private authCore: AuthCore;
   private oauthCore: OAuthCore;
   private oauthCore: OAuthCore;
   private userCore: UserCore;
   private userCore: UserCore;
-  private shareCore: SharedLinkCore;
   private keyCore: APIKeyCore;
   private keyCore: APIKeyCore;
 
 
   private logger = new Logger(AuthService.name);
   private logger = new Logger(AuthService.name);
@@ -45,7 +44,7 @@ export class AuthService {
     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
     @Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
     @Inject(IUserRepository) userRepository: IUserRepository,
     @Inject(IUserRepository) userRepository: IUserRepository,
     @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
     @Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
-    @Inject(ISharedLinkRepository) shareRepository: ISharedLinkRepository,
+    @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
     @Inject(IKeyRepository) keyRepository: IKeyRepository,
     @Inject(IKeyRepository) keyRepository: IKeyRepository,
     @Inject(INITIAL_SYSTEM_CONFIG)
     @Inject(INITIAL_SYSTEM_CONFIG)
     initialConfig: SystemConfig,
     initialConfig: SystemConfig,
@@ -54,7 +53,6 @@ export class AuthService {
     this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
     this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
     this.oauthCore = new OAuthCore(configRepository, initialConfig);
     this.oauthCore = new OAuthCore(configRepository, initialConfig);
     this.userCore = new UserCore(userRepository, cryptoRepository);
     this.userCore = new UserCore(userRepository, cryptoRepository);
-    this.shareCore = new SharedLinkCore(shareRepository, cryptoRepository);
     this.keyCore = new APIKeyCore(cryptoRepository, keyRepository);
     this.keyCore = new APIKeyCore(cryptoRepository, keyRepository);
   }
   }
 
 
@@ -147,7 +145,7 @@ export class AuthService {
     const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string;
     const apiKey = (headers[IMMICH_API_KEY_HEADER] || params.apiKey) as string;
 
 
     if (shareKey) {
     if (shareKey) {
-      return this.shareCore.validate(shareKey);
+      return this.validateSharedLink(shareKey);
     }
     }
 
 
     if (userToken) {
     if (userToken) {
@@ -193,4 +191,29 @@ export class AuthService {
     const cookies = cookieParser.parse(headers.cookie || '');
     const cookies = cookieParser.parse(headers.cookie || '');
     return cookies[IMMICH_ACCESS_COOKIE] || null;
     return cookies[IMMICH_ACCESS_COOKIE] || null;
   }
   }
+
+  async validateSharedLink(key: string | string[]): Promise<AuthUserDto | null> {
+    key = Array.isArray(key) ? key[0] : key;
+
+    const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
+    const link = await this.sharedLinkRepository.getByKey(bytes);
+    if (link) {
+      if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
+        const user = link.user;
+        if (user) {
+          return {
+            id: user.id,
+            email: user.email,
+            isAdmin: user.isAdmin,
+            isPublicUser: true,
+            sharedLinkId: link.id,
+            isAllowUpload: link.allowUpload,
+            isAllowDownload: link.allowDownload,
+            isShowExif: link.showExif,
+          };
+        }
+      }
+    }
+    throw new UnauthorizedException('Invalid share key');
+  }
 }
 }

+ 1 - 0
server/src/domain/auth/dto/auth-user.dto.ts

@@ -8,4 +8,5 @@ export class AuthUserDto {
   isAllowDownload?: boolean;
   isAllowDownload?: boolean;
   isShowExif?: boolean;
   isShowExif?: boolean;
   accessTokenId?: string;
   accessTokenId?: string;
+  externalPath?: string | null;
 }
 }

+ 1 - 0
server/src/domain/crypto/crypto.repository.ts

@@ -2,6 +2,7 @@ export const ICryptoRepository = 'ICryptoRepository';
 
 
 export interface ICryptoRepository {
 export interface ICryptoRepository {
   randomBytes(size: number): Buffer;
   randomBytes(size: number): Buffer;
+  hashFile(filePath: string): Promise<Buffer>;
   hashSha256(data: string): string;
   hashSha256(data: string): string;
   hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
   hashBcrypt(data: string | Buffer, saltOrRounds: string | number): Promise<string>;
   compareBcrypt(data: string | Buffer, encrypted: string): boolean;
   compareBcrypt(data: string | Buffer, encrypted: string): boolean;

+ 57 - 0
server/src/domain/domain.constant.ts

@@ -27,3 +27,60 @@ export function assertMachineLearningEnabled() {
     throw new BadRequestException('Machine learning is not enabled.');
     throw new BadRequestException('Machine learning is not enabled.');
   }
   }
 }
 }
+
+const validMimeTypes = [
+  'image/avif',
+  'image/gif',
+  'image/heic',
+  'image/heif',
+  'image/jpeg',
+  'image/jxl',
+  'image/png',
+  'image/tiff',
+  'image/webp',
+  'image/x-adobe-dng',
+  'image/x-arriflex-ari',
+  'image/x-canon-cr2',
+  'image/x-canon-cr3',
+  'image/x-canon-crw',
+  'image/x-epson-erf',
+  'image/x-fuji-raf',
+  'image/x-hasselblad-3fr',
+  'image/x-hasselblad-fff',
+  'image/x-kodak-dcr',
+  'image/x-kodak-k25',
+  'image/x-kodak-kdc',
+  'image/x-leica-rwl',
+  'image/x-minolta-mrw',
+  'image/x-nikon-nef',
+  'image/x-olympus-orf',
+  'image/x-olympus-ori',
+  'image/x-panasonic-raw',
+  'image/x-pentax-pef',
+  'image/x-phantom-cin',
+  'image/x-phaseone-cap',
+  'image/x-phaseone-iiq',
+  'image/x-samsung-srw',
+  'image/x-sigma-x3f',
+  'image/x-sony-arw',
+  'image/x-sony-sr2',
+  'image/x-sony-srf',
+  'video/3gpp',
+  'video/mp2t',
+  'video/mp4',
+  'video/mpeg',
+  'video/quicktime',
+  'video/webm',
+  'video/x-flv',
+  'video/x-matroska',
+  'video/x-ms-wmv',
+  'video/x-msvideo',
+];
+
+export function isSupportedFileType(mimetype: string): boolean {
+  return validMimeTypes.includes(mimetype);
+}
+
+export function isSidecarFileType(mimeType: string): boolean {
+  return ['application/xml', 'text/xml'].includes(mimeType);
+}

+ 2 - 0
server/src/domain/partner/partner.service.spec.ts

@@ -17,6 +17,7 @@ const responseDto = {
     createdAt: new Date('2021-01-01'),
     createdAt: new Date('2021-01-01'),
     deletedAt: null,
     deletedAt: null,
     updatedAt: new Date('2021-01-01'),
     updatedAt: new Date('2021-01-01'),
+    externalPath: null,
   },
   },
   user1: {
   user1: {
     email: 'immich@test.com',
     email: 'immich@test.com',
@@ -31,6 +32,7 @@ const responseDto = {
     createdAt: new Date('2021-01-01'),
     createdAt: new Date('2021-01-01'),
     deletedAt: null,
     deletedAt: null,
     updatedAt: new Date('2021-01-01'),
     updatedAt: new Date('2021-01-01'),
+    externalPath: null,
   },
   },
 };
 };
 
 

+ 0 - 12
server/src/domain/shared-link/dto/create-shared-link.dto.ts

@@ -1,12 +0,0 @@
-import { AlbumEntity, AssetEntity, SharedLinkType } from '@app/infra/entities';
-
-export class CreateSharedLinkDto {
-  description?: string;
-  expiresAt?: Date;
-  type!: SharedLinkType;
-  assets!: AssetEntity[];
-  album?: AlbumEntity;
-  allowUpload?: boolean;
-  allowDownload?: boolean;
-  showExif?: boolean;
-}

+ 0 - 18
server/src/domain/shared-link/dto/edit-shared-link.dto.ts

@@ -1,18 +0,0 @@
-import { IsOptional } from 'class-validator';
-
-export class EditSharedLinkDto {
-  @IsOptional()
-  description?: string;
-
-  @IsOptional()
-  expiresAt?: Date | null;
-
-  @IsOptional()
-  allowUpload?: boolean;
-
-  @IsOptional()
-  allowDownload?: boolean;
-
-  @IsOptional()
-  showExif?: boolean;
-}

+ 0 - 2
server/src/domain/shared-link/dto/index.ts

@@ -1,2 +0,0 @@
-export * from './create-shared-link.dto';
-export * from './edit-shared-link.dto';

+ 2 - 3
server/src/domain/shared-link/index.ts

@@ -1,5 +1,4 @@
-export * from './dto';
-export * from './response-dto';
-export * from './shared-link.core';
+export * from './shared-link-response.dto';
+export * from './shared-link.dto';
 export * from './shared-link.repository';
 export * from './shared-link.repository';
 export * from './shared-link.service';
 export * from './shared-link.service';

+ 0 - 1
server/src/domain/shared-link/response-dto/index.ts

@@ -1 +0,0 @@
-export * from './shared-link-response.dto';

+ 3 - 3
server/src/domain/shared-link/response-dto/shared-link-response.dto.ts → server/src/domain/shared-link/shared-link-response.dto.ts

@@ -1,12 +1,12 @@
 import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
 import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
 import _ from 'lodash';
 import _ from 'lodash';
-import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album';
-import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset';
+import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../album';
+import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset';
 
 
 export class SharedLinkResponseDto {
 export class SharedLinkResponseDto {
   id!: string;
   id!: string;
-  description?: string;
+  description!: string | null;
   userId!: string;
   userId!: string;
   key!: string;
   key!: string;
 
 

+ 0 - 80
server/src/domain/shared-link/shared-link.core.ts

@@ -1,80 +0,0 @@
-import { AssetEntity, SharedLinkEntity } from '@app/infra/entities';
-import { BadRequestException, ForbiddenException, Logger, UnauthorizedException } from '@nestjs/common';
-import { AuthUserDto } from '../auth';
-import { ICryptoRepository } from '../crypto';
-import { CreateSharedLinkDto } from './dto';
-import { ISharedLinkRepository } from './shared-link.repository';
-
-export class SharedLinkCore {
-  readonly logger = new Logger(SharedLinkCore.name);
-
-  constructor(private repository: ISharedLinkRepository, private cryptoRepository: ICryptoRepository) {}
-
-  // TODO: move to SharedLinkController/SharedLinkService
-  create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
-    return this.repository.create({
-      key: Buffer.from(this.cryptoRepository.randomBytes(50)),
-      description: dto.description,
-      userId,
-      createdAt: new Date(),
-      expiresAt: dto.expiresAt ?? null,
-      type: dto.type,
-      assets: dto.assets,
-      album: dto.album,
-      allowUpload: dto.allowUpload ?? false,
-      allowDownload: dto.allowDownload ?? true,
-      showExif: dto.showExif ?? true,
-    });
-  }
-
-  async addAssets(userId: string, id: string, assets: AssetEntity[]) {
-    const link = await this.repository.get(userId, id);
-    if (!link) {
-      throw new BadRequestException('Shared link not found');
-    }
-
-    return this.repository.update({ ...link, assets: [...link.assets, ...assets] });
-  }
-
-  async removeAssets(userId: string, id: string, assets: AssetEntity[]) {
-    const link = await this.repository.get(userId, id);
-    if (!link) {
-      throw new BadRequestException('Shared link not found');
-    }
-
-    const newAssets = link.assets.filter((asset) => assets.find((a) => a.id === asset.id));
-
-    return this.repository.update({ ...link, assets: newAssets });
-  }
-
-  checkDownloadAccess(user: AuthUserDto) {
-    if (user.isPublicUser && !user.isAllowDownload) {
-      throw new ForbiddenException();
-    }
-  }
-
-  async validate(key: string | string[]): Promise<AuthUserDto | null> {
-    key = Array.isArray(key) ? key[0] : key;
-
-    const bytes = Buffer.from(key, key.length === 100 ? 'hex' : 'base64url');
-    const link = await this.repository.getByKey(bytes);
-    if (link) {
-      if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
-        const user = link.user;
-        if (user) {
-          return {
-            id: user.id,
-            email: user.email,
-            isAdmin: user.isAdmin,
-            isPublicUser: true,
-            sharedLinkId: link.id,
-            isAllowUpload: link.allowUpload,
-            isAllowDownload: link.allowDownload,
-            isShowExif: link.showExif,
-          };
-        }
-      }
-    }
-    throw new UnauthorizedException('Invalid share key');
-  }
-}

+ 53 - 0
server/src/domain/shared-link/shared-link.dto.ts

@@ -0,0 +1,53 @@
+import { SharedLinkType } from '@app/infra/entities';
+import { ApiProperty } from '@nestjs/swagger';
+import { IsBoolean, IsDate, IsEnum, IsOptional, IsString } from 'class-validator';
+import { ValidateUUID } from '../../immich/decorators/validate-uuid.decorator';
+
+export class SharedLinkCreateDto {
+  @IsEnum(SharedLinkType)
+  @ApiProperty({ enum: SharedLinkType, enumName: 'SharedLinkType' })
+  type!: SharedLinkType;
+
+  @ValidateUUID({ each: true, optional: true })
+  assetIds?: string[];
+
+  @ValidateUUID({ optional: true })
+  albumId?: string;
+
+  @IsString()
+  @IsOptional()
+  description?: string;
+
+  @IsDate()
+  @IsOptional()
+  expiresAt?: Date | null = null;
+
+  @IsOptional()
+  @IsBoolean()
+  allowUpload?: boolean = false;
+
+  @IsOptional()
+  @IsBoolean()
+  allowDownload?: boolean = true;
+
+  @IsOptional()
+  @IsBoolean()
+  showExif?: boolean = true;
+}
+
+export class SharedLinkEditDto {
+  @IsOptional()
+  description?: string;
+
+  @IsOptional()
+  expiresAt?: Date | null;
+
+  @IsOptional()
+  allowUpload?: boolean;
+
+  @IsOptional()
+  allowDownload?: boolean;
+
+  @IsOptional()
+  showExif?: boolean;
+}

+ 1 - 1
server/src/domain/shared-link/shared-link.repository.ts

@@ -6,7 +6,7 @@ export interface ISharedLinkRepository {
   getAll(userId: string): Promise<SharedLinkEntity[]>;
   getAll(userId: string): Promise<SharedLinkEntity[]>;
   get(userId: string, id: string): Promise<SharedLinkEntity | null>;
   get(userId: string, id: string): Promise<SharedLinkEntity | null>;
   getByKey(key: Buffer): Promise<SharedLinkEntity | null>;
   getByKey(key: Buffer): Promise<SharedLinkEntity | null>;
-  create(entity: Omit<SharedLinkEntity, 'id' | 'user'>): Promise<SharedLinkEntity>;
+  create(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
   update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
   update(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
   remove(entity: SharedLinkEntity): Promise<void>;
   remove(entity: SharedLinkEntity): Promise<void>;
 }
 }

+ 149 - 2
server/src/domain/shared-link/shared-link.service.spec.ts

@@ -1,16 +1,33 @@
 import { BadRequestException, ForbiddenException } from '@nestjs/common';
 import { BadRequestException, ForbiddenException } from '@nestjs/common';
-import { authStub, newSharedLinkRepositoryMock, sharedLinkResponseStub, sharedLinkStub } from '@test';
+import {
+  albumStub,
+  assetEntityStub,
+  authStub,
+  newAccessRepositoryMock,
+  newCryptoRepositoryMock,
+  newSharedLinkRepositoryMock,
+  sharedLinkResponseStub,
+  sharedLinkStub,
+} from '@test';
+import { when } from 'jest-when';
+import _ from 'lodash';
+import { SharedLinkType } from '../../infra/entities/shared-link.entity';
+import { AssetIdErrorReason, IAccessRepository, ICryptoRepository } from '../index';
 import { ISharedLinkRepository } from './shared-link.repository';
 import { ISharedLinkRepository } from './shared-link.repository';
 import { SharedLinkService } from './shared-link.service';
 import { SharedLinkService } from './shared-link.service';
 
 
 describe(SharedLinkService.name, () => {
 describe(SharedLinkService.name, () => {
   let sut: SharedLinkService;
   let sut: SharedLinkService;
+  let accessMock: jest.Mocked<IAccessRepository>;
+  let cryptoMock: jest.Mocked<ICryptoRepository>;
   let shareMock: jest.Mocked<ISharedLinkRepository>;
   let shareMock: jest.Mocked<ISharedLinkRepository>;
 
 
   beforeEach(async () => {
   beforeEach(async () => {
+    accessMock = newAccessRepositoryMock();
+    cryptoMock = newCryptoRepositoryMock();
     shareMock = newSharedLinkRepositoryMock();
     shareMock = newSharedLinkRepositoryMock();
 
 
-    sut = new SharedLinkService(shareMock);
+    sut = new SharedLinkService(accessMock, cryptoMock, shareMock);
   });
   });
 
 
   it('should work', () => {
   it('should work', () => {
@@ -64,6 +81,82 @@ describe(SharedLinkService.name, () => {
     });
     });
   });
   });
 
 
+  describe('create', () => {
+    it('should not allow an album shared link without an albumId', async () => {
+      await expect(sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [] })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+    });
+
+    it('should not allow non-owners to create album shared links', async () => {
+      accessMock.hasAlbumOwnerAccess.mockResolvedValue(false);
+      await expect(
+        sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [], albumId: 'album-1' }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should not allow individual shared links with no assets', async () => {
+      await expect(
+        sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: [] }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should require asset ownership to make an individual shared link', async () => {
+      accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
+      await expect(
+        sut.create(authStub.admin, { type: SharedLinkType.INDIVIDUAL, assetIds: ['asset-1'] }),
+      ).rejects.toBeInstanceOf(BadRequestException);
+    });
+
+    it('should create an album shared link', async () => {
+      accessMock.hasAlbumOwnerAccess.mockResolvedValue(true);
+      shareMock.create.mockResolvedValue(sharedLinkStub.valid);
+
+      await sut.create(authStub.admin, { type: SharedLinkType.ALBUM, albumId: albumStub.oneAsset.id });
+
+      expect(accessMock.hasAlbumOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
+      expect(shareMock.create).toHaveBeenCalledWith({
+        type: SharedLinkType.ALBUM,
+        userId: authStub.admin.id,
+        albumId: albumStub.oneAsset.id,
+        allowDownload: true,
+        allowUpload: true,
+        assets: [],
+        description: null,
+        expiresAt: null,
+        showExif: true,
+        key: Buffer.from('random-bytes', 'utf8'),
+      });
+    });
+
+    it('should create an individual shared link', async () => {
+      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
+      shareMock.create.mockResolvedValue(sharedLinkStub.individual);
+
+      await sut.create(authStub.admin, {
+        type: SharedLinkType.INDIVIDUAL,
+        assetIds: [assetEntityStub.image.id],
+        showExif: true,
+        allowDownload: true,
+        allowUpload: true,
+      });
+
+      expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
+      expect(shareMock.create).toHaveBeenCalledWith({
+        type: SharedLinkType.INDIVIDUAL,
+        userId: authStub.admin.id,
+        albumId: null,
+        allowDownload: true,
+        allowUpload: true,
+        assets: [{ id: assetEntityStub.image.id }],
+        description: null,
+        expiresAt: null,
+        showExif: true,
+        key: Buffer.from('random-bytes', 'utf8'),
+      });
+    });
+  });
+
   describe('update', () => {
   describe('update', () => {
     it('should throw an error for an invalid shared link', async () => {
     it('should throw an error for an invalid shared link', async () => {
       shareMock.get.mockResolvedValue(null);
       shareMock.get.mockResolvedValue(null);
@@ -100,4 +193,58 @@ describe(SharedLinkService.name, () => {
       expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
       expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
     });
     });
   });
   });
+
+  describe('addAssets', () => {
+    it('should not work on album shared links', async () => {
+      shareMock.get.mockResolvedValue(sharedLinkStub.valid);
+      await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+    });
+
+    it('should add assets to a shared link', async () => {
+      shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
+      shareMock.create.mockResolvedValue(sharedLinkStub.individual);
+
+      when(accessMock.hasOwnerAssetAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false);
+      when(accessMock.hasOwnerAssetAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
+
+      await expect(
+        sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2', 'asset-3'] }),
+      ).resolves.toEqual([
+        { assetId: assetEntityStub.image.id, success: false, error: AssetIdErrorReason.DUPLICATE },
+        { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NO_PERMISSION },
+        { assetId: 'asset-3', success: true },
+      ]);
+
+      expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledTimes(2);
+      expect(shareMock.update).toHaveBeenCalledWith({
+        ...sharedLinkStub.individual,
+        assets: [assetEntityStub.image, { id: 'asset-3' }],
+      });
+    });
+  });
+
+  describe('removeAssets', () => {
+    it('should not work on album shared links', async () => {
+      shareMock.get.mockResolvedValue(sharedLinkStub.valid);
+      await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+    });
+
+    it('should remove assets from a shared link', async () => {
+      shareMock.get.mockResolvedValue(_.cloneDeep(sharedLinkStub.individual));
+      shareMock.create.mockResolvedValue(sharedLinkStub.individual);
+
+      await expect(
+        sut.removeAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2'] }),
+      ).resolves.toEqual([
+        { assetId: assetEntityStub.image.id, success: true },
+        { assetId: 'asset-2', success: false, error: AssetIdErrorReason.NOT_FOUND },
+      ]);
+
+      expect(shareMock.update).toHaveBeenCalledWith({ ...sharedLinkStub.individual, assets: [] });
+    });
+  });
 });
 });

+ 112 - 6
server/src/domain/shared-link/shared-link.service.ts

@@ -1,15 +1,22 @@
-import { SharedLinkEntity } from '@app/infra/entities';
+import { AssetEntity, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
 import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
 import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
+import { IAccessRepository } from '../access';
+import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
 import { AuthUserDto } from '../auth';
 import { AuthUserDto } from '../auth';
-import { EditSharedLinkDto } from './dto';
-import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto';
+import { ICryptoRepository } from '../crypto';
+import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './shared-link-response.dto';
+import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto';
 import { ISharedLinkRepository } from './shared-link.repository';
 import { ISharedLinkRepository } from './shared-link.repository';
 
 
 @Injectable()
 @Injectable()
 export class SharedLinkService {
 export class SharedLinkService {
-  constructor(@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository) {}
+  constructor(
+    @Inject(IAccessRepository) private accessRepository: IAccessRepository,
+    @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
+    @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
+  ) {}
 
 
-  async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
+  getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
     return this.repository.getAll(authUser.id).then((links) => links.map(mapSharedLink));
     return this.repository.getAll(authUser.id).then((links) => links.map(mapSharedLink));
   }
   }
 
 
@@ -30,7 +37,52 @@ export class SharedLinkService {
     return this.map(sharedLink, { withExif: true });
     return this.map(sharedLink, { withExif: true });
   }
   }
 
 
-  async update(authUser: AuthUserDto, id: string, dto: EditSharedLinkDto) {
+  async create(authUser: AuthUserDto, dto: SharedLinkCreateDto): Promise<SharedLinkResponseDto> {
+    switch (dto.type) {
+      case SharedLinkType.ALBUM:
+        if (!dto.albumId) {
+          throw new BadRequestException('Invalid albumId');
+        }
+
+        const isAlbumOwner = await this.accessRepository.hasAlbumOwnerAccess(authUser.id, dto.albumId);
+        if (!isAlbumOwner) {
+          throw new BadRequestException('Invalid albumId');
+        }
+
+        break;
+
+      case SharedLinkType.INDIVIDUAL:
+        if (!dto.assetIds || dto.assetIds.length === 0) {
+          throw new BadRequestException('Invalid assetIds');
+        }
+
+        for (const assetId of dto.assetIds) {
+          const hasAccess = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
+          if (!hasAccess) {
+            throw new BadRequestException(`No access to assetId: ${assetId}`);
+          }
+        }
+
+        break;
+    }
+
+    const sharedLink = await this.repository.create({
+      key: this.cryptoRepository.randomBytes(50),
+      userId: authUser.id,
+      type: dto.type,
+      albumId: dto.albumId || null,
+      assets: (dto.assetIds || []).map((id) => ({ id } as AssetEntity)),
+      description: dto.description || null,
+      expiresAt: dto.expiresAt || null,
+      allowUpload: dto.allowUpload ?? true,
+      allowDownload: dto.allowDownload ?? true,
+      showExif: dto.showExif ?? true,
+    });
+
+    return this.map(sharedLink, { withExif: true });
+  }
+
+  async update(authUser: AuthUserDto, id: string, dto: SharedLinkEditDto) {
     await this.findOrFail(authUser, id);
     await this.findOrFail(authUser, id);
     const sharedLink = await this.repository.update({
     const sharedLink = await this.repository.update({
       id,
       id,
@@ -57,6 +109,60 @@ export class SharedLinkService {
     return sharedLink;
     return sharedLink;
   }
   }
 
 
+  async addAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
+    const sharedLink = await this.findOrFail(authUser, id);
+
+    if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
+      throw new BadRequestException('Invalid shared link type');
+    }
+
+    const results: AssetIdsResponseDto[] = [];
+    for (const assetId of dto.assetIds) {
+      const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId);
+      if (hasAsset) {
+        results.push({ assetId, success: false, error: AssetIdErrorReason.DUPLICATE });
+        continue;
+      }
+
+      const hasAccess = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
+      if (!hasAccess) {
+        results.push({ assetId, success: false, error: AssetIdErrorReason.NO_PERMISSION });
+        continue;
+      }
+
+      results.push({ assetId, success: true });
+      sharedLink.assets.push({ id: assetId } as AssetEntity);
+    }
+
+    await this.repository.update(sharedLink);
+
+    return results;
+  }
+
+  async removeAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {
+    const sharedLink = await this.findOrFail(authUser, id);
+
+    if (sharedLink.type !== SharedLinkType.INDIVIDUAL) {
+      throw new BadRequestException('Invalid shared link type');
+    }
+
+    const results: AssetIdsResponseDto[] = [];
+    for (const assetId of dto.assetIds) {
+      const hasAsset = sharedLink.assets.find((asset) => asset.id === assetId);
+      if (!hasAsset) {
+        results.push({ assetId, success: false, error: AssetIdErrorReason.NOT_FOUND });
+        continue;
+      }
+
+      results.push({ assetId, success: true });
+      sharedLink.assets = sharedLink.assets.filter((asset) => asset.id !== assetId);
+    }
+
+    await this.repository.update(sharedLink);
+
+    return results;
+  }
+
   private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
   private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
     return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink);
     return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink);
   }
   }

+ 21 - 0
server/src/domain/storage-template/storage-template.service.spec.ts

@@ -194,5 +194,26 @@ describe(StorageTemplateService.name, () => {
         ['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'],
         ['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'],
       ]);
       ]);
     });
     });
+
+    it('should not move read-only asset', async () => {
+      assetMock.getAll.mockResolvedValue({
+        items: [
+          {
+            ...assetEntityStub.image,
+            originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext',
+            isReadOnly: true,
+          },
+        ],
+        hasNextPage: false,
+      });
+      assetMock.save.mockResolvedValue(assetEntityStub.image);
+      userMock.getList.mockResolvedValue([userEntityStub.user1]);
+
+      await sut.handleMigration();
+
+      expect(assetMock.getAll).toHaveBeenCalled();
+      expect(storageMock.moveFile).not.toHaveBeenCalled();
+      expect(assetMock.save).not.toHaveBeenCalled();
+    });
   });
   });
 });
 });

+ 9 - 1
server/src/domain/storage-template/storage-template.service.ts

@@ -76,6 +76,11 @@ export class StorageTemplateService {
 
 
   // TODO: use asset core (once in domain)
   // TODO: use asset core (once in domain)
   async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
   async moveAsset(asset: AssetEntity, metadata: MoveAssetMetadata) {
+    if (asset.isReadOnly) {
+      this.logger.verbose(`Not moving read-only asset: ${asset.originalPath}`);
+      return;
+    }
+
     const destination = await this.core.getTemplatePath(asset, metadata);
     const destination = await this.core.getTemplatePath(asset, metadata);
     if (asset.originalPath !== destination) {
     if (asset.originalPath !== destination) {
       const source = asset.originalPath;
       const source = asset.originalPath;
@@ -96,7 +101,10 @@ export class StorageTemplateService {
           asset.originalPath = destination;
           asset.originalPath = destination;
           asset.sidecarPath = sidecarDestination || null;
           asset.sidecarPath = sidecarDestination || null;
         } catch (error: any) {
         } catch (error: any) {
-          this.logger.warn('Unable to save new originalPath to database, undoing move', error?.stack);
+          this.logger.warn(
+            `Unable to save new originalPath to database, undoing move for path ${asset.originalPath} - filename ${asset.originalFileName} - id ${asset.id}`,
+            error?.stack,
+          );
 
 
           // Either sidecar move failed or the save failed. Eithr way, move media back
           // Either sidecar move failed or the save failed. Eithr way, move media back
           await this.storageRepository.moveFile(destination, source);
           await this.storageRepository.moveFile(destination, source);

+ 4 - 0
server/src/domain/user/dto/create-user.dto.ts

@@ -23,6 +23,10 @@ export class CreateUserDto {
   @IsString()
   @IsString()
   @Transform(toSanitized)
   @Transform(toSanitized)
   storageLabel?: string | null;
   storageLabel?: string | null;
+
+  @IsOptional()
+  @IsString()
+  externalPath?: string | null;
 }
 }
 
 
 export class CreateAdminDto {
 export class CreateAdminDto {

+ 4 - 0
server/src/domain/user/dto/update-user.dto.ts

@@ -29,6 +29,10 @@ export class UpdateUserDto {
   @Transform(toSanitized)
   @Transform(toSanitized)
   storageLabel?: string;
   storageLabel?: string;
 
 
+  @IsOptional()
+  @IsString()
+  externalPath?: string;
+
   @IsNotEmpty()
   @IsNotEmpty()
   @IsUUID('4')
   @IsUUID('4')
   @ApiProperty({ format: 'uuid' })
   @ApiProperty({ format: 'uuid' })

+ 2 - 0
server/src/domain/user/response-dto/user-response.dto.ts

@@ -6,6 +6,7 @@ export class UserResponseDto {
   firstName!: string;
   firstName!: string;
   lastName!: string;
   lastName!: string;
   storageLabel!: string | null;
   storageLabel!: string | null;
+  externalPath!: string | null;
   profileImagePath!: string;
   profileImagePath!: string;
   shouldChangePassword!: boolean;
   shouldChangePassword!: boolean;
   isAdmin!: boolean;
   isAdmin!: boolean;
@@ -22,6 +23,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
     firstName: entity.firstName,
     firstName: entity.firstName,
     lastName: entity.lastName,
     lastName: entity.lastName,
     storageLabel: entity.storageLabel,
     storageLabel: entity.storageLabel,
+    externalPath: entity.externalPath,
     profileImagePath: entity.profileImagePath,
     profileImagePath: entity.profileImagePath,
     shouldChangePassword: entity.shouldChangePassword,
     shouldChangePassword: entity.shouldChangePassword,
     isAdmin: entity.isAdmin,
     isAdmin: entity.isAdmin,

+ 6 - 2
server/src/domain/user/user.core.ts

@@ -6,7 +6,6 @@ import {
   Logger,
   Logger,
   NotFoundException,
   NotFoundException,
 } from '@nestjs/common';
 } from '@nestjs/common';
-import { hash } from 'bcrypt';
 import { constants, createReadStream, ReadStream } from 'fs';
 import { constants, createReadStream, ReadStream } from 'fs';
 import fs from 'fs/promises';
 import fs from 'fs/promises';
 import { AuthUserDto } from '../auth';
 import { AuthUserDto } from '../auth';
@@ -28,6 +27,7 @@ export class UserCore {
       // Users can never update the isAdmin property.
       // Users can never update the isAdmin property.
       delete dto.isAdmin;
       delete dto.isAdmin;
       delete dto.storageLabel;
       delete dto.storageLabel;
+      delete dto.externalPath;
     } else if (dto.isAdmin && authUser.id !== id) {
     } else if (dto.isAdmin && authUser.id !== id) {
       // Admin cannot create another admin.
       // Admin cannot create another admin.
       throw new BadRequestException('The server already has an admin');
       throw new BadRequestException('The server already has an admin');
@@ -56,6 +56,10 @@ export class UserCore {
         dto.storageLabel = null;
         dto.storageLabel = null;
       }
       }
 
 
+      if (dto.externalPath === '') {
+        dto.externalPath = null;
+      }
+
       return this.userRepository.update(id, dto);
       return this.userRepository.update(id, dto);
     } catch (e) {
     } catch (e) {
       Logger.error(e, 'Failed to update user info');
       Logger.error(e, 'Failed to update user info');
@@ -79,7 +83,7 @@ export class UserCore {
     try {
     try {
       const payload: Partial<UserEntity> = { ...createUserDto };
       const payload: Partial<UserEntity> = { ...createUserDto };
       if (payload.password) {
       if (payload.password) {
-        payload.password = await hash(payload.password, SALT_ROUNDS);
+        payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
       }
       }
       return this.userRepository.create(payload);
       return this.userRepository.create(payload);
     } catch (e) {
     } catch (e) {

+ 5 - 0
server/src/domain/user/user.service.spec.ts

@@ -53,6 +53,7 @@ const adminUser: UserEntity = Object.freeze({
   tags: [],
   tags: [],
   assets: [],
   assets: [],
   storageLabel: 'admin',
   storageLabel: 'admin',
+  externalPath: null,
 });
 });
 
 
 const immichUser: UserEntity = Object.freeze({
 const immichUser: UserEntity = Object.freeze({
@@ -71,6 +72,7 @@ const immichUser: UserEntity = Object.freeze({
   tags: [],
   tags: [],
   assets: [],
   assets: [],
   storageLabel: null,
   storageLabel: null,
+  externalPath: null,
 });
 });
 
 
 const updatedImmichUser: UserEntity = Object.freeze({
 const updatedImmichUser: UserEntity = Object.freeze({
@@ -89,6 +91,7 @@ const updatedImmichUser: UserEntity = Object.freeze({
   tags: [],
   tags: [],
   assets: [],
   assets: [],
   storageLabel: null,
   storageLabel: null,
+  externalPath: null,
 });
 });
 
 
 const adminUserResponse = Object.freeze({
 const adminUserResponse = Object.freeze({
@@ -104,6 +107,7 @@ const adminUserResponse = Object.freeze({
   deletedAt: null,
   deletedAt: null,
   updatedAt: new Date('2021-01-01'),
   updatedAt: new Date('2021-01-01'),
   storageLabel: 'admin',
   storageLabel: 'admin',
+  externalPath: null,
 });
 });
 
 
 describe(UserService.name, () => {
 describe(UserService.name, () => {
@@ -153,6 +157,7 @@ describe(UserService.name, () => {
           deletedAt: null,
           deletedAt: null,
           updatedAt: new Date('2021-01-01'),
           updatedAt: new Date('2021-01-01'),
           storageLabel: 'admin',
           storageLabel: 'admin',
+          externalPath: null,
         },
         },
       ]);
       ]);
     });
     });

+ 1 - 7
server/src/immich/api-v1/album/album.controller.ts

@@ -1,5 +1,5 @@
 import { AlbumResponseDto } from '@app/domain';
 import { AlbumResponseDto } from '@app/domain';
-import { Body, Controller, Delete, Get, Param, Post, Put, Query, Response } from '@nestjs/common';
+import { Body, Controller, Delete, Get, Param, Put, Query, Response } from '@nestjs/common';
 import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
 import { Response as Res } from 'express';
 import { Response as Res } from 'express';
 import { handleDownload } from '../../app.utils';
 import { handleDownload } from '../../app.utils';
@@ -10,7 +10,6 @@ import { UseValidation } from '../../decorators/use-validation.decorator';
 import { DownloadDto } from '../asset/dto/download-library.dto';
 import { DownloadDto } from '../asset/dto/download-library.dto';
 import { AlbumService } from './album.service';
 import { AlbumService } from './album.service';
 import { AddAssetsDto } from './dto/add-assets.dto';
 import { AddAssetsDto } from './dto/add-assets.dto';
-import { CreateAlbumShareLinkDto as CreateAlbumSharedLinkDto } from './dto/create-album-shared-link.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 
 
@@ -59,9 +58,4 @@ export class AlbumController {
   ) {
   ) {
     return this.service.downloadArchive(authUser, id, dto).then((download) => handleDownload(download, res));
     return this.service.downloadArchive(authUser, id, dto).then((download) => handleDownload(download, res));
   }
   }
-
-  @Post('create-shared-link')
-  createAlbumSharedLink(@AuthUser() authUser: AuthUserDto, @Body() dto: CreateAlbumSharedLinkDto) {
-    return this.service.createSharedLink(authUser, dto);
-  }
 }
 }

+ 4 - 14
server/src/immich/api-v1/album/album.service.spec.ts

@@ -1,7 +1,7 @@
-import { AlbumResponseDto, ICryptoRepository, ISharedLinkRepository, mapUser } from '@app/domain';
+import { AlbumResponseDto, mapUser } from '@app/domain';
 import { AlbumEntity, UserEntity } from '@app/infra/entities';
 import { AlbumEntity, UserEntity } from '@app/infra/entities';
 import { ForbiddenException, NotFoundException } from '@nestjs/common';
 import { ForbiddenException, NotFoundException } from '@nestjs/common';
-import { newCryptoRepositoryMock, newSharedLinkRepositoryMock, userEntityStub } from '@test';
+import { userEntityStub } from '@test';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { DownloadService } from '../../modules/download/download.service';
 import { DownloadService } from '../../modules/download/download.service';
 import { IAlbumRepository } from './album-repository';
 import { IAlbumRepository } from './album-repository';
@@ -11,9 +11,7 @@ import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 describe('Album service', () => {
 describe('Album service', () => {
   let sut: AlbumService;
   let sut: AlbumService;
   let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
   let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
-  let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
-  let cryptoMock: jest.Mocked<ICryptoRepository>;
 
 
   const authUser: AuthUserDto = Object.freeze({
   const authUser: AuthUserDto = Object.freeze({
     id: '1111',
     id: '1111',
@@ -34,6 +32,7 @@ describe('Album service', () => {
     tags: [],
     tags: [],
     assets: [],
     assets: [],
     storageLabel: null,
     storageLabel: null,
+    externalPath: null,
   });
   });
   const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
   const albumId = 'f19ab956-4761-41ea-a5d6-bae948308d58';
   const sharedAlbumOwnerId = '2222';
   const sharedAlbumOwnerId = '2222';
@@ -99,20 +98,11 @@ describe('Album service', () => {
       updateThumbnails: jest.fn(),
       updateThumbnails: jest.fn(),
     };
     };
 
 
-    sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
-
     downloadServiceMock = {
     downloadServiceMock = {
       downloadArchive: jest.fn(),
       downloadArchive: jest.fn(),
     };
     };
 
 
-    cryptoMock = newCryptoRepositoryMock();
-
-    sut = new AlbumService(
-      albumRepositoryMock,
-      sharedLinkRepositoryMock,
-      downloadServiceMock as DownloadService,
-      cryptoMock,
-    );
+    sut = new AlbumService(albumRepositoryMock, downloadServiceMock as DownloadService);
   });
   });
 
 
   it('gets an owned album', async () => {
   it('gets an owned album', async () => {

+ 9 - 34
server/src/immich/api-v1/album/album.service.ts

@@ -1,36 +1,22 @@
-import {
-  AlbumResponseDto,
-  ICryptoRepository,
-  ISharedLinkRepository,
-  mapAlbum,
-  mapSharedLink,
-  SharedLinkCore,
-  SharedLinkResponseDto,
-} from '@app/domain';
-import { AlbumEntity, SharedLinkType } from '@app/infra/entities';
+import { AlbumResponseDto, mapAlbum } from '@app/domain';
+import { AlbumEntity } from '@app/infra/entities';
 import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
 import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { DownloadService } from '../../modules/download/download.service';
 import { DownloadService } from '../../modules/download/download.service';
 import { DownloadDto } from '../asset/dto/download-library.dto';
 import { DownloadDto } from '../asset/dto/download-library.dto';
 import { IAlbumRepository } from './album-repository';
 import { IAlbumRepository } from './album-repository';
 import { AddAssetsDto } from './dto/add-assets.dto';
 import { AddAssetsDto } from './dto/add-assets.dto';
-import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 
 
 @Injectable()
 @Injectable()
 export class AlbumService {
 export class AlbumService {
-  readonly logger = new Logger(AlbumService.name);
-  private shareCore: SharedLinkCore;
+  private logger = new Logger(AlbumService.name);
 
 
   constructor(
   constructor(
     @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
     @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
-    @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
     private downloadService: DownloadService,
     private downloadService: DownloadService,
-    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
-  ) {
-    this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository);
-  }
+  ) {}
 
 
   private async _getAlbum({
   private async _getAlbum({
     authUser,
     authUser,
@@ -91,7 +77,7 @@ export class AlbumService {
   }
   }
 
 
   async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) {
   async downloadArchive(authUser: AuthUserDto, albumId: string, dto: DownloadDto) {
-    this.shareCore.checkDownloadAccess(authUser);
+    this.checkDownloadAccess(authUser);
 
 
     const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
     const album = await this._getAlbum({ authUser, albumId, validateIsOwner: false });
     const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0);
     const assets = (album.assets || []).map((asset) => asset).slice(dto.skip || 0);
@@ -99,20 +85,9 @@ export class AlbumService {
     return this.downloadService.downloadArchive(album.albumName, assets);
     return this.downloadService.downloadArchive(album.albumName, assets);
   }
   }
 
 
-  async createSharedLink(authUser: AuthUserDto, dto: CreateAlbumShareLinkDto): Promise<SharedLinkResponseDto> {
-    const album = await this._getAlbum({ authUser, albumId: dto.albumId });
-
-    const sharedLink = await this.shareCore.create(authUser.id, {
-      type: SharedLinkType.ALBUM,
-      expiresAt: dto.expiresAt,
-      allowUpload: dto.allowUpload,
-      album,
-      assets: [],
-      description: dto.description,
-      allowDownload: dto.allowDownload,
-      showExif: dto.showExif,
-    });
-
-    return mapSharedLink(sharedLink);
+  private checkDownloadAccess(authUser: AuthUserDto) {
+    if (authUser.isPublicUser && !authUser.isAllowDownload) {
+      throw new ForbiddenException();
+    }
   }
   }
 }
 }

+ 0 - 35
server/src/immich/api-v1/album/dto/create-album-shared-link.dto.ts

@@ -1,35 +0,0 @@
-import { ValidateUUID } from '@app/immich/decorators/validate-uuid.decorator';
-import { ApiProperty } from '@nestjs/swagger';
-import { Type } from 'class-transformer';
-import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator';
-
-export class CreateAlbumShareLinkDto {
-  @ValidateUUID()
-  albumId!: string;
-
-  @IsOptional()
-  @IsDate()
-  @Type(() => Date)
-  @ApiProperty()
-  expiresAt?: Date;
-
-  @IsBoolean()
-  @IsOptional()
-  @ApiProperty()
-  allowUpload?: boolean;
-
-  @IsBoolean()
-  @IsOptional()
-  @ApiProperty()
-  allowDownload?: boolean;
-
-  @IsBoolean()
-  @IsOptional()
-  @ApiProperty()
-  showExif?: boolean;
-
-  @IsString()
-  @IsOptional()
-  @ApiProperty()
-  description?: string;
-}

+ 18 - 0
server/src/immich/api-v1/asset/asset-repository.ts

@@ -20,6 +20,10 @@ export interface AssetCheck {
   checksum: Buffer;
   checksum: Buffer;
 }
 }
 
 
+export interface AssetOwnerCheck extends AssetCheck {
+  ownerId: string;
+}
+
 export interface IAssetRepository {
 export interface IAssetRepository {
   get(id: string): Promise<AssetEntity | null>;
   get(id: string): Promise<AssetEntity | null>;
   create(
   create(
@@ -39,6 +43,7 @@ export interface IAssetRepository {
   getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
   getAssetByTimeBucket(userId: string, getAssetByTimeBucketDto: GetAssetByTimeBucketDto): Promise<AssetEntity[]>;
   getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
   getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
   getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
   getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
+  getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>;
 }
 }
 
 
 export const IAssetRepository = 'IAssetRepository';
 export const IAssetRepository = 'IAssetRepository';
@@ -350,4 +355,17 @@ export class AssetRepository implements IAssetRepository {
 
 
     return assetCountByUserId;
     return assetCountByUserId;
   }
   }
+
+  getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null> {
+    return this.assetRepository.findOne({
+      select: {
+        id: true,
+        ownerId: true,
+        checksum: true,
+      },
+      where: {
+        originalPath,
+      },
+    });
+  }
 }
 }

+ 16 - 32
server/src/immich/api-v1/asset/asset.controller.ts

@@ -1,4 +1,4 @@
-import { AssetResponseDto, ImmichReadStream, SharedLinkResponseDto } from '@app/domain';
+import { AssetResponseDto, ImmichReadStream } from '@app/domain';
 import {
 import {
   Body,
   Body,
   Controller,
   Controller,
@@ -10,7 +10,6 @@ import {
   HttpStatus,
   HttpStatus,
   Param,
   Param,
   ParseFilePipe,
   ParseFilePipe,
-  Patch,
   Post,
   Post,
   Put,
   Put,
   Query,
   Query,
@@ -28,16 +27,13 @@ import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config'
 import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
 import { UUIDParamDto } from '../../controllers/dto/uuid-param.dto';
 import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
 import { AuthUser, AuthUserDto } from '../../decorators/auth-user.decorator';
 import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
 import { Authenticated, SharedLinkRoute } from '../../decorators/authenticated.decorator';
-import { AddAssetsDto } from '../album/dto/add-assets.dto';
-import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
 import FileNotEmptyValidator from '../validation/file-not-empty-validator';
 import FileNotEmptyValidator from '../validation/file-not-empty-validator';
 import { AssetService } from './asset.service';
 import { AssetService } from './asset.service';
 import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
 import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
 import { AssetSearchDto } from './dto/asset-search.dto';
 import { AssetSearchDto } from './dto/asset-search.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
-import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
-import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
+import { CreateAssetDto, ImportAssetDto, mapToUploadFile } from './dto/create-asset.dto';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { DeviceIdDto } from './dto/device-id.dto';
 import { DeviceIdDto } from './dto/device-id.dto';
 import { DownloadFilesDto } from './dto/download-files.dto';
 import { DownloadFilesDto } from './dto/download-files.dto';
@@ -118,6 +114,20 @@ export class AssetController {
     return responseDto;
     return responseDto;
   }
   }
 
 
+  @Post('import')
+  async importFile(
+    @AuthUser() authUser: AuthUserDto,
+    @Body(new ValidationPipe()) dto: ImportAssetDto,
+    @Response({ passthrough: true }) res: Res,
+  ): Promise<AssetFileUploadResponseDto> {
+    const responseDto = await this.assetService.importFile(authUser, dto);
+    if (responseDto.duplicate) {
+      res.status(200);
+    }
+
+    return responseDto;
+  }
+
   @SharedLinkRoute()
   @SharedLinkRoute()
   @Get('/download/:id')
   @Get('/download/:id')
   @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
   @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
@@ -319,30 +329,4 @@ export class AssetController {
   ): Promise<AssetBulkUploadCheckResponseDto> {
   ): Promise<AssetBulkUploadCheckResponseDto> {
     return this.assetService.bulkUploadCheck(authUser, dto);
     return this.assetService.bulkUploadCheck(authUser, dto);
   }
   }
-
-  @Post('/shared-link')
-  createAssetsSharedLink(
-    @AuthUser() authUser: AuthUserDto,
-    @Body(ValidationPipe) dto: CreateAssetsShareLinkDto,
-  ): Promise<SharedLinkResponseDto> {
-    return this.assetService.createAssetsSharedLink(authUser, dto);
-  }
-
-  @SharedLinkRoute()
-  @Patch('/shared-link/add')
-  addAssetsToSharedLink(
-    @AuthUser() authUser: AuthUserDto,
-    @Body(ValidationPipe) dto: AddAssetsDto,
-  ): Promise<SharedLinkResponseDto> {
-    return this.assetService.addAssetsToSharedLink(authUser, dto);
-  }
-
-  @SharedLinkRoute()
-  @Patch('/shared-link/remove')
-  removeAssetsFromSharedLink(
-    @AuthUser() authUser: AuthUserDto,
-    @Body(ValidationPipe) dto: RemoveAssetsDto,
-  ): Promise<SharedLinkResponseDto> {
-    return this.assetService.removeAssetsFromSharedLink(authUser, dto);
-  }
 }
 }

+ 5 - 4
server/src/immich/api-v1/asset/asset.core.ts

@@ -2,17 +2,17 @@ import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
 import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
 import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
 import { parse } from 'node:path';
 import { parse } from 'node:path';
 import { IAssetRepository } from './asset-repository';
 import { IAssetRepository } from './asset-repository';
-import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
+import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
 
 
 export class AssetCore {
 export class AssetCore {
   constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {}
   constructor(private repository: IAssetRepository, private jobRepository: IJobRepository) {}
 
 
   async create(
   async create(
     authUser: AuthUserDto,
     authUser: AuthUserDto,
-    dto: CreateAssetDto,
+    dto: CreateAssetDto | ImportAssetDto,
     file: UploadFile,
     file: UploadFile,
     livePhotoAssetId?: string,
     livePhotoAssetId?: string,
-    sidecarFile?: UploadFile,
+    sidecarPath?: string,
   ): Promise<AssetEntity> {
   ): Promise<AssetEntity> {
     const asset = await this.repository.create({
     const asset = await this.repository.create({
       owner: { id: authUser.id } as UserEntity,
       owner: { id: authUser.id } as UserEntity,
@@ -41,7 +41,8 @@ export class AssetCore {
       sharedLinks: [],
       sharedLinks: [],
       originalFileName: parse(file.originalName).name,
       originalFileName: parse(file.originalName).name,
       faces: [],
       faces: [],
-      sidecarPath: sidecarFile?.originalPath || null,
+      sidecarPath: sidecarPath || null,
+      isReadOnly: dto.isReadOnly ?? false,
     });
     });
 
 
     await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
     await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });

+ 45 - 90
server/src/immich/api-v1/asset/asset.service.spec.ts

@@ -1,13 +1,6 @@
-import {
-  IAccessRepository,
-  ICryptoRepository,
-  IJobRepository,
-  ISharedLinkRepository,
-  IStorageRepository,
-  JobName,
-} from '@app/domain';
+import { IAccessRepository, ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
 import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
 import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
-import { BadRequestException, ForbiddenException } from '@nestjs/common';
+import { ForbiddenException } from '@nestjs/common';
 import {
 import {
   assetEntityStub,
   assetEntityStub,
   authStub,
   authStub,
@@ -15,17 +8,13 @@ import {
   newAccessRepositoryMock,
   newAccessRepositoryMock,
   newCryptoRepositoryMock,
   newCryptoRepositoryMock,
   newJobRepositoryMock,
   newJobRepositoryMock,
-  newSharedLinkRepositoryMock,
   newStorageRepositoryMock,
   newStorageRepositoryMock,
-  sharedLinkResponseStub,
-  sharedLinkStub,
 } from '@test';
 } from '@test';
 import { when } from 'jest-when';
 import { when } from 'jest-when';
 import { QueryFailedError, Repository } from 'typeorm';
 import { QueryFailedError, Repository } from 'typeorm';
 import { DownloadService } from '../../modules/download/download.service';
 import { DownloadService } from '../../modules/download/download.service';
 import { IAssetRepository } from './asset-repository';
 import { IAssetRepository } from './asset-repository';
 import { AssetService } from './asset.service';
 import { AssetService } from './asset.service';
-import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
 import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
 import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
 import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
@@ -133,9 +122,8 @@ describe('AssetService', () => {
   let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
   let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
   let accessMock: jest.Mocked<IAccessRepository>;
   let accessMock: jest.Mocked<IAccessRepository>;
   let assetRepositoryMock: jest.Mocked<IAssetRepository>;
   let assetRepositoryMock: jest.Mocked<IAssetRepository>;
-  let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
-  let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
+  let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
   let jobMock: jest.Mocked<IJobRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
   let storageMock: jest.Mocked<IStorageRepository>;
 
 
@@ -158,26 +146,27 @@ describe('AssetService', () => {
       getAssetCountByUserId: jest.fn(),
       getAssetCountByUserId: jest.fn(),
       getArchivedAssetCountByUserId: jest.fn(),
       getArchivedAssetCountByUserId: jest.fn(),
       getExistingAssets: jest.fn(),
       getExistingAssets: jest.fn(),
+      getByOriginalPath: jest.fn(),
     };
     };
 
 
+    cryptoMock = newCryptoRepositoryMock();
+
     downloadServiceMock = {
     downloadServiceMock = {
       downloadArchive: jest.fn(),
       downloadArchive: jest.fn(),
     };
     };
 
 
     accessMock = newAccessRepositoryMock();
     accessMock = newAccessRepositoryMock();
-    sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
-    jobMock = newJobRepositoryMock();
     cryptoMock = newCryptoRepositoryMock();
     cryptoMock = newCryptoRepositoryMock();
+    jobMock = newJobRepositoryMock();
     storageMock = newStorageRepositoryMock();
     storageMock = newStorageRepositoryMock();
 
 
     sut = new AssetService(
     sut = new AssetService(
       accessMock,
       accessMock,
       assetRepositoryMock,
       assetRepositoryMock,
       a,
       a,
+      cryptoMock,
       downloadServiceMock as DownloadService,
       downloadServiceMock as DownloadService,
-      sharedLinkRepositoryMock,
       jobMock,
       jobMock,
-      cryptoMock,
       storageMock,
       storageMock,
     );
     );
 
 
@@ -189,77 +178,6 @@ describe('AssetService', () => {
       .mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
       .mockResolvedValue(assetEntityStub.livePhotoMotionAsset);
   });
   });
 
 
-  describe('createAssetsSharedLink', () => {
-    it('should create an individual share link', async () => {
-      const asset1 = _getAsset_1();
-      const dto: CreateAssetsShareLinkDto = { assetIds: [asset1.id] };
-
-      assetRepositoryMock.getById.mockResolvedValue(asset1);
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
-      sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
-
-      await expect(sut.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
-
-      expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
-      expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.user1.id, asset1.id);
-    });
-  });
-
-  describe('updateAssetsInSharedLink', () => {
-    it('should require a valid shared link', async () => {
-      const asset1 = _getAsset_1();
-
-      const authDto = authStub.adminSharedLink;
-      const dto = { assetIds: [asset1.id] };
-
-      assetRepositoryMock.getById.mockResolvedValue(asset1);
-      sharedLinkRepositoryMock.get.mockResolvedValue(null);
-      accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
-
-      await expect(sut.addAssetsToSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
-
-      expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
-      expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
-      expect(sharedLinkRepositoryMock.update).not.toHaveBeenCalled();
-    });
-
-    it('should add assets to a shared link', async () => {
-      const asset1 = _getAsset_1();
-
-      const authDto = authStub.adminSharedLink;
-      const dto = { assetIds: [asset1.id] };
-
-      assetRepositoryMock.getById.mockResolvedValue(asset1);
-      sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
-      accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
-      sharedLinkRepositoryMock.update.mockResolvedValue(sharedLinkStub.valid);
-
-      await expect(sut.addAssetsToSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
-
-      expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
-      expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
-      expect(sharedLinkRepositoryMock.update).toHaveBeenCalled();
-    });
-
-    it('should remove assets from a shared link', async () => {
-      const asset1 = _getAsset_1();
-
-      const authDto = authStub.adminSharedLink;
-      const dto = { assetIds: [asset1.id] };
-
-      assetRepositoryMock.getById.mockResolvedValue(asset1);
-      sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
-      accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
-      sharedLinkRepositoryMock.update.mockResolvedValue(sharedLinkStub.valid);
-
-      await expect(sut.removeAssetsFromSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
-
-      expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
-      expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
-      expect(sharedLinkRepositoryMock.update).toHaveBeenCalled();
-    });
-  });
-
   describe('uploadFile', () => {
   describe('uploadFile', () => {
     it('should handle a file upload', async () => {
     it('should handle a file upload', async () => {
       const assetEntity = _getAsset_1();
       const assetEntity = _getAsset_1();
@@ -528,6 +446,43 @@ describe('AssetService', () => {
     });
     });
   });
   });
 
 
+  describe('importFile', () => {
+    it('should handle a file import', async () => {
+      assetRepositoryMock.create.mockResolvedValue(assetEntityStub.image);
+      storageMock.checkFileExists.mockResolvedValue(true);
+
+      await expect(
+        sut.importFile(authStub.external1, {
+          ..._getCreateAssetDto(),
+          assetPath: '/data/user1/fake_path/asset_1.jpeg',
+          isReadOnly: true,
+        }),
+      ).resolves.toEqual({ duplicate: false, id: 'asset-id' });
+
+      expect(assetRepositoryMock.create).toHaveBeenCalled();
+    });
+
+    it('should handle a duplicate if originalPath already exists', async () => {
+      const error = new QueryFailedError('', [], '');
+      (error as any).constraint = 'UQ_userid_checksum';
+
+      assetRepositoryMock.create.mockRejectedValue(error);
+      assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetEntityStub.image]);
+      storageMock.checkFileExists.mockResolvedValue(true);
+      cryptoMock.hashFile.mockResolvedValue(Buffer.from('file hash', 'utf8'));
+
+      await expect(
+        sut.importFile(authStub.external1, {
+          ..._getCreateAssetDto(),
+          assetPath: '/data/user1/fake_path/asset_1.jpeg',
+          isReadOnly: true,
+        }),
+      ).resolves.toEqual({ duplicate: true, id: 'asset-id' });
+
+      expect(assetRepositoryMock.create).toHaveBeenCalled();
+    });
+  });
+
   describe('getAssetById', () => {
   describe('getAssetById', () => {
     it('should allow owner access', async () => {
     it('should allow owner access', async () => {
       accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
       accessMock.hasOwnerAssetAccess.mockResolvedValue(true);

+ 92 - 80
server/src/immich/api-v1/asset/asset.service.ts

@@ -1,20 +1,19 @@
 import {
 import {
   AssetResponseDto,
   AssetResponseDto,
+  AuthUserDto,
   getLivePhotoMotionFilename,
   getLivePhotoMotionFilename,
   IAccessRepository,
   IAccessRepository,
   ICryptoRepository,
   ICryptoRepository,
   IJobRepository,
   IJobRepository,
   ImmichReadStream,
   ImmichReadStream,
-  ISharedLinkRepository,
+  isSidecarFileType,
+  isSupportedFileType,
   IStorageRepository,
   IStorageRepository,
   JobName,
   JobName,
   mapAsset,
   mapAsset,
   mapAssetWithoutExif,
   mapAssetWithoutExif,
-  mapSharedLink,
-  SharedLinkCore,
-  SharedLinkResponseDto,
 } from '@app/domain';
 } from '@app/domain';
-import { AssetEntity, AssetType, SharedLinkType } from '@app/infra/entities';
+import { AssetEntity, AssetType } from '@app/infra/entities';
 import {
 import {
   BadRequestException,
   BadRequestException,
   ForbiddenException,
   ForbiddenException,
@@ -26,23 +25,22 @@ import {
   StreamableFile,
   StreamableFile,
 } from '@nestjs/common';
 } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
 import { InjectRepository } from '@nestjs/typeorm';
+import { R_OK, W_OK } from 'constants';
 import { Response as Res } from 'express';
 import { Response as Res } from 'express';
-import { constants, createReadStream, stat } from 'fs';
+import { createReadStream, stat } from 'fs';
 import fs from 'fs/promises';
 import fs from 'fs/promises';
+import mime from 'mime-types';
+import path from 'path';
 import { QueryFailedError, Repository } from 'typeorm';
 import { QueryFailedError, Repository } from 'typeorm';
 import { promisify } from 'util';
 import { promisify } from 'util';
-import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { DownloadService } from '../../modules/download/download.service';
 import { DownloadService } from '../../modules/download/download.service';
-import { AddAssetsDto } from '../album/dto/add-assets.dto';
-import { RemoveAssetsDto } from '../album/dto/remove-assets.dto';
 import { IAssetRepository } from './asset-repository';
 import { IAssetRepository } from './asset-repository';
 import { AssetCore } from './asset.core';
 import { AssetCore } from './asset.core';
 import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
 import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
 import { AssetSearchDto } from './dto/asset-search.dto';
 import { AssetSearchDto } from './dto/asset-search.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
 import { CheckExistingAssetsDto } from './dto/check-existing-assets.dto';
-import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
-import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
+import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { DownloadFilesDto } from './dto/download-files.dto';
 import { DownloadFilesDto } from './dto/download-files.dto';
 import { DownloadDto } from './dto/download-library.dto';
 import { DownloadDto } from './dto/download-library.dto';
@@ -80,22 +78,18 @@ interface ServableFile {
 @Injectable()
 @Injectable()
 export class AssetService {
 export class AssetService {
   readonly logger = new Logger(AssetService.name);
   readonly logger = new Logger(AssetService.name);
-  private shareCore: SharedLinkCore;
   private assetCore: AssetCore;
   private assetCore: AssetCore;
 
 
   constructor(
   constructor(
     @Inject(IAccessRepository) private accessRepository: IAccessRepository,
     @Inject(IAccessRepository) private accessRepository: IAccessRepository,
     @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
     @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
-    @InjectRepository(AssetEntity)
-    private assetRepository: Repository<AssetEntity>,
+    @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
+    @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
     private downloadService: DownloadService,
     private downloadService: DownloadService,
-    @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
-    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
   ) {
   ) {
     this.assetCore = new AssetCore(_assetRepository, jobRepository);
     this.assetCore = new AssetCore(_assetRepository, jobRepository);
-    this.shareCore = new SharedLinkCore(sharedLinkRepository, cryptoRepository);
   }
   }
 
 
   public async uploadFile(
   public async uploadFile(
@@ -120,7 +114,7 @@ export class AssetService {
         livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
         livePhotoAsset = await this.assetCore.create(authUser, livePhotoDto, livePhotoFile);
       }
       }
 
 
-      const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile);
+      const asset = await this.assetCore.create(authUser, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath);
 
 
       return { id: asset.id, duplicate: false };
       return { id: asset.id, duplicate: false };
     } catch (error: any) {
     } catch (error: any) {
@@ -142,6 +136,73 @@ export class AssetService {
     }
     }
   }
   }
 
 
+  public async importFile(authUser: AuthUserDto, dto: ImportAssetDto): Promise<AssetFileUploadResponseDto> {
+    dto = {
+      ...dto,
+      assetPath: path.resolve(dto.assetPath),
+      sidecarPath: dto.sidecarPath ? path.resolve(dto.sidecarPath) : undefined,
+    };
+
+    const assetPathType = mime.lookup(dto.assetPath) as string;
+    if (!isSupportedFileType(assetPathType)) {
+      throw new BadRequestException(`Unsupported file type ${assetPathType}`);
+    }
+
+    if (dto.sidecarPath) {
+      const sidecarType = mime.lookup(dto.sidecarPath) as string;
+      if (!isSidecarFileType(sidecarType)) {
+        throw new BadRequestException(`Unsupported sidecar file type ${assetPathType}`);
+      }
+    }
+
+    for (const filepath of [dto.assetPath, dto.sidecarPath]) {
+      if (!filepath) {
+        continue;
+      }
+
+      const exists = await this.storageRepository.checkFileExists(filepath, R_OK);
+      if (!exists) {
+        throw new BadRequestException('File does not exist');
+      }
+    }
+
+    if (!authUser.externalPath || !dto.assetPath.match(new RegExp(`^${authUser.externalPath}`))) {
+      throw new BadRequestException("File does not exist within user's external path");
+    }
+
+    const assetFile: UploadFile = {
+      checksum: await this.cryptoRepository.hashFile(dto.assetPath),
+      mimeType: assetPathType,
+      originalPath: dto.assetPath,
+      originalName: path.parse(dto.assetPath).name,
+    };
+
+    try {
+      const asset = await this.assetCore.create(authUser, dto, assetFile, undefined, dto.sidecarPath);
+      return { id: asset.id, duplicate: false };
+    } catch (error: QueryFailedError | Error | any) {
+      // handle duplicates with a success response
+      if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_userid_checksum') {
+        const [duplicate] = await this._assetRepository.getAssetsByChecksums(authUser.id, [assetFile.checksum]);
+        return { id: duplicate.id, duplicate: true };
+      }
+
+      if (error instanceof QueryFailedError && (error as any).constraint === 'UQ_4ed4f8052685ff5b1e7ca1058ba') {
+        const duplicate = await this._assetRepository.getByOriginalPath(dto.assetPath);
+        if (duplicate) {
+          if (duplicate.ownerId === authUser.id) {
+            return { id: duplicate.id, duplicate: true };
+          }
+
+          throw new BadRequestException('Path in use by another user');
+        }
+      }
+
+      this.logger.error(`Error importing file ${error}`, error?.stack);
+      throw new BadRequestException(`Error importing file`, `${error}`);
+    }
+  }
+
   public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
   public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
     return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
     return this._assetRepository.getAllByDeviceId(authUser.id, deviceId);
   }
   }
@@ -304,7 +365,7 @@ export class AssetService {
         let videoPath = asset.originalPath;
         let videoPath = asset.originalPath;
         let mimeType = asset.mimeType;
         let mimeType = asset.mimeType;
 
 
-        await fs.access(videoPath, constants.R_OK | constants.W_OK);
+        await fs.access(videoPath, R_OK | W_OK);
 
 
         if (asset.encodedVideoPath) {
         if (asset.encodedVideoPath) {
           videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath);
           videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath);
@@ -386,13 +447,16 @@ export class AssetService {
         await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
         await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: [id] } });
 
 
         result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
         result.push({ id, status: DeleteAssetStatusEnum.SUCCESS });
-        deleteQueue.push(
-          asset.originalPath,
-          asset.webpPath,
-          asset.resizePath,
-          asset.encodedVideoPath,
-          asset.sidecarPath,
-        );
+
+        if (!asset.isReadOnly) {
+          deleteQueue.push(
+            asset.originalPath,
+            asset.webpPath,
+            asset.resizePath,
+            asset.encodedVideoPath,
+            asset.sidecarPath,
+          );
+        }
 
 
         // TODO refactor this to use cascades
         // TODO refactor this to use cascades
         if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
         if (asset.livePhotoVideoId && !ids.includes(asset.livePhotoVideoId)) {
@@ -608,61 +672,9 @@ export class AssetService {
   }
   }
 
 
   private checkDownloadAccess(authUser: AuthUserDto) {
   private checkDownloadAccess(authUser: AuthUserDto) {
-    this.shareCore.checkDownloadAccess(authUser);
-  }
-
-  async createAssetsSharedLink(authUser: AuthUserDto, dto: CreateAssetsShareLinkDto): Promise<SharedLinkResponseDto> {
-    const assets = [];
-
-    await this.checkAssetsAccess(authUser, dto.assetIds);
-    for (const assetId of dto.assetIds) {
-      const asset = await this._assetRepository.getById(assetId);
-      assets.push(asset);
-    }
-
-    const sharedLink = await this.shareCore.create(authUser.id, {
-      type: SharedLinkType.INDIVIDUAL,
-      expiresAt: dto.expiresAt,
-      allowUpload: dto.allowUpload,
-      assets,
-      description: dto.description,
-      allowDownload: dto.allowDownload,
-      showExif: dto.showExif,
-    });
-
-    return mapSharedLink(sharedLink);
-  }
-
-  async addAssetsToSharedLink(authUser: AuthUserDto, dto: AddAssetsDto): Promise<SharedLinkResponseDto> {
-    if (!authUser.sharedLinkId) {
+    if (authUser.isPublicUser && !authUser.isAllowDownload) {
       throw new ForbiddenException();
       throw new ForbiddenException();
     }
     }
-
-    const assets = [];
-
-    for (const assetId of dto.assetIds) {
-      const asset = await this._assetRepository.getById(assetId);
-      assets.push(asset);
-    }
-
-    const updatedLink = await this.shareCore.addAssets(authUser.id, authUser.sharedLinkId, assets);
-    return mapSharedLink(updatedLink);
-  }
-
-  async removeAssetsFromSharedLink(authUser: AuthUserDto, dto: RemoveAssetsDto): Promise<SharedLinkResponseDto> {
-    if (!authUser.sharedLinkId) {
-      throw new ForbiddenException();
-    }
-
-    const assets = [];
-
-    for (const assetId of dto.assetIds) {
-      const asset = await this._assetRepository.getById(assetId);
-      assets.push(asset);
-    }
-
-    const updatedLink = await this.shareCore.removeAssets(authUser.id, authUser.sharedLinkId, assets);
-    return mapSharedLink(updatedLink);
   }
   }
 
 
   getExifPermission(authUser: AuthUserDto) {
   getExifPermission(authUser: AuthUserDto) {
@@ -730,7 +742,7 @@ export class AssetService {
       return;
       return;
     }
     }
 
 
-    await fs.access(filepath, constants.R_OK);
+    await fs.access(filepath, R_OK);
 
 
     return new StreamableFile(createReadStream(filepath));
     return new StreamableFile(createReadStream(filepath));
   }
   }

+ 0 - 41
server/src/immich/api-v1/asset/dto/create-asset-shared-link.dto.ts

@@ -1,41 +0,0 @@
-import { ApiProperty } from '@nestjs/swagger';
-import { Type } from 'class-transformer';
-import { IsArray, IsBoolean, IsDate, IsNotEmpty, IsOptional, IsString } from 'class-validator';
-
-export class CreateAssetsShareLinkDto {
-  @IsArray()
-  @IsString({ each: true })
-  @IsNotEmpty({ each: true })
-  @ApiProperty({
-    isArray: true,
-    type: String,
-    title: 'Array asset IDs to be shared',
-    example: [
-      'bf973405-3f2a-48d2-a687-2ed4167164be',
-      'dd41870b-5d00-46d2-924e-1d8489a0aa0f',
-      'fad77c3f-deef-4e7e-9608-14c1aa4e559a',
-    ],
-  })
-  assetIds!: string[];
-
-  @IsDate()
-  @Type(() => Date)
-  @IsOptional()
-  expiresAt?: Date;
-
-  @IsBoolean()
-  @IsOptional()
-  allowUpload?: boolean;
-
-  @IsBoolean()
-  @IsOptional()
-  allowDownload?: boolean;
-
-  @IsBoolean()
-  @IsOptional()
-  showExif?: boolean;
-
-  @IsString()
-  @IsOptional()
-  description?: string;
-}

+ 31 - 5
server/src/immich/api-v1/asset/dto/create-asset.dto.ts

@@ -1,9 +1,11 @@
 import { AssetType } from '@app/infra/entities';
 import { AssetType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import { ApiProperty } from '@nestjs/swagger';
-import { IsBoolean, IsEnum, IsNotEmpty, IsOptional } from 'class-validator';
+import { Transform } from 'class-transformer';
+import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
 import { ImmichFile } from '../../../config/asset-upload.config';
 import { ImmichFile } from '../../../config/asset-upload.config';
+import { toBoolean, toSanitized } from '../../../utils/transform.util';
 
 
-export class CreateAssetDto {
+export class CreateAssetBase {
   @IsNotEmpty()
   @IsNotEmpty()
   deviceAssetId!: string;
   deviceAssetId!: string;
 
 
@@ -32,11 +34,18 @@ export class CreateAssetDto {
   @IsBoolean()
   @IsBoolean()
   isVisible?: boolean;
   isVisible?: boolean;
 
 
-  @IsNotEmpty()
-  fileExtension!: string;
-
   @IsOptional()
   @IsOptional()
   duration?: string;
   duration?: string;
+}
+
+export class CreateAssetDto extends CreateAssetBase {
+  @IsOptional()
+  @IsBoolean()
+  @Transform(toBoolean)
+  isReadOnly?: boolean = false;
+
+  @IsNotEmpty()
+  fileExtension!: string;
 
 
   // The properties below are added to correctly generate the API docs
   // The properties below are added to correctly generate the API docs
   // and client SDKs. Validation should be handled in the controller.
   // and client SDKs. Validation should be handled in the controller.
@@ -50,6 +59,23 @@ export class CreateAssetDto {
   sidecarData?: any;
   sidecarData?: any;
 }
 }
 
 
+export class ImportAssetDto extends CreateAssetBase {
+  @IsOptional()
+  @Transform(toBoolean)
+  isReadOnly?: boolean = true;
+
+  @IsString()
+  @IsNotEmpty()
+  @Transform(toSanitized)
+  assetPath!: string;
+
+  @IsString()
+  @IsOptional()
+  @IsNotEmpty()
+  @Transform(toSanitized)
+  sidecarPath?: string;
+}
+
 export interface UploadFile {
 export interface UploadFile {
   mimeType: string;
   mimeType: string;
   checksum: Buffer;
   checksum: Buffer;

+ 20 - 18
server/src/immich/config/asset-upload.config.spec.ts

@@ -50,47 +50,49 @@ describe('assetUploadOption', () => {
     });
     });
 
 
     for (const { mimetype, extension } of [
     for (const { mimetype, extension } of [
-      { mimetype: 'image/dng', extension: 'dng' },
+      { mimetype: 'image/avif', extension: 'avif' },
       { mimetype: 'image/gif', extension: 'gif' },
       { mimetype: 'image/gif', extension: 'gif' },
       { mimetype: 'image/heic', extension: 'heic' },
       { mimetype: 'image/heic', extension: 'heic' },
       { mimetype: 'image/heif', extension: 'heif' },
       { mimetype: 'image/heif', extension: 'heif' },
       { mimetype: 'image/jpeg', extension: 'jpeg' },
       { mimetype: 'image/jpeg', extension: 'jpeg' },
       { mimetype: 'image/jpeg', extension: 'jpg' },
       { mimetype: 'image/jpeg', extension: 'jpg' },
+      { mimetype: 'image/jxl', extension: 'jxl' },
       { mimetype: 'image/png', extension: 'png' },
       { mimetype: 'image/png', extension: 'png' },
       { mimetype: 'image/tiff', extension: 'tiff' },
       { mimetype: 'image/tiff', extension: 'tiff' },
       { mimetype: 'image/webp', extension: 'webp' },
       { mimetype: 'image/webp', extension: 'webp' },
-      { mimetype: 'image/avif', extension: 'avif' },
       { mimetype: 'image/x-adobe-dng', extension: 'dng' },
       { mimetype: 'image/x-adobe-dng', extension: 'dng' },
-      { mimetype: 'image/x-fuji-raf', extension: 'raf' },
-      { mimetype: 'image/x-nikon-nef', extension: 'nef' },
-      { mimetype: 'image/x-samsung-srw', extension: 'srw' },
-      { mimetype: 'image/x-sony-arw', extension: 'arw' },
-      { mimetype: 'image/x-canon-crw', extension: 'crw' },
+      { mimetype: 'image/x-arriflex-ari', extension: 'ari' },
       { mimetype: 'image/x-canon-cr2', extension: 'cr2' },
       { mimetype: 'image/x-canon-cr2', extension: 'cr2' },
       { mimetype: 'image/x-canon-cr3', extension: 'cr3' },
       { mimetype: 'image/x-canon-cr3', extension: 'cr3' },
+      { mimetype: 'image/x-canon-crw', extension: 'crw' },
       { mimetype: 'image/x-epson-erf', extension: 'erf' },
       { mimetype: 'image/x-epson-erf', extension: 'erf' },
+      { mimetype: 'image/x-fuji-raf', extension: 'raf' },
+      { mimetype: 'image/x-hasselblad-3fr', extension: '3fr' },
+      { mimetype: 'image/x-hasselblad-fff', extension: 'fff' },
       { mimetype: 'image/x-kodak-dcr', extension: 'dcr' },
       { mimetype: 'image/x-kodak-dcr', extension: 'dcr' },
       { mimetype: 'image/x-kodak-k25', extension: 'k25' },
       { mimetype: 'image/x-kodak-k25', extension: 'k25' },
       { mimetype: 'image/x-kodak-kdc', extension: 'kdc' },
       { mimetype: 'image/x-kodak-kdc', extension: 'kdc' },
+      { mimetype: 'image/x-leica-rwl', extension: 'rwl' },
       { mimetype: 'image/x-minolta-mrw', extension: 'mrw' },
       { mimetype: 'image/x-minolta-mrw', extension: 'mrw' },
+      { mimetype: 'image/x-nikon-nef', extension: 'nef' },
       { mimetype: 'image/x-olympus-orf', extension: 'orf' },
       { mimetype: 'image/x-olympus-orf', extension: 'orf' },
+      { mimetype: 'image/x-olympus-ori', extension: 'ori' },
       { mimetype: 'image/x-panasonic-raw', extension: 'raw' },
       { mimetype: 'image/x-panasonic-raw', extension: 'raw' },
       { mimetype: 'image/x-pentax-pef', extension: 'pef' },
       { mimetype: 'image/x-pentax-pef', extension: 'pef' },
+      { mimetype: 'image/x-phantom-cin', extension: 'cin' },
+      { mimetype: 'image/x-phaseone-cap', extension: 'cap' },
+      { mimetype: 'image/x-phaseone-iiq', extension: 'iiq' },
+      { mimetype: 'image/x-samsung-srw', extension: 'srw' },
       { mimetype: 'image/x-sigma-x3f', extension: 'x3f' },
       { mimetype: 'image/x-sigma-x3f', extension: 'x3f' },
-      { mimetype: 'image/x-sony-srf', extension: 'srf' },
+      { mimetype: 'image/x-sony-arw', extension: 'arw' },
       { mimetype: 'image/x-sony-sr2', extension: 'sr2' },
       { mimetype: 'image/x-sony-sr2', extension: 'sr2' },
-      { mimetype: 'image/x-hasselblad-3fr', extension: '3fr' },
-      { mimetype: 'image/x-hasselblad-fff', extension: 'fff' },
-      { mimetype: 'image/x-leica-rwl', extension: 'rwl' },
-      { mimetype: 'image/x-olympus-ori', extension: 'ori' },
-      { mimetype: 'image/x-phaseone-iiq', extension: 'iiq' },
-      { mimetype: 'image/x-arriflex-ari', extension: 'ari' },
-      { mimetype: 'image/x-phaseone-cap', extension: 'cap' },
-      { mimetype: 'image/x-phantom-cin', extension: 'cin' },
-      { mimetype: 'video/avi', extension: 'avi' },
-      { mimetype: 'video/mov', extension: 'mov' },
+      { mimetype: 'image/x-sony-srf', extension: 'srf' },
+      { mimetype: 'video/3gpp', extension: '3gp' },
+      { mimetype: 'video/mp2t', extension: 'm2ts' },
+      { mimetype: 'video/mp2t', extension: 'mts' },
       { mimetype: 'video/mp4', extension: 'mp4' },
       { mimetype: 'video/mp4', extension: 'mp4' },
       { mimetype: 'video/mpeg', extension: 'mpg' },
       { mimetype: 'video/mpeg', extension: 'mpg' },
+      { mimetype: 'video/quicktime', extension: 'mov' },
       { mimetype: 'video/webm', extension: 'webm' },
       { mimetype: 'video/webm', extension: 'webm' },
       { mimetype: 'video/x-flv', extension: 'flv' },
       { mimetype: 'video/x-flv', extension: 'flv' },
       { mimetype: 'video/x-matroska', extension: 'mkv' },
       { mimetype: 'video/x-matroska', extension: 'mkv' },

+ 12 - 13
server/src/immich/config/asset-upload.config.ts

@@ -1,3 +1,4 @@
+import { isSidecarFileType, isSupportedFileType } from '@app/domain';
 import { StorageCore, StorageFolder } from '@app/domain/storage';
 import { StorageCore, StorageFolder } from '@app/domain/storage';
 import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
 import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common';
 import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
 import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface';
@@ -53,21 +54,19 @@ function fileFilter(req: AuthRequest, file: any, cb: any) {
   if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
   if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
     return cb(new UnauthorizedException());
     return cb(new UnauthorizedException());
   }
   }
-  if (
-    file.mimetype.match(
-      /\/(jpg|jpeg|png|gif|avi|mov|mp4|webm|x-msvideo|quicktime|heic|heif|avif|dng|x-adobe-dng|webp|tiff|3gpp|nef|x-nikon-nef|x-fuji-raf|x-samsung-srw|mpeg|x-flv|x-ms-wmv|x-matroska|x-sony-arw|arw|x-canon-crw|x-canon-cr2|x-canon-cr3|x-epson-erf|x-kodak-dcr|x-kodak-kdc|x-kodak-k25|x-minolta-mrw|x-olympus-orf|x-panasonic-raw|x-pentax-pef|x-sigma-x3f|x-sony-srf|x-sony-sr2|x-hasselblad-3fr|x-hasselblad-fff|x-leica-rwl|x-olympus-ori|x-phaseone-iiq|x-arriflex-ari|x-phaseone-cap|x-phantom-cin)$/,
-    )
-  ) {
+
+  if (isSupportedFileType(file.mimetype)) {
     cb(null, true);
     cb(null, true);
-  } else {
-    // Additionally support XML but only for sidecar files
-    if (file.fieldname == 'sidecarData' && file.mimetype.match(/\/xml$/)) {
-      return cb(null, true);
-    }
-
-    logger.error(`Unsupported file type ${extname(file.originalname)} file MIME type ${file.mimetype}`);
-    cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false);
+    return;
+  }
+
+  // Additionally support XML but only for sidecar files.
+  if (file.fieldname === 'sidecarData' && isSidecarFileType(file.mimetype)) {
+    return cb(null, true);
   }
   }
+
+  logger.error(`Unsupported file type ${extname(file.originalname)} file MIME type ${file.mimetype}`);
+  cb(new BadRequestException(`Unsupported file type ${extname(file.originalname)}`), false);
 }
 }
 
 
 function destination(req: AuthRequest, file: Express.Multer.File, cb: any) {
 function destination(req: AuthRequest, file: Express.Multer.File, cb: any) {

+ 38 - 5
server/src/immich/controllers/shared-link.controller.ts

@@ -1,13 +1,21 @@
-import { AuthUserDto, EditSharedLinkDto, SharedLinkResponseDto, SharedLinkService } from '@app/domain';
-import { Body, Controller, Delete, Get, Param, Patch } from '@nestjs/common';
+import {
+  AssetIdsDto,
+  AssetIdsResponseDto,
+  AuthUserDto,
+  SharedLinkCreateDto,
+  SharedLinkEditDto,
+  SharedLinkResponseDto,
+  SharedLinkService,
+} from '@app/domain';
+import { Body, Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
 import { ApiTags } from '@nestjs/swagger';
 import { AuthUser } from '../decorators/auth-user.decorator';
 import { AuthUser } from '../decorators/auth-user.decorator';
 import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator';
 import { Authenticated, SharedLinkRoute } from '../decorators/authenticated.decorator';
 import { UseValidation } from '../decorators/use-validation.decorator';
 import { UseValidation } from '../decorators/use-validation.decorator';
 import { UUIDParamDto } from './dto/uuid-param.dto';
 import { UUIDParamDto } from './dto/uuid-param.dto';
 
 
-@ApiTags('share')
-@Controller('share')
+@ApiTags('Shared Link')
+@Controller('shared-link')
 @Authenticated()
 @Authenticated()
 @UseValidation()
 @UseValidation()
 export class SharedLinkController {
 export class SharedLinkController {
@@ -29,11 +37,16 @@ export class SharedLinkController {
     return this.service.get(authUser, id);
     return this.service.get(authUser, id);
   }
   }
 
 
+  @Post()
+  createSharedLink(@AuthUser() authUser: AuthUserDto, @Body() dto: SharedLinkCreateDto) {
+    return this.service.create(authUser, dto);
+  }
+
   @Patch(':id')
   @Patch(':id')
   updateSharedLink(
   updateSharedLink(
     @AuthUser() authUser: AuthUserDto,
     @AuthUser() authUser: AuthUserDto,
     @Param() { id }: UUIDParamDto,
     @Param() { id }: UUIDParamDto,
-    @Body() dto: EditSharedLinkDto,
+    @Body() dto: SharedLinkEditDto,
   ): Promise<SharedLinkResponseDto> {
   ): Promise<SharedLinkResponseDto> {
     return this.service.update(authUser, id, dto);
     return this.service.update(authUser, id, dto);
   }
   }
@@ -42,4 +55,24 @@ export class SharedLinkController {
   removeSharedLink(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
   removeSharedLink(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<void> {
     return this.service.remove(authUser, id);
     return this.service.remove(authUser, id);
   }
   }
+
+  @SharedLinkRoute()
+  @Put(':id/assets')
+  addSharedLinkAssets(
+    @AuthUser() authUser: AuthUserDto,
+    @Param() { id }: UUIDParamDto,
+    @Body() dto: AssetIdsDto,
+  ): Promise<AssetIdsResponseDto[]> {
+    return this.service.addAssets(authUser, id, dto);
+  }
+
+  @SharedLinkRoute()
+  @Delete(':id/assets')
+  removeSharedLinkAssets(
+    @AuthUser() authUser: AuthUserDto,
+    @Param() { id }: UUIDParamDto,
+    @Body() dto: AssetIdsDto,
+  ): Promise<AssetIdsResponseDto[]> {
+    return this.service.removeAssets(authUser, id, dto);
+  }
 }
 }

+ 4 - 1
server/src/infra/entities/asset.entity.ts

@@ -42,7 +42,7 @@ export class AssetEntity {
   @Column()
   @Column()
   type!: AssetType;
   type!: AssetType;
 
 
-  @Column()
+  @Column({ unique: true })
   originalPath!: string;
   originalPath!: string;
 
 
   @Column({ type: 'varchar', nullable: true })
   @Column({ type: 'varchar', nullable: true })
@@ -75,6 +75,9 @@ export class AssetEntity {
   @Column({ type: 'boolean', default: false })
   @Column({ type: 'boolean', default: false })
   isArchived!: boolean;
   isArchived!: boolean;
 
 
+  @Column({ type: 'boolean', default: false })
+  isReadOnly!: boolean;
+
   @Column({ type: 'varchar', nullable: true })
   @Column({ type: 'varchar', nullable: true })
   mimeType!: string | null;
   mimeType!: string | null;
 
 

+ 5 - 2
server/src/infra/entities/shared-link.entity.ts

@@ -18,8 +18,8 @@ export class SharedLinkEntity {
   @PrimaryGeneratedColumn('uuid')
   @PrimaryGeneratedColumn('uuid')
   id!: string;
   id!: string;
 
 
-  @Column({ nullable: true })
-  description?: string;
+  @Column({ type: 'varchar', nullable: true })
+  description!: string | null;
 
 
   @Column()
   @Column()
   userId!: string;
   userId!: string;
@@ -55,6 +55,9 @@ export class SharedLinkEntity {
   @Index('IDX_sharedlink_albumId')
   @Index('IDX_sharedlink_albumId')
   @ManyToOne(() => AlbumEntity, (album) => album.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
   @ManyToOne(() => AlbumEntity, (album) => album.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
   album?: AlbumEntity;
   album?: AlbumEntity;
+
+  @Column({ type: 'varchar', nullable: true })
+  albumId!: string | null;
 }
 }
 
 
 export enum SharedLinkType {
 export enum SharedLinkType {

+ 3 - 0
server/src/infra/entities/user.entity.ts

@@ -30,6 +30,9 @@ export class UserEntity {
   @Column({ type: 'varchar', unique: true, default: null })
   @Column({ type: 'varchar', unique: true, default: null })
   storageLabel!: string | null;
   storageLabel!: string | null;
 
 
+  @Column({ type: 'varchar', default: null })
+  externalPath!: string | null;
+
   @Column({ default: '', select: false })
   @Column({ default: '', select: false })
   password?: string;
   password?: string;
 
 

+ 18 - 0
server/src/infra/migrations/1686584273471-ImportAsset.ts

@@ -0,0 +1,18 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class ImportAsset1686584273471 implements MigrationInterface {
+    name = 'ImportAsset1686584273471'
+
+    public async up(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" ADD "isReadOnly" boolean NOT NULL DEFAULT false`);
+        await queryRunner.query(`ALTER TABLE "assets" ADD CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba" UNIQUE ("originalPath")`);
+        await queryRunner.query(`ALTER TABLE "users" ADD "externalPath" character varying`);
+    }
+
+    public async down(queryRunner: QueryRunner): Promise<void> {
+        await queryRunner.query(`ALTER TABLE "assets" DROP CONSTRAINT "UQ_4ed4f8052685ff5b1e7ca1058ba"`);
+        await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "isReadOnly"`);
+        await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "externalPath"`);
+    }
+
+}

+ 9 - 0
server/src/infra/repositories/access.repository.ts

@@ -95,4 +95,13 @@ export class AccessRepository implements IAccessRepository {
       }))
       }))
     );
     );
   }
   }
+
+  hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean> {
+    return this.albumRepository.exist({
+      where: {
+        id: albumId,
+        ownerId: userId,
+      },
+    });
+  }
 }
 }

+ 7 - 0
server/src/infra/repositories/asset.repository.ts

@@ -248,6 +248,13 @@ export class AssetRepository implements IAssetRepository {
     });
     });
   }
   }
 
 
+  getLastUpdatedAssetForAlbumId(albumId: string): Promise<AssetEntity | null> {
+    return this.repository.findOne({
+      where: { albums: { id: albumId } },
+      order: { updatedAt: 'DESC' },
+    });
+  }
+
   async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
   async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
     const { isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
     const { isFavorite, fileCreatedAfter, fileCreatedBefore } = options;
 
 

+ 11 - 0
server/src/infra/repositories/crypto.repository.ts

@@ -2,6 +2,7 @@ import { ICryptoRepository } from '@app/domain';
 import { Injectable } from '@nestjs/common';
 import { Injectable } from '@nestjs/common';
 import { compareSync, hash } from 'bcrypt';
 import { compareSync, hash } from 'bcrypt';
 import { createHash, randomBytes } from 'crypto';
 import { createHash, randomBytes } from 'crypto';
+import { createReadStream } from 'fs';
 
 
 @Injectable()
 @Injectable()
 export class CryptoRepository implements ICryptoRepository {
 export class CryptoRepository implements ICryptoRepository {
@@ -13,4 +14,14 @@ export class CryptoRepository implements ICryptoRepository {
   hashSha256(value: string) {
   hashSha256(value: string) {
     return createHash('sha256').update(value).digest('base64');
     return createHash('sha256').update(value).digest('base64');
   }
   }
+
+  hashFile(filepath: string): Promise<Buffer> {
+    return new Promise<Buffer>((resolve, reject) => {
+      const hash = createHash('sha1');
+      const stream = createReadStream(filepath);
+      stream.on('error', (err) => reject(err));
+      stream.on('data', (chunk) => hash.update(chunk));
+      stream.on('end', () => resolve(hash.digest()));
+    });
+  }
 }
 }

Some files were not shown because too many files changed in this diff