Explorar el Código

Merge branch 'main' of github.com:immich-app/immich into pr/lukehmcc/2913

Alex Tran hace 2 años
padre
commit
2308a7fe04
Se han modificado 38 ficheros con 651 adiciones y 480 borrados
  1. 10 0
      mobile/ios/Podfile.lock
  2. 2 6
      mobile/lib/modules/album/views/sharing_page.dart
  3. 3 1
      mobile/lib/modules/home/ui/asset_grid/group_divider_title.dart
  4. 40 45
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart
  5. 2 2
      mobile/lib/modules/home/ui/home_page_app_bar.dart
  6. 5 1
      mobile/lib/modules/memories/ui/memory_lane.dart
  7. 8 1
      mobile/lib/modules/partner/ui/partner_list.dart
  8. 16 17
      mobile/lib/modules/settings/ui/asset_list_settings/asset_list_layout_settings.dart
  9. 5 4
      mobile/lib/modules/settings/ui/asset_list_settings/asset_list_storage_indicator.dart
  10. 1 1
      mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart
  11. 14 8
      mobile/lib/modules/settings/ui/settings_switch_list_tile.dart
  12. 10 10
      mobile/lib/modules/settings/ui/theme_setting/theme_setting.dart
  13. 1 1
      mobile/lib/shared/ui/immich_image.dart
  14. 134 0
      server/src/domain/access/access.core.ts
  15. 14 6
      server/src/domain/access/access.repository.ts
  16. 1 0
      server/src/domain/access/index.ts
  17. 28 18
      server/src/domain/album/album.service.spec.ts
  18. 14 15
      server/src/domain/album/album.service.ts
  19. 21 0
      server/src/domain/domain.constant.spec.ts
  20. 29 28
      server/src/domain/domain.constant.ts
  21. 12 11
      server/src/domain/shared-link/shared-link.service.spec.ts
  22. 10 16
      server/src/domain/shared-link/shared-link.service.ts
  23. 2 2
      server/src/domain/smart-info/smart-info.service.spec.ts
  24. 0 4
      server/src/domain/smart-info/smart-info.service.ts
  25. 6 2
      server/src/immich/api-v1/asset/asset-repository.ts
  26. 2 2
      server/src/immich/api-v1/asset/asset.controller.ts
  27. 28 27
      server/src/immich/api-v1/asset/asset.service.spec.ts
  28. 34 95
      server/src/immich/api-v1/asset/asset.service.ts
  29. 30 27
      server/src/immich/config/asset-upload.config.spec.ts
  30. 1 1
      server/src/immich/controllers/user.controller.ts
  31. 125 75
      server/src/infra/repositories/access.repository.ts
  32. 21 7
      server/test/repositories/access.repository.mock.ts
  33. 6 6
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  34. 1 7
      web/src/lib/components/assets/thumbnail/thumbnail.svelte
  35. 2 0
      web/src/lib/stores/preferences.store.ts
  36. 2 31
      web/src/lib/utils/asset-utils.spec.ts
  37. 3 2
      web/src/lib/utils/asset-utils.ts
  38. 8 1
      web/src/routes/(user)/search/+page.svelte

+ 10 - 0
mobile/ios/Podfile.lock

@@ -1,4 +1,7 @@
 PODS:
+  - connectivity_plus (0.0.1):
+    - Flutter
+    - ReachabilitySwift
   - device_info_plus (0.0.1):
     - Flutter
   - Flutter (1.0.0)
@@ -33,6 +36,7 @@ PODS:
   - photo_manager (2.0.0):
     - Flutter
     - FlutterMacOS
+  - ReachabilitySwift (5.0.0)
   - SAMKeychain (1.5.3)
   - share_plus (0.0.1):
     - Flutter
@@ -51,6 +55,7 @@ PODS:
     - Flutter
 
 DEPENDENCIES:
+  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
   - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
   - Flutter (from `Flutter`)
   - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
@@ -75,10 +80,13 @@ DEPENDENCIES:
 SPEC REPOS:
   trunk:
     - FMDB
+    - ReachabilitySwift
     - SAMKeychain
     - Toast
 
 EXTERNAL SOURCES:
+  connectivity_plus:
+    :path: ".symlinks/plugins/connectivity_plus/ios"
   device_info_plus:
     :path: ".symlinks/plugins/device_info_plus/ios"
   Flutter:
@@ -121,6 +129,7 @@ EXTERNAL SOURCES:
     :path: ".symlinks/plugins/wakelock/ios"
 
 SPEC CHECKSUMS:
+  connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
   device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
   Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
   flutter_native_splash: 52501b97d1c0a5f898d687f1646226c1f93c56ef
@@ -136,6 +145,7 @@ SPEC CHECKSUMS:
   path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
   permission_handler_apple: 44366e37eaf29454a1e7b1b7d736c2cceaeb17ce
   photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
+  ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
   share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
   shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c

+ 2 - 6
mobile/lib/modules/album/views/sharing_page.dart

@@ -236,7 +236,7 @@ class SharingPage extends HookConsumerWidget {
           SliverToBoxAdapter(child: buildTopBottons()),
           if (partner.isNotEmpty)
             SliverPadding(
-              padding: const EdgeInsets.only(left: 12, right: 12, bottom: 4),
+              padding: const EdgeInsets.all(12),
               sliver: SliverToBoxAdapter(
                 child: const Text(
                   "partner_page_title",
@@ -246,11 +246,7 @@ class SharingPage extends HookConsumerWidget {
             ),
           if (partner.isNotEmpty) PartnerList(partner: partner),
           SliverPadding(
-            padding: EdgeInsets.only(
-              left: 12,
-              right: 12,
-              top: partner.isEmpty ? 0 : 16,
-            ),
+            padding: const EdgeInsets.all(12),
             sliver: SliverToBoxAdapter(
               child: const Text(
                 "sharing_page_album",

+ 3 - 1
mobile/lib/modules/home/ui/asset_grid/group_divider_title.dart

@@ -1,4 +1,5 @@
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 
 class GroupDividerTitle extends ConsumerWidget {
@@ -20,6 +21,7 @@ class GroupDividerTitle extends ConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     void handleTitleIconClick() {
+      HapticFeedback.heavyImpact();
       if (selected) {
         onDeselect();
       } else {
@@ -30,7 +32,7 @@ class GroupDividerTitle extends ConsumerWidget {
     return Padding(
       padding: const EdgeInsets.only(
         top: 12.0,
-        bottom: 4.0,
+        bottom: 16.0,
         left: 12.0,
         right: 12.0,
       ),

+ 40 - 45
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid_view.dart

@@ -19,6 +19,45 @@ typedef ImmichAssetGridSelectionListener = void Function(
   Set<Asset>,
 );
 
+class ImmichAssetGridView extends StatefulWidget {
+  final RenderList renderList;
+  final int assetsPerRow;
+  final double margin;
+  final bool showStorageIndicator;
+  final ImmichAssetGridSelectionListener? listener;
+  final bool selectionActive;
+  final Future<void> Function()? onRefresh;
+  final Set<Asset>? preselectedAssets;
+  final bool canDeselect;
+  final bool dynamicLayout;
+  final bool showMultiSelectIndicator;
+  final void Function(ItemPosition start, ItemPosition end)?
+      visibleItemsListener;
+  final Widget? topWidget;
+
+  const ImmichAssetGridView({
+    super.key,
+    required this.renderList,
+    required this.assetsPerRow,
+    required this.showStorageIndicator,
+    this.listener,
+    this.margin = 5.0,
+    this.selectionActive = false,
+    this.onRefresh,
+    this.preselectedAssets,
+    this.canDeselect = true,
+    this.dynamicLayout = true,
+    this.showMultiSelectIndicator = true,
+    this.visibleItemsListener,
+    this.topWidget,
+  });
+
+  @override
+  State<StatefulWidget> createState() {
+    return ImmichAssetGridViewState();
+  }
+}
+
 class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
   final ItemScrollController _itemScrollController = ItemScrollController();
   final ItemPositionsListener _itemPositionsListener =
@@ -272,11 +311,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
   Widget _buildAssetGrid() {
     final useDragScrolling = widget.renderList.totalAssets >= 20;
 
-    void dragScrolling(bool active) {
-      setState(() {
-        _scrolling = active;
-      });
-    }
+    void dragScrolling(bool active) => _scrolling = active;
 
     final listWidget = ScrollablePositionedList.builder(
       padding: const EdgeInsets.only(
@@ -302,7 +337,6 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
             child: listWidget,
           )
         : listWidget;
-
     return widget.onRefresh == null
         ? child
         : RefreshIndicator(onRefresh: widget.onRefresh!, child: child);
@@ -388,42 +422,3 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
     );
   }
 }
-
-class ImmichAssetGridView extends StatefulWidget {
-  final RenderList renderList;
-  final int assetsPerRow;
-  final double margin;
-  final bool showStorageIndicator;
-  final ImmichAssetGridSelectionListener? listener;
-  final bool selectionActive;
-  final Future<void> Function()? onRefresh;
-  final Set<Asset>? preselectedAssets;
-  final bool canDeselect;
-  final bool dynamicLayout;
-  final bool showMultiSelectIndicator;
-  final void Function(ItemPosition start, ItemPosition end)?
-      visibleItemsListener;
-  final Widget? topWidget;
-
-  const ImmichAssetGridView({
-    super.key,
-    required this.renderList,
-    required this.assetsPerRow,
-    required this.showStorageIndicator,
-    this.listener,
-    this.margin = 5.0,
-    this.selectionActive = false,
-    this.onRefresh,
-    this.preselectedAssets,
-    this.canDeselect = true,
-    this.dynamicLayout = true,
-    this.showMultiSelectIndicator = true,
-    this.visibleItemsListener,
-    this.topWidget,
-  });
-
-  @override
-  State<StatefulWidget> createState() {
-    return ImmichAssetGridViewState();
-  }
-}

+ 2 - 2
mobile/lib/modules/home/ui/home_page_app_bar.dart

@@ -71,8 +71,8 @@ class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
               ),
               if (serverInfoState.isVersionMismatch)
                 Positioned(
-                  bottom: 12,
-                  right: 12,
+                  bottom: 4,
+                  right: 6,
                   child: GestureDetector(
                     onTap: () => Scaffold.of(context).openDrawer(),
                     child: Material(

+ 5 - 1
mobile/lib/modules/memories/ui/memory_lane.dart

@@ -1,9 +1,11 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/memories/providers/memory.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/shared/ui/immich_image.dart';
+import 'package:openapi/api.dart';
 
 class MemoryLane extends HookConsumerWidget {
   const MemoryLane({super.key});
@@ -27,6 +29,7 @@ class MemoryLane extends HookConsumerWidget {
                         padding: const EdgeInsets.only(right: 8.0, bottom: 8),
                         child: GestureDetector(
                           onTap: () {
+                            HapticFeedback.heavyImpact();
                             AutoRouter.of(context).push(
                               VerticalRouteView(
                                 memories: memories,
@@ -53,6 +56,7 @@ class MemoryLane extends HookConsumerWidget {
                                     width: 130,
                                     height: 200,
                                     useGrayBoxPlaceholder: true,
+                                    type: ThumbnailFormat.JPEG,
                                   ),
                                 ),
                               ),
@@ -66,7 +70,7 @@ class MemoryLane extends HookConsumerWidget {
                                   child: Text(
                                     memory.title,
                                     style: const TextStyle(
-                                      fontWeight: FontWeight.w500,
+                                      fontWeight: FontWeight.bold,
                                       color: Colors.white,
                                       fontSize: 14,
                                     ),

+ 8 - 1
mobile/lib/modules/partner/ui/partner_list.dart

@@ -23,7 +23,14 @@ class PartnerList extends HookConsumerWidget {
     return ListTile(
       contentPadding: const EdgeInsets.symmetric(horizontal: 12.0),
       leading: userAvatar(context, p, radius: 30),
-      title: Text("${p.firstName} ${p.lastName}"),
+      title: Text(
+        "${p.firstName} ${p.lastName}'s photos",
+        style: TextStyle(
+          fontWeight: FontWeight.bold,
+          fontSize: 14,
+          color: Theme.of(context).primaryColor,
+        ),
+      ),
       onTap: () => AutoRouter.of(context).push(PartnerDetailRoute(partner: p)),
     );
   }

+ 16 - 17
mobile/lib/modules/settings/ui/asset_list_settings/asset_list_layout_settings.dart

@@ -51,31 +51,34 @@ class LayoutSettings extends HookConsumerWidget {
       children: [
         SwitchListTile.adaptive(
           activeColor: Theme.of(context).primaryColor,
-          title: const Text(
+          title: Text(
             "asset_list_layout_settings_dynamic_layout_title",
-            style: TextStyle(
-              fontSize: 12,
-            ),
+            style: Theme.of(context)
+                .textTheme
+                .labelLarge
+                ?.copyWith(fontWeight: FontWeight.bold),
           ).tr(),
           onChanged: switchChanged,
           value: useDynamicLayout.value,
         ),
+        const Divider(
+          indent: 18,
+          endIndent: 18,
+        ),
         ListTile(
           title: const Text(
             "asset_list_layout_settings_group_by",
             style: TextStyle(
-              fontSize: 12,
+              fontSize: 16,
               fontWeight: FontWeight.bold,
             ),
           ).tr(),
         ),
         RadioListTile(
           activeColor: Theme.of(context).primaryColor,
-          title: const Text(
+          title: Text(
             "asset_list_layout_settings_group_by_month_day",
-            style: TextStyle(
-              fontSize: 12,
-            ),
+            style: Theme.of(context).textTheme.labelLarge,
           ).tr(),
           value: GroupAssetsBy.day,
           groupValue: groupBy.value,
@@ -84,11 +87,9 @@ class LayoutSettings extends HookConsumerWidget {
         ),
         RadioListTile(
           activeColor: Theme.of(context).primaryColor,
-          title: const Text(
+          title: Text(
             "asset_list_layout_settings_group_by_month",
-            style: TextStyle(
-              fontSize: 12,
-            ),
+            style: Theme.of(context).textTheme.labelLarge,
           ).tr(),
           value: GroupAssetsBy.month,
           groupValue: groupBy.value,
@@ -97,11 +98,9 @@ class LayoutSettings extends HookConsumerWidget {
         ),
         RadioListTile(
           activeColor: Theme.of(context).primaryColor,
-          title: const Text(
+          title: Text(
             "asset_list_layout_settings_group_automatically",
-            style: TextStyle(
-              fontSize: 12,
-            ),
+            style: Theme.of(context).textTheme.labelLarge,
           ).tr(),
           value: GroupAssetsBy.auto,
           groupValue: groupBy.value,

+ 5 - 4
mobile/lib/modules/settings/ui/asset_list_settings/asset_list_storage_indicator.dart

@@ -34,11 +34,12 @@ class StorageIndicator extends HookConsumerWidget {
 
     return SwitchListTile.adaptive(
       activeColor: Theme.of(context).primaryColor,
-      title: const Text(
+      title: Text(
         "theme_setting_asset_list_storage_indicator_title",
-        style: TextStyle(
-          fontSize: 12,
-        ),
+        style: Theme.of(context)
+            .textTheme
+            .labelLarge
+            ?.copyWith(fontWeight: FontWeight.bold),
       ).tr(),
       onChanged: switchChanged,
       value: showStorageIndicator.value,

+ 1 - 1
mobile/lib/modules/settings/ui/asset_list_settings/asset_list_tiles_per_row.dart

@@ -39,7 +39,7 @@ class TilesPerRow extends HookConsumerWidget {
           title: const Text(
             "theme_setting_asset_list_tiles_per_row_title",
             style: TextStyle(
-              fontSize: 12,
+              fontSize: 14,
               fontWeight: FontWeight.bold,
             ),
           ).tr(args: ["${itemsValue.value.toInt()}"]),

+ 14 - 8
mobile/lib/modules/settings/ui/settings_switch_list_tile.dart

@@ -22,15 +22,21 @@ class SettingsSwitchListTile extends StatelessWidget {
   Widget build(BuildContext context) {
     return SwitchListTile.adaptive(
       value: valueNotifier.value,
-      onChanged: !enabled ? null : (value) {
-        valueNotifier.value = value;
-        appSettingService.setSetting(settingsEnum, value);
-      },
-      activeColor: Theme
-        .of(context)
-        .primaryColor,
+      onChanged: !enabled
+          ? null
+          : (value) {
+              valueNotifier.value = value;
+              appSettingService.setSetting(settingsEnum, value);
+            },
+      activeColor: Theme.of(context).primaryColor,
       dense: true,
-      title: Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
+      title: Text(
+        title,
+        style: Theme.of(context)
+            .textTheme
+            .labelLarge
+            ?.copyWith(fontWeight: FontWeight.bold),
+      ),
       subtitle: subtitle != null ? Text(subtitle!) : null,
     );
   }

+ 10 - 10
mobile/lib/modules/settings/ui/theme_setting/theme_setting.dart

@@ -40,12 +40,12 @@ class ThemeSetting extends HookConsumerWidget {
       children: [
         SwitchListTile.adaptive(
           activeColor: Theme.of(context).primaryColor,
-          title: const Text(
+          title: Text(
             'theme_setting_system_theme_switch',
-            style: TextStyle(
-              fontSize: 12.0,
-              fontWeight: FontWeight.bold,
-            ),
+            style: Theme.of(context)
+                .textTheme
+                .labelLarge
+                ?.copyWith(fontWeight: FontWeight.bold),
           ).tr(),
           value: currentTheme.value == ThemeMode.system,
           onChanged: (bool isSystem) {
@@ -78,12 +78,12 @@ class ThemeSetting extends HookConsumerWidget {
         if (currentTheme.value != ThemeMode.system)
           SwitchListTile.adaptive(
             activeColor: Theme.of(context).primaryColor,
-            title: const Text(
+            title: Text(
               'theme_setting_dark_mode_switch',
-              style: TextStyle(
-                fontSize: 12.0,
-                fontWeight: FontWeight.bold,
-              ),
+              style: Theme.of(context)
+                  .textTheme
+                  .labelLarge
+                  ?.copyWith(fontWeight: FontWeight.bold),
             ).tr(),
             value: ref.watch(immichThemeProvider) == ThemeMode.dark,
             onChanged: (bool isDark) {

+ 1 - 1
mobile/lib/shared/ui/immich_image.dart

@@ -93,7 +93,7 @@ class ImmichImage extends StatelessWidget {
     return CachedNetworkImage(
       imageUrl: thumbnailRequestUrl,
       httpHeaders: {"Authorization": "Bearer $token"},
-      cacheKey: getThumbnailCacheKey(asset),
+      cacheKey: getThumbnailCacheKey(asset, type: type),
       width: width,
       height: height,
       // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and

+ 134 - 0
server/src/domain/access/access.core.ts

@@ -0,0 +1,134 @@
+import { BadRequestException } from '@nestjs/common';
+import { AuthUserDto } from '../auth';
+import { IAccessRepository } from './access.repository';
+
+export enum Permission {
+  // ASSET_CREATE = 'asset.create',
+  ASSET_READ = 'asset.read',
+  ASSET_UPDATE = 'asset.update',
+  ASSET_DELETE = 'asset.delete',
+  ASSET_SHARE = 'asset.share',
+  ASSET_VIEW = 'asset.view',
+  ASSET_DOWNLOAD = 'asset.download',
+
+  // ALBUM_CREATE = 'album.create',
+  // ALBUM_READ = 'album.read',
+  ALBUM_UPDATE = 'album.update',
+  ALBUM_DELETE = 'album.delete',
+  ALBUM_SHARE = 'album.share',
+
+  LIBRARY_READ = 'library.read',
+  LIBRARY_DOWNLOAD = 'library.download',
+}
+
+export class AccessCore {
+  constructor(private repository: IAccessRepository) {}
+
+  async requirePermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
+    const hasAccess = await this.hasPermission(authUser, permission, ids);
+    if (!hasAccess) {
+      throw new BadRequestException(`Not found or no ${permission} access`);
+    }
+  }
+
+  async hasPermission(authUser: AuthUserDto, permission: Permission, ids: string[] | string) {
+    ids = Array.isArray(ids) ? ids : [ids];
+
+    const isSharedLink = authUser.isPublicUser ?? false;
+
+    for (const id of ids) {
+      const hasAccess = isSharedLink
+        ? await this.hasSharedLinkAccess(authUser, permission, id)
+        : await this.hasOtherAccess(authUser, permission, id);
+      if (!hasAccess) {
+        return false;
+      }
+    }
+
+    return true;
+  }
+
+  private async hasSharedLinkAccess(authUser: AuthUserDto, permission: Permission, id: string) {
+    const sharedLinkId = authUser.sharedLinkId;
+    if (!sharedLinkId) {
+      return false;
+    }
+
+    switch (permission) {
+      case Permission.ASSET_READ:
+        return this.repository.asset.hasSharedLinkAccess(sharedLinkId, id);
+
+      case Permission.ASSET_VIEW:
+        return await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id);
+
+      case Permission.ASSET_DOWNLOAD:
+        return !!authUser.isAllowDownload && (await this.repository.asset.hasSharedLinkAccess(sharedLinkId, id));
+
+      case Permission.ASSET_SHARE:
+        // TODO: fix this to not use authUser.id for shared link access control
+        return this.repository.asset.hasOwnerAccess(authUser.id, id);
+
+      // case Permission.ALBUM_READ:
+      //   return this.repository.album.hasSharedLinkAccess(sharedLinkId, id);
+
+      default:
+        return false;
+    }
+  }
+
+  private async hasOtherAccess(authUser: AuthUserDto, permission: Permission, id: string) {
+    switch (permission) {
+      case Permission.ASSET_READ:
+        return (
+          (await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
+          (await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
+          (await this.repository.asset.hasPartnerAccess(authUser.id, id))
+        );
+      case Permission.ASSET_UPDATE:
+        return this.repository.asset.hasOwnerAccess(authUser.id, id);
+
+      case Permission.ASSET_DELETE:
+        return this.repository.asset.hasOwnerAccess(authUser.id, id);
+
+      case Permission.ASSET_SHARE:
+        return (
+          (await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
+          (await this.repository.asset.hasPartnerAccess(authUser.id, id))
+        );
+
+      case Permission.ASSET_VIEW:
+        return (
+          (await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
+          (await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
+          (await this.repository.asset.hasPartnerAccess(authUser.id, id))
+        );
+
+      case Permission.ASSET_DOWNLOAD:
+        return (
+          (await this.repository.asset.hasOwnerAccess(authUser.id, id)) ||
+          (await this.repository.asset.hasAlbumAccess(authUser.id, id)) ||
+          (await this.repository.asset.hasPartnerAccess(authUser.id, id))
+        );
+
+      // case Permission.ALBUM_READ:
+      //   return this.repository.album.hasOwnerAccess(authUser.id, id);
+
+      case Permission.ALBUM_UPDATE:
+        return this.repository.album.hasOwnerAccess(authUser.id, id);
+
+      case Permission.ALBUM_DELETE:
+        return this.repository.album.hasOwnerAccess(authUser.id, id);
+
+      case Permission.ALBUM_SHARE:
+        return this.repository.album.hasOwnerAccess(authUser.id, id);
+
+      case Permission.LIBRARY_READ:
+        return authUser.id === id || (await this.repository.library.hasPartnerAccess(authUser.id, id));
+
+      case Permission.LIBRARY_DOWNLOAD:
+        return authUser.id === id;
+    }
+
+    return false;
+  }
+}

+ 14 - 6
server/src/domain/access/access.repository.ts

@@ -1,12 +1,20 @@
 export const IAccessRepository = 'IAccessRepository';
 
 export interface IAccessRepository {
-  hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
+  asset: {
+    hasOwnerAccess(userId: string, assetId: string): Promise<boolean>;
+    hasAlbumAccess(userId: string, assetId: string): Promise<boolean>;
+    hasPartnerAccess(userId: string, assetId: string): Promise<boolean>;
+    hasSharedLinkAccess(sharedLinkId: string, assetId: string): Promise<boolean>;
+  };
 
-  hasAlbumAssetAccess(userId: string, assetId: string): Promise<boolean>;
-  hasOwnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
-  hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean>;
-  hasSharedLinkAssetAccess(userId: string, assetId: string): Promise<boolean>;
+  album: {
+    hasOwnerAccess(userId: string, albumId: string): Promise<boolean>;
+    hasSharedAlbumAccess(userId: string, albumId: string): Promise<boolean>;
+    hasSharedLinkAccess(sharedLinkId: string, albumId: string): Promise<boolean>;
+  };
 
-  hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean>;
+  library: {
+    hasPartnerAccess(userId: string, partnerId: string): Promise<boolean>;
+  };
 }

+ 1 - 0
server/src/domain/access/index.ts

@@ -1 +1,2 @@
+export * from './access.core';
 export * from './access.repository';

+ 28 - 18
server/src/domain/album/album.service.spec.ts

@@ -1,7 +1,9 @@
-import { BadRequestException, ForbiddenException } from '@nestjs/common';
+import { BadRequestException } from '@nestjs/common';
 import {
   albumStub,
   authStub,
+  IAccessRepositoryMock,
+  newAccessRepositoryMock,
   newAlbumRepositoryMock,
   newAssetRepositoryMock,
   newJobRepositoryMock,
@@ -17,18 +19,20 @@ import { AlbumService } from './album.service';
 
 describe(AlbumService.name, () => {
   let sut: AlbumService;
+  let accessMock: IAccessRepositoryMock;
   let albumMock: jest.Mocked<IAlbumRepository>;
   let assetMock: jest.Mocked<IAssetRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   let userMock: jest.Mocked<IUserRepository>;
 
   beforeEach(async () => {
+    accessMock = newAccessRepositoryMock();
     albumMock = newAlbumRepositoryMock();
     assetMock = newAssetRepositoryMock();
     jobMock = newJobRepositoryMock();
     userMock = newUserRepositoryMock();
 
-    sut = new AlbumService(albumMock, assetMock, jobMock, userMock);
+    sut = new AlbumService(accessMock, albumMock, assetMock, jobMock, userMock);
   });
 
   it('should work', () => {
@@ -210,16 +214,16 @@ describe(AlbumService.name, () => {
     });
 
     it('should prevent updating a not owned album (shared with auth user)', async () => {
-      albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
-
+      accessMock.album.hasOwnerAccess.mockResolvedValue(false);
       await expect(
         sut.update(authStub.admin, albumStub.sharedWithAdmin.id, {
           albumName: 'new album name',
         }),
-      ).rejects.toBeInstanceOf(ForbiddenException);
+      ).rejects.toBeInstanceOf(BadRequestException);
     });
 
     it('should require a valid thumbnail asset id', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
       albumMock.update.mockResolvedValue(albumStub.oneAsset);
       albumMock.hasAsset.mockResolvedValue(false);
@@ -235,6 +239,8 @@ describe(AlbumService.name, () => {
     });
 
     it('should allow the owner to update the album', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
+
       albumMock.getByIds.mockResolvedValue([albumStub.oneAsset]);
       albumMock.update.mockResolvedValue(albumStub.oneAsset);
 
@@ -256,6 +262,7 @@ describe(AlbumService.name, () => {
 
   describe('delete', () => {
     it('should throw an error for an album not found', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       albumMock.getByIds.mockResolvedValue([]);
 
       await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
@@ -266,14 +273,18 @@ describe(AlbumService.name, () => {
     });
 
     it('should not let a shared user delete the album', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(false);
       albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
 
-      await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(ForbiddenException);
+      await expect(sut.delete(authStub.admin, albumStub.sharedWithAdmin.id)).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
 
       expect(albumMock.delete).not.toHaveBeenCalled();
     });
 
     it('should let the owner delete an album', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       albumMock.getByIds.mockResolvedValue([albumStub.empty]);
 
       await sut.delete(authStub.admin, albumStub.empty.id);
@@ -284,23 +295,16 @@ describe(AlbumService.name, () => {
   });
 
   describe('addUsers', () => {
-    it('should require a valid album id', async () => {
-      albumMock.getByIds.mockResolvedValue([]);
-      await expect(sut.addUsers(authStub.admin, 'album-1', { sharedUserIds: ['user-1'] })).rejects.toBeInstanceOf(
-        BadRequestException,
-      );
-      expect(albumMock.update).not.toHaveBeenCalled();
-    });
-
-    it('should require the user to be the owner', async () => {
-      albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
+    it('should throw an error if the auth user is not the owner', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(false);
       await expect(
         sut.addUsers(authStub.admin, albumStub.sharedWithAdmin.id, { sharedUserIds: ['user-1'] }),
-      ).rejects.toBeInstanceOf(ForbiddenException);
+      ).rejects.toBeInstanceOf(BadRequestException);
       expect(albumMock.update).not.toHaveBeenCalled();
     });
 
     it('should throw an error if the userId is already added', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
       await expect(
         sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { sharedUserIds: [authStub.admin.id] }),
@@ -309,6 +313,7 @@ describe(AlbumService.name, () => {
     });
 
     it('should throw an error if the userId does not exist', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       albumMock.getByIds.mockResolvedValue([albumStub.sharedWithAdmin]);
       userMock.get.mockResolvedValue(null);
       await expect(
@@ -318,6 +323,7 @@ describe(AlbumService.name, () => {
     });
 
     it('should add valid shared users', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       albumMock.getByIds.mockResolvedValue([_.cloneDeep(albumStub.sharedWithAdmin)]);
       albumMock.update.mockResolvedValue(albumStub.sharedWithAdmin);
       userMock.get.mockResolvedValue(userEntityStub.user2);
@@ -332,12 +338,14 @@ describe(AlbumService.name, () => {
 
   describe('removeUser', () => {
     it('should require a valid album id', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       albumMock.getByIds.mockResolvedValue([]);
       await expect(sut.removeUser(authStub.admin, 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException);
       expect(albumMock.update).not.toHaveBeenCalled();
     });
 
     it('should remove a shared user from an owned album', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(true);
       albumMock.getByIds.mockResolvedValue([albumStub.sharedWithUser]);
 
       await expect(
@@ -353,13 +361,15 @@ describe(AlbumService.name, () => {
     });
 
     it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => {
+      accessMock.album.hasOwnerAccess.mockResolvedValue(false);
       albumMock.getByIds.mockResolvedValue([albumStub.sharedWithMultiple]);
 
       await expect(
         sut.removeUser(authStub.user1, albumStub.sharedWithMultiple.id, authStub.user2.id),
-      ).rejects.toBeInstanceOf(ForbiddenException);
+      ).rejects.toBeInstanceOf(BadRequestException);
 
       expect(albumMock.update).not.toHaveBeenCalled();
+      expect(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.user1.id, albumStub.sharedWithMultiple.id);
     });
 
     it('should allow a shared user to remove themselves', async () => {

+ 14 - 15
server/src/domain/album/album.service.ts

@@ -1,7 +1,8 @@
 import { AlbumEntity, AssetEntity, UserEntity } from '@app/infra/entities';
-import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
+import { BadRequestException, Inject, Injectable } from '@nestjs/common';
 import { IAssetRepository, mapAsset } from '../asset';
 import { AuthUserDto } from '../auth';
+import { AccessCore, IAccessRepository, Permission } from '../index';
 import { IJobRepository, JobName } from '../job';
 import { IUserRepository } from '../user';
 import { AlbumCountResponseDto, AlbumResponseDto, mapAlbum } from './album-response.dto';
@@ -10,12 +11,16 @@ import { AddUsersDto, CreateAlbumDto, GetAlbumsDto, UpdateAlbumDto } from './dto
 
 @Injectable()
 export class AlbumService {
+  private access: AccessCore;
   constructor(
+    @Inject(IAccessRepository) accessRepository: IAccessRepository,
     @Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
     @Inject(IAssetRepository) private assetRepository: IAssetRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
     @Inject(IUserRepository) private userRepository: IUserRepository,
-  ) {}
+  ) {
+    this.access = new AccessCore(accessRepository);
+  }
 
   async getCount(authUser: AuthUserDto): Promise<AlbumCountResponseDto> {
     const [owned, shared, notShared] = await Promise.all([
@@ -100,8 +105,9 @@ export class AlbumService {
   }
 
   async update(authUser: AuthUserDto, id: string, dto: UpdateAlbumDto): Promise<AlbumResponseDto> {
+    await this.access.requirePermission(authUser, Permission.ALBUM_UPDATE, id);
+
     const album = await this.get(id);
-    this.assertOwner(authUser, album);
 
     if (dto.albumThumbnailAssetId) {
       const valid = await this.albumRepository.hasAsset(id, dto.albumThumbnailAssetId);
@@ -122,22 +128,21 @@ export class AlbumService {
   }
 
   async delete(authUser: AuthUserDto, id: string): Promise<void> {
+    await this.access.requirePermission(authUser, Permission.ALBUM_DELETE, id);
+
     const [album] = await this.albumRepository.getByIds([id]);
     if (!album) {
       throw new BadRequestException('Album not found');
     }
 
-    if (album.ownerId !== authUser.id) {
-      throw new ForbiddenException('Album not owned by user');
-    }
-
     await this.albumRepository.delete(album);
     await this.jobRepository.queue({ name: JobName.SEARCH_REMOVE_ALBUM, data: { ids: [id] } });
   }
 
   async addUsers(authUser: AuthUserDto, id: string, dto: AddUsersDto) {
+    await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id);
+
     const album = await this.get(id);
-    this.assertOwner(authUser, album);
 
     for (const userId of dto.sharedUserIds) {
       const exists = album.sharedUsers.find((user) => user.id === userId);
@@ -180,7 +185,7 @@ export class AlbumService {
 
     // non-admin can remove themselves
     if (authUser.id !== userId) {
-      this.assertOwner(authUser, album);
+      await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, id);
     }
 
     await this.albumRepository.update({
@@ -197,10 +202,4 @@ export class AlbumService {
     }
     return album;
   }
-
-  private assertOwner(authUser: AuthUserDto, album: AlbumEntity) {
-    if (album.ownerId !== authUser.id) {
-      throw new ForbiddenException('Album not owned by user');
-    }
-  }
 }

+ 21 - 0
server/src/domain/domain.constant.spec.ts

@@ -0,0 +1,21 @@
+import { validMimeTypes } from './domain.constant';
+
+describe('valid mime types', () => {
+  it('should be a sorted list', () => {
+    expect(validMimeTypes).toEqual(validMimeTypes.sort());
+  });
+
+  it('should contain only unique values', () => {
+    expect(validMimeTypes).toEqual([...new Set(validMimeTypes)]);
+  });
+
+  it('should contain only image or video mime types', () => {
+    expect(validMimeTypes).toEqual(
+      validMimeTypes.filter((mimeType) => mimeType.startsWith('image/') || mimeType.startsWith('video/')),
+    );
+  });
+
+  it('should contain only lowercase mime types', () => {
+    expect(validMimeTypes).toEqual(validMimeTypes.map((mimeType) => mimeType.toLowerCase()));
+  });
+});

+ 29 - 28
server/src/domain/domain.constant.ts

@@ -28,16 +28,40 @@ export function assertMachineLearningEnabled() {
   }
 }
 
-const validMimeTypes = [
+export const validMimeTypes = [
+  'image/3fr',
+  'image/ari',
+  'image/arw',
   'image/avif',
+  'image/cap',
+  'image/cin',
+  'image/cr2',
   'image/cr3',
+  'image/crw',
+  'image/dcr',
+  'image/dng',
+  'image/erf',
+  'image/fff',
   'image/gif',
   'image/heic',
   'image/heif',
+  'image/iiq',
   'image/jpeg',
   'image/jxl',
+  'image/k25',
+  'image/kdc',
+  'image/mrw',
+  'image/nef',
+  'image/orf',
+  'image/ori',
+  'image/pef',
   'image/png',
-  'image/dng',
+  'image/raf',
+  'image/raw',
+  'image/rwl',
+  'image/sr2',
+  'image/srf',
+  'image/srw',
   'image/tiff',
   'image/webp',
   'image/x-adobe-dng',
@@ -67,38 +91,15 @@ const validMimeTypes = [
   'image/x-sony-arw',
   'image/x-sony-sr2',
   'image/x-sony-srf',
-  'image/dng',
-  'image/ari',
-  'image/cr2',
-  'image/cr3',
-  'image/crw',
-  'image/erf',
-  'image/raf',
-  'image/3fr',
-  'image/fff',
-  'image/dcr',
-  'image/k25',
-  'image/kdc',
-  'image/rwl',
-  'image/mrw',
-  'image/nef',
-  'image/orf',
-  'image/ori',
-  'image/raw',
-  'image/pef',
-  'image/cin',
-  'image/cap',
-  'image/iiq',
-  'image/srw',
   'image/x3f',
-  'image/arw',
-  'image/sr2',
-  'image/srf',
   'video/3gpp',
+  'video/avi',
   'video/mp2t',
   'video/mp4',
   'video/mpeg',
+  'video/msvideo',
   'video/quicktime',
+  'video/vnd.avi',
   'video/webm',
   'video/x-flv',
   'video/x-matroska',

+ 12 - 11
server/src/domain/shared-link/shared-link.service.spec.ts

@@ -3,6 +3,7 @@ import {
   albumStub,
   assetEntityStub,
   authStub,
+  IAccessRepositoryMock,
   newAccessRepositoryMock,
   newCryptoRepositoryMock,
   newSharedLinkRepositoryMock,
@@ -12,13 +13,13 @@ import {
 import { when } from 'jest-when';
 import _ from 'lodash';
 import { SharedLinkType } from '../../infra/entities/shared-link.entity';
-import { AssetIdErrorReason, IAccessRepository, ICryptoRepository } from '../index';
+import { AssetIdErrorReason, ICryptoRepository } from '../index';
 import { ISharedLinkRepository } from './shared-link.repository';
 import { SharedLinkService } from './shared-link.service';
 
 describe(SharedLinkService.name, () => {
   let sut: SharedLinkService;
-  let accessMock: jest.Mocked<IAccessRepository>;
+  let accessMock: IAccessRepositoryMock;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
   let shareMock: jest.Mocked<ISharedLinkRepository>;
 
@@ -89,7 +90,7 @@ describe(SharedLinkService.name, () => {
     });
 
     it('should not allow non-owners to create album shared links', async () => {
-      accessMock.hasAlbumOwnerAccess.mockResolvedValue(false);
+      accessMock.album.hasOwnerAccess.mockResolvedValue(false);
       await expect(
         sut.create(authStub.admin, { type: SharedLinkType.ALBUM, assetIds: [], albumId: 'album-1' }),
       ).rejects.toBeInstanceOf(BadRequestException);
@@ -102,19 +103,19 @@ describe(SharedLinkService.name, () => {
     });
 
     it('should require asset ownership to make an individual shared link', async () => {
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
+      accessMock.asset.hasOwnerAccess.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);
+      accessMock.album.hasOwnerAccess.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(accessMock.album.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, albumStub.oneAsset.id);
       expect(shareMock.create).toHaveBeenCalledWith({
         type: SharedLinkType.ALBUM,
         userId: authStub.admin.id,
@@ -130,7 +131,7 @@ describe(SharedLinkService.name, () => {
     });
 
     it('should create an individual shared link', async () => {
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
       shareMock.create.mockResolvedValue(sharedLinkStub.individual);
 
       await sut.create(authStub.admin, {
@@ -141,7 +142,7 @@ describe(SharedLinkService.name, () => {
         allowUpload: true,
       });
 
-      expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
+      expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
       expect(shareMock.create).toHaveBeenCalledWith({
         type: SharedLinkType.INDIVIDUAL,
         userId: authStub.admin.id,
@@ -206,8 +207,8 @@ describe(SharedLinkService.name, () => {
       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);
+      when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-2').mockResolvedValue(false);
+      when(accessMock.asset.hasOwnerAccess).calledWith(authStub.admin.id, 'asset-3').mockResolvedValue(true);
 
       await expect(
         sut.addAssets(authStub.admin, 'link-1', { assetIds: [assetEntityStub.image.id, 'asset-2', 'asset-3'] }),
@@ -217,7 +218,7 @@ describe(SharedLinkService.name, () => {
         { assetId: 'asset-3', success: true },
       ]);
 
-      expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledTimes(2);
+      expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledTimes(2);
       expect(shareMock.update).toHaveBeenCalledWith({
         ...sharedLinkStub.individual,
         assets: [assetEntityStub.image, { id: 'asset-3' }],

+ 10 - 16
server/src/domain/shared-link/shared-link.service.ts

@@ -1,6 +1,6 @@
 import { AssetEntity, SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
 import { BadRequestException, ForbiddenException, Inject, Injectable } from '@nestjs/common';
-import { IAccessRepository } from '../access';
+import { AccessCore, IAccessRepository, Permission } from '../access';
 import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
 import { AuthUserDto } from '../auth';
 import { ICryptoRepository } from '../crypto';
@@ -10,11 +10,15 @@ import { ISharedLinkRepository } from './shared-link.repository';
 
 @Injectable()
 export class SharedLinkService {
+  private access: AccessCore;
+
   constructor(
-    @Inject(IAccessRepository) private accessRepository: IAccessRepository,
+    @Inject(IAccessRepository) accessRepository: IAccessRepository,
     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
     @Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
-  ) {}
+  ) {
+    this.access = new AccessCore(accessRepository);
+  }
 
   getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
     return this.repository.getAll(authUser.id).then((links) => links.map(mapSharedLink));
@@ -43,12 +47,7 @@ export class SharedLinkService {
         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');
-        }
-
+        await this.access.requirePermission(authUser, Permission.ALBUM_SHARE, dto.albumId);
         break;
 
       case SharedLinkType.INDIVIDUAL:
@@ -56,12 +55,7 @@ export class SharedLinkService {
           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}`);
-          }
-        }
+        await this.access.requirePermission(authUser, Permission.ASSET_SHARE, dto.assetIds);
 
         break;
     }
@@ -124,7 +118,7 @@ export class SharedLinkService {
         continue;
       }
 
-      const hasAccess = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
+      const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_SHARE, assetId);
       if (!hasAccess) {
         results.push({ assetId, success: false, error: AssetIdErrorReason.NO_PERMISSION });
         continue;

+ 2 - 2
server/src/domain/smart-info/smart-info.service.spec.ts

@@ -91,13 +91,13 @@ describe(SmartInfoService.name, () => {
       });
     });
 
-    it('should no update the smart info if no tags were returned', async () => {
+    it('should always overwrite old tags', async () => {
       machineMock.classifyImage.mockResolvedValue([]);
 
       await sut.handleClassifyImage({ id: asset.id });
 
       expect(machineMock.classifyImage).toHaveBeenCalled();
-      expect(smartMock.upsert).not.toHaveBeenCalled();
+      expect(smartMock.upsert).toHaveBeenCalled();
     });
   });
 

+ 0 - 4
server/src/domain/smart-info/smart-info.service.ts

@@ -41,10 +41,6 @@ export class SmartInfoService {
     }
 
     const tags = await this.machineLearning.classifyImage({ imagePath: asset.resizePath });
-    if (tags.length === 0) {
-      return false;
-    }
-
     await this.repository.upsert({ assetId: asset.id, tags });
 
     return true;

+ 6 - 2
server/src/immich/api-v1/asset/asset-repository.ts

@@ -260,11 +260,15 @@ export class AssetRepository implements IAssetRepository {
     asset.isArchived = dto.isArchived ?? asset.isArchived;
 
     if (asset.exifInfo != null) {
-      asset.exifInfo.description = dto.description || '';
+      if (dto.description !== undefined) {
+        asset.exifInfo.description = dto.description;
+      }
       await this.exifRepository.save(asset.exifInfo);
     } else {
       const exifInfo = new ExifEntity();
-      exifInfo.description = dto.description || '';
+      if (dto.description !== undefined) {
+        exifInfo.description = dto.description;
+      }
       exifInfo.asset = asset;
       await this.exifRepository.save(exifInfo);
       asset.exifInfo = exifInfo;

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

@@ -162,7 +162,7 @@ export class AssetController {
 
   @SharedLinkRoute()
   @Get('/file/:id')
-  @Header('Cache-Control', 'max-age=31536000')
+  @Header('Cache-Control', 'private, max-age=86400, no-transform')
   @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
   serveFile(
     @AuthUser() authUser: AuthUserDto,
@@ -176,7 +176,7 @@ export class AssetController {
 
   @SharedLinkRoute()
   @Get('/thumbnail/:id')
-  @Header('Cache-Control', 'max-age=31536000')
+  @Header('Cache-Control', 'private, max-age=86400, no-transform')
   @ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
   getAssetThumbnail(
     @AuthUser() authUser: AuthUserDto,

+ 28 - 27
server/src/immich/api-v1/asset/asset.service.spec.ts

@@ -1,10 +1,11 @@
-import { IAccessRepository, ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
+import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
 import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
-import { ForbiddenException } from '@nestjs/common';
+import { BadRequestException } from '@nestjs/common';
 import {
   assetEntityStub,
   authStub,
   fileStub,
+  IAccessRepositoryMock,
   newAccessRepositoryMock,
   newCryptoRepositoryMock,
   newJobRepositoryMock,
@@ -120,7 +121,7 @@ const _getArchivedAssetsCountByUserId = (): AssetCountByUserIdResponseDto => {
 describe('AssetService', () => {
   let sut: AssetService;
   let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
-  let accessMock: jest.Mocked<IAccessRepository>;
+  let accessMock: IAccessRepositoryMock;
   let assetRepositoryMock: jest.Mocked<IAssetRepository>;
   let cryptoMock: jest.Mocked<ICryptoRepository>;
   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
@@ -293,7 +294,7 @@ describe('AssetService', () => {
   describe('deleteAll', () => {
     it('should return failed status when an asset is missing', async () => {
       assetRepositoryMock.get.mockResolvedValue(null);
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
 
       await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
         { id: 'asset1', status: 'FAILED' },
@@ -305,7 +306,7 @@ describe('AssetService', () => {
     it('should return failed status a delete fails', async () => {
       assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
       assetRepositoryMock.remove.mockRejectedValue('delete failed');
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
 
       await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
         { id: 'asset1', status: 'FAILED' },
@@ -315,7 +316,7 @@ describe('AssetService', () => {
     });
 
     it('should delete a live photo', async () => {
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
 
       await expect(sut.deleteAll(authStub.user1, { ids: [assetEntityStub.livePhotoStillAsset.id] })).resolves.toEqual([
         { id: assetEntityStub.livePhotoStillAsset.id, status: 'SUCCESS' },
@@ -364,7 +365,7 @@ describe('AssetService', () => {
         .calledWith(asset2.id)
         .mockResolvedValue(asset2 as AssetEntity);
 
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
 
       await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
         { id: 'asset1', status: 'SUCCESS' },
@@ -409,7 +410,7 @@ describe('AssetService', () => {
 
   describe('downloadFile', () => {
     it('should download a single file', async () => {
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
       assetRepositoryMock.get.mockResolvedValue(_getAsset_1());
 
       await sut.downloadFile(authStub.admin, 'id_1');
@@ -485,56 +486,56 @@ describe('AssetService', () => {
 
   describe('getAssetById', () => {
     it('should allow owner access', async () => {
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
       assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
       await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
-      expect(accessMock.hasOwnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
+      expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
     });
 
     it('should allow shared link access', async () => {
-      accessMock.hasSharedLinkAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasSharedLinkAccess.mockResolvedValue(true);
       assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
       await sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id);
-      expect(accessMock.hasSharedLinkAssetAccess).toHaveBeenCalledWith(
+      expect(accessMock.asset.hasSharedLinkAccess).toHaveBeenCalledWith(
         authStub.adminSharedLink.sharedLinkId,
         assetEntityStub.image.id,
       );
     });
 
     it('should allow partner sharing access', async () => {
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
-      accessMock.hasPartnerAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
+      accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
       assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
       await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
-      expect(accessMock.hasPartnerAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
+      expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
     });
 
     it('should allow shared album access', async () => {
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
-      accessMock.hasPartnerAssetAccess.mockResolvedValue(false);
-      accessMock.hasAlbumAssetAccess.mockResolvedValue(true);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
+      accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
+      accessMock.asset.hasAlbumAccess.mockResolvedValue(true);
       assetRepositoryMock.getById.mockResolvedValue(assetEntityStub.image);
       await sut.getAssetById(authStub.admin, assetEntityStub.image.id);
-      expect(accessMock.hasAlbumAssetAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
+      expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, assetEntityStub.image.id);
     });
 
     it('should throw an error for no access', async () => {
-      accessMock.hasOwnerAssetAccess.mockResolvedValue(false);
-      accessMock.hasPartnerAssetAccess.mockResolvedValue(false);
-      accessMock.hasSharedLinkAssetAccess.mockResolvedValue(false);
-      accessMock.hasAlbumAssetAccess.mockResolvedValue(false);
+      accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
+      accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
+      accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
+      accessMock.asset.hasAlbumAccess.mockResolvedValue(false);
       await expect(sut.getAssetById(authStub.admin, assetEntityStub.image.id)).rejects.toBeInstanceOf(
-        ForbiddenException,
+        BadRequestException,
       );
       expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
     });
 
     it('should throw an error for an invalid shared link', async () => {
-      accessMock.hasSharedLinkAssetAccess.mockResolvedValue(false);
+      accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
       await expect(sut.getAssetById(authStub.adminSharedLink, assetEntityStub.image.id)).rejects.toBeInstanceOf(
-        ForbiddenException,
+        BadRequestException,
       );
-      expect(accessMock.hasOwnerAssetAccess).not.toHaveBeenCalled();
+      expect(accessMock.asset.hasOwnerAccess).not.toHaveBeenCalled();
       expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
     });
   });

+ 34 - 95
server/src/immich/api-v1/asset/asset.service.ts

@@ -1,4 +1,5 @@
 import {
+  AccessCore,
   AssetResponseDto,
   AuthUserDto,
   getLivePhotoMotionFilename,
@@ -6,17 +7,16 @@ import {
   ICryptoRepository,
   IJobRepository,
   ImmichReadStream,
-  isSidecarFileType,
   isSupportedFileType,
   IStorageRepository,
   JobName,
   mapAsset,
   mapAssetWithoutExif,
+  Permission,
 } from '@app/domain';
 import { AssetEntity, AssetType } from '@app/infra/entities';
 import {
   BadRequestException,
-  ForbiddenException,
   Inject,
   Injectable,
   InternalServerErrorException,
@@ -79,9 +79,10 @@ interface ServableFile {
 export class AssetService {
   readonly logger = new Logger(AssetService.name);
   private assetCore: AssetCore;
+  private access: AccessCore;
 
   constructor(
-    @Inject(IAccessRepository) private accessRepository: IAccessRepository,
+    @Inject(IAccessRepository) accessRepository: IAccessRepository,
     @Inject(IAssetRepository) private _assetRepository: IAssetRepository,
     @InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
     @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@@ -90,6 +91,7 @@ export class AssetService {
     @Inject(IStorageRepository) private storageRepository: IStorageRepository,
   ) {
     this.assetCore = new AssetCore(_assetRepository, jobRepository);
+    this.access = new AccessCore(accessRepository);
   }
 
   public async uploadFile(
@@ -149,9 +151,8 @@ export class AssetService {
     }
 
     if (dto.sidecarPath) {
-      const sidecarType = mime.lookup(dto.sidecarPath) as string;
-      if (!isSidecarFileType(sidecarType)) {
-        throw new BadRequestException(`Unsupported sidecar file type ${assetPathType}`);
+      if (path.extname(dto.sidecarPath).toLowerCase() !== '.xmp') {
+        throw new BadRequestException(`Unsupported sidecar file type`);
       }
     }
 
@@ -208,32 +209,21 @@ export class AssetService {
   }
 
   public async getAllAssets(authUser: AuthUserDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
-    if (dto.userId && dto.userId !== authUser.id) {
-      await this.checkUserAccess(authUser, dto.userId);
-    }
-    const assets = await this._assetRepository.getAllByUserId(dto.userId || authUser.id, dto);
-
+    const userId = dto.userId || authUser.id;
+    await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
+    const assets = await this._assetRepository.getAllByUserId(userId, dto);
     return assets.map((asset) => mapAsset(asset));
   }
 
-  public async getAssetByTimeBucket(
-    authUser: AuthUserDto,
-    getAssetByTimeBucketDto: GetAssetByTimeBucketDto,
-  ): Promise<AssetResponseDto[]> {
-    if (getAssetByTimeBucketDto.userId) {
-      await this.checkUserAccess(authUser, getAssetByTimeBucketDto.userId);
-    }
-
-    const assets = await this._assetRepository.getAssetByTimeBucket(
-      getAssetByTimeBucketDto.userId || authUser.id,
-      getAssetByTimeBucketDto,
-    );
-
+  public async getAssetByTimeBucket(authUser: AuthUserDto, dto: GetAssetByTimeBucketDto): Promise<AssetResponseDto[]> {
+    const userId = dto.userId || authUser.id;
+    await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
+    const assets = await this._assetRepository.getAssetByTimeBucket(userId, dto);
     return assets.map((asset) => mapAsset(asset));
   }
 
   public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
-    await this.checkAssetsAccess(authUser, [assetId]);
+    await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId);
 
     const allowExif = this.getExifPermission(authUser);
     const asset = await this._assetRepository.getById(assetId);
@@ -246,7 +236,7 @@ export class AssetService {
   }
 
   public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
-    await this.checkAssetsAccess(authUser, [assetId], true);
+    await this.access.requirePermission(authUser, Permission.ASSET_UPDATE, assetId);
 
     const asset = await this._assetRepository.getById(assetId);
     if (!asset) {
@@ -261,15 +251,15 @@ export class AssetService {
   }
 
   public async downloadLibrary(authUser: AuthUserDto, dto: DownloadDto) {
-    this.checkDownloadAccess(authUser);
+    await this.access.requirePermission(authUser, Permission.LIBRARY_DOWNLOAD, authUser.id);
+
     const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
 
     return this.downloadService.downloadArchive(dto.name || `library`, assets);
   }
 
   public async downloadFiles(authUser: AuthUserDto, dto: DownloadFilesDto) {
-    this.checkDownloadAccess(authUser);
-    await this.checkAssetsAccess(authUser, [...dto.assetIds]);
+    await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, dto.assetIds);
 
     const assetToDownload = [];
 
@@ -289,8 +279,7 @@ export class AssetService {
   }
 
   public async downloadFile(authUser: AuthUserDto, assetId: string): Promise<ImmichReadStream> {
-    this.checkDownloadAccess(authUser);
-    await this.checkAssetsAccess(authUser, [assetId]);
+    await this.access.requirePermission(authUser, Permission.ASSET_DOWNLOAD, assetId);
 
     try {
       const asset = await this._assetRepository.get(assetId);
@@ -312,7 +301,8 @@ export class AssetService {
     res: Res,
     headers: Record<string, string>,
   ) {
-    await this.checkAssetsAccess(authUser, [assetId]);
+    await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId);
+
     const asset = await this._assetRepository.get(assetId);
     if (!asset) {
       throw new NotFoundException('Asset not found');
@@ -338,7 +328,8 @@ export class AssetService {
     res: Res,
     headers: Record<string, string>,
   ) {
-    await this.checkAssetsAccess(authUser, [assetId]);
+    // this is not quite right as sometimes this returns the original still
+    await this.access.requirePermission(authUser, Permission.ASSET_VIEW, assetId);
 
     const allowOriginalFile = !!(!authUser.isPublicUser || authUser.isAllowDownload);
 
@@ -421,13 +412,17 @@ export class AssetService {
   }
 
   public async deleteAll(authUser: AuthUserDto, dto: DeleteAssetDto): Promise<DeleteAssetResponseDto[]> {
-    await this.checkAssetsAccess(authUser, dto.ids, true);
-
     const deleteQueue: Array<string | null> = [];
     const result: DeleteAssetResponseDto[] = [];
 
     const ids = dto.ids.slice();
     for (const id of ids) {
+      const hasAccess = await this.access.hasPermission(authUser, Permission.ASSET_DELETE, id);
+      if (!hasAccess) {
+        result.push({ id, status: DeleteAssetStatusEnum.FAILED });
+        continue;
+      }
+
       const asset = await this._assetRepository.get(id);
       if (!asset) {
         result.push({ id, status: DeleteAssetStatusEnum.FAILED });
@@ -605,17 +600,11 @@ export class AssetService {
 
   async getAssetCountByTimeBucket(
     authUser: AuthUserDto,
-    getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto,
+    dto: GetAssetCountByTimeBucketDto,
   ): Promise<AssetCountByTimeBucketResponseDto> {
-    if (getAssetCountByTimeBucketDto.userId !== undefined) {
-      await this.checkUserAccess(authUser, getAssetCountByTimeBucketDto.userId);
-    }
-
-    const result = await this._assetRepository.getAssetCountByTimeBucket(
-      getAssetCountByTimeBucketDto.userId || authUser.id,
-      getAssetCountByTimeBucketDto,
-    );
-
+    const userId = dto.userId || authUser.id;
+    await this.access.requirePermission(authUser, Permission.LIBRARY_READ, userId);
+    const result = await this._assetRepository.getAssetCountByTimeBucket(userId, dto);
     return mapAssetCountByTimeBucket(result);
   }
 
@@ -627,56 +616,6 @@ export class AssetService {
     return this._assetRepository.getArchivedAssetCountByUserId(authUser.id);
   }
 
-  private async checkAssetsAccess(authUser: AuthUserDto, assetIds: string[], mustBeOwner = false) {
-    const sharedLinkId = authUser.sharedLinkId;
-
-    for (const assetId of assetIds) {
-      if (sharedLinkId) {
-        const canAccess = await this.accessRepository.hasSharedLinkAssetAccess(sharedLinkId, assetId);
-        if (canAccess) {
-          continue;
-        }
-
-        throw new ForbiddenException();
-      }
-
-      const isOwner = await this.accessRepository.hasOwnerAssetAccess(authUser.id, assetId);
-      if (isOwner) {
-        continue;
-      }
-
-      if (mustBeOwner) {
-        throw new ForbiddenException();
-      }
-
-      const isPartnerShared = await this.accessRepository.hasPartnerAssetAccess(authUser.id, assetId);
-      if (isPartnerShared) {
-        continue;
-      }
-
-      const isAlbumShared = await this.accessRepository.hasAlbumAssetAccess(authUser.id, assetId);
-      if (isAlbumShared) {
-        continue;
-      }
-
-      throw new ForbiddenException();
-    }
-  }
-
-  private async checkUserAccess(authUser: AuthUserDto, userId: string) {
-    // Check if userId shares assets with authUser
-    const canAccess = await this.accessRepository.hasPartnerAccess(authUser.id, userId);
-    if (!canAccess) {
-      throw new ForbiddenException();
-    }
-  }
-
-  private checkDownloadAccess(authUser: AuthUserDto) {
-    if (authUser.isPublicUser && !authUser.isAllowDownload) {
-      throw new ForbiddenException();
-    }
-  }
-
   getExifPermission(authUser: AuthUserDto) {
     return !authUser.isPublicUser || authUser.isShowExif;
   }

+ 30 - 27
server/src/immich/config/asset-upload.config.spec.ts

@@ -50,14 +50,41 @@ describe('assetUploadOption', () => {
     });
 
     for (const { mimetype, extension } of [
+      // Please ensure this list is sorted.
+      { mimetype: 'image/3fr', extension: '3fr' },
+      { mimetype: 'image/ari', extension: 'ari' },
+      { mimetype: 'image/arw', extension: 'arw' },
       { mimetype: 'image/avif', extension: 'avif' },
+      { mimetype: 'image/cap', extension: 'cap' },
+      { mimetype: 'image/cin', extension: 'cin' },
+      { mimetype: 'image/cr2', extension: 'cr2' },
+      { mimetype: 'image/cr3', extension: 'cr3' },
+      { mimetype: 'image/crw', extension: 'crw' },
+      { mimetype: 'image/dcr', extension: 'dcr' },
+      { mimetype: 'image/dng', extension: 'dng' },
+      { mimetype: 'image/erf', extension: 'erf' },
+      { mimetype: 'image/fff', extension: 'fff' },
       { mimetype: 'image/gif', extension: 'gif' },
       { mimetype: 'image/heic', extension: 'heic' },
       { mimetype: 'image/heif', extension: 'heif' },
+      { mimetype: 'image/iiq', extension: 'iiq' },
       { mimetype: 'image/jpeg', extension: 'jpeg' },
       { mimetype: 'image/jpeg', extension: 'jpg' },
       { mimetype: 'image/jxl', extension: 'jxl' },
+      { mimetype: 'image/k25', extension: 'k25' },
+      { mimetype: 'image/kdc', extension: 'kdc' },
+      { mimetype: 'image/mrw', extension: 'mrw' },
+      { mimetype: 'image/nef', extension: 'nef' },
+      { mimetype: 'image/orf', extension: 'orf' },
+      { mimetype: 'image/ori', extension: 'ori' },
+      { mimetype: 'image/pef', extension: 'pef' },
       { mimetype: 'image/png', extension: 'png' },
+      { mimetype: 'image/raf', extension: 'raf' },
+      { mimetype: 'image/raw', extension: 'raw' },
+      { mimetype: 'image/rwl', extension: 'rwl' },
+      { mimetype: 'image/sr2', extension: 'sr2' },
+      { mimetype: 'image/srf', extension: 'srf' },
+      { mimetype: 'image/srw', extension: 'srw' },
       { mimetype: 'image/tiff', extension: 'tiff' },
       { mimetype: 'image/webp', extension: 'webp' },
       { mimetype: 'image/x-adobe-dng', extension: 'dng' },
@@ -87,40 +114,16 @@ describe('assetUploadOption', () => {
       { mimetype: 'image/x-sony-arw', extension: 'arw' },
       { mimetype: 'image/x-sony-sr2', extension: 'sr2' },
       { mimetype: 'image/x-sony-srf', extension: 'srf' },
-
-      { mimetype: 'image/dng', extension: 'dng' },
-      { mimetype: 'image/ari', extension: 'ari' },
-      { mimetype: 'image/cr2', extension: 'cr2' },
-      { mimetype: 'image/cr3', extension: 'cr3' },
-      { mimetype: 'image/crw', extension: 'crw' },
-      { mimetype: 'image/erf', extension: 'erf' },
-      { mimetype: 'image/raf', extension: 'raf' },
-      { mimetype: 'image/3fr', extension: '3fr' },
-      { mimetype: 'image/fff', extension: 'fff' },
-      { mimetype: 'image/dcr', extension: 'dcr' },
-      { mimetype: 'image/k25', extension: 'k25' },
-      { mimetype: 'image/kdc', extension: 'kdc' },
-      { mimetype: 'image/rwl', extension: 'rwl' },
-      { mimetype: 'image/mrw', extension: 'mrw' },
-      { mimetype: 'image/nef', extension: 'nef' },
-      { mimetype: 'image/orf', extension: 'orf' },
-      { mimetype: 'image/ori', extension: 'ori' },
-      { mimetype: 'image/raw', extension: 'raw' },
-      { mimetype: 'image/pef', extension: 'pef' },
-      { mimetype: 'image/cin', extension: 'cin' },
-      { mimetype: 'image/cap', extension: 'cap' },
-      { mimetype: 'image/iiq', extension: 'iiq' },
-      { mimetype: 'image/srw', extension: 'srw' },
       { mimetype: 'image/x3f', extension: 'x3f' },
-      { mimetype: 'image/arw', extension: 'arw' },
-      { mimetype: 'image/sr2', extension: 'sr2' },
-      { mimetype: 'image/srf', extension: 'srf' },
       { mimetype: 'video/3gpp', extension: '3gp' },
+      { mimetype: 'video/avi', extension: 'avi' },
       { mimetype: 'video/mp2t', extension: 'm2ts' },
       { mimetype: 'video/mp2t', extension: 'mts' },
       { mimetype: 'video/mp4', extension: 'mp4' },
       { mimetype: 'video/mpeg', extension: 'mpg' },
+      { mimetype: 'video/msvideo', extension: 'avi' },
       { mimetype: 'video/quicktime', extension: 'mov' },
+      { mimetype: 'video/vnd.avi', extension: 'avi' },
       { mimetype: 'video/webm', extension: 'webm' },
       { mimetype: 'video/x-flv', extension: 'flv' },
       { mimetype: 'video/x-matroska', extension: 'mkv' },

+ 1 - 1
server/src/immich/controllers/user.controller.ts

@@ -98,7 +98,7 @@ export class UserController {
   }
 
   @Get('/profile-image/:userId')
-  @Header('Cache-Control', 'max-age=600')
+  @Header('Cache-Control', 'private, max-age=86400, no-transform')
   async getProfileImage(@Param() { userId }: UserIdDto, @Response({ passthrough: true }) res: Res): Promise<any> {
     const readableStream = await this.service.getUserProfileImage(userId);
     res.header('Content-Type', 'image/jpeg');

+ 125 - 75
server/src/infra/repositories/access.repository.ts

@@ -11,97 +11,147 @@ export class AccessRepository implements IAccessRepository {
     @InjectRepository(SharedLinkEntity) private sharedLinkRepository: Repository<SharedLinkEntity>,
   ) {}
 
-  hasPartnerAccess(userId: string, partnerId: string): Promise<boolean> {
-    return this.partnerRepository.exist({
-      where: {
-        sharedWithId: userId,
-        sharedById: partnerId,
-      },
-    });
-  }
+  library = {
+    hasPartnerAccess: (userId: string, partnerId: string): Promise<boolean> => {
+      return this.partnerRepository.exist({
+        where: {
+          sharedWithId: userId,
+          sharedById: partnerId,
+        },
+      });
+    },
+  };
 
-  hasAlbumAssetAccess(userId: string, assetId: string): Promise<boolean> {
-    return this.albumRepository.exist({
-      where: [
-        {
-          ownerId: userId,
-          assets: {
-            id: assetId,
+  asset = {
+    hasAlbumAccess: (userId: string, assetId: string): Promise<boolean> => {
+      return this.albumRepository.exist({
+        where: [
+          {
+            ownerId: userId,
+            assets: {
+              id: assetId,
+            },
           },
-        },
-        {
-          sharedUsers: {
-            id: userId,
+          {
+            sharedUsers: {
+              id: userId,
+            },
+            assets: {
+              id: assetId,
+            },
           },
-          assets: {
-            id: assetId,
+          // still part of a live photo is in an album
+          {
+            ownerId: userId,
+            assets: {
+              livePhotoVideoId: assetId,
+            },
           },
-        },
-      ],
-    });
-  }
-
-  hasOwnerAssetAccess(userId: string, assetId: string): Promise<boolean> {
-    return this.assetRepository.exist({
-      where: {
-        id: assetId,
-        ownerId: userId,
-      },
-    });
-  }
+          {
+            sharedUsers: {
+              id: userId,
+            },
+            assets: {
+              livePhotoVideoId: assetId,
+            },
+          },
+        ],
+      });
+    },
 
-  hasPartnerAssetAccess(userId: string, assetId: string): Promise<boolean> {
-    return this.partnerRepository.exist({
-      where: {
-        sharedWith: {
-          id: userId,
+    hasOwnerAccess: (userId: string, assetId: string): Promise<boolean> => {
+      return this.assetRepository.exist({
+        where: {
+          id: assetId,
+          ownerId: userId,
         },
-        sharedBy: {
-          assets: {
-            id: assetId,
+      });
+    },
+
+    hasPartnerAccess: (userId: string, assetId: string): Promise<boolean> => {
+      return this.partnerRepository.exist({
+        where: {
+          sharedWith: {
+            id: userId,
+          },
+          sharedBy: {
+            assets: {
+              id: assetId,
+            },
           },
         },
-      },
-      relations: {
-        sharedWith: true,
-        sharedBy: {
-          assets: true,
+        relations: {
+          sharedWith: true,
+          sharedBy: {
+            assets: true,
+          },
         },
-      },
-    });
-  }
+      });
+    },
 
-  async hasSharedLinkAssetAccess(sharedLinkId: string, assetId: string): Promise<boolean> {
-    return (
-      // album asset
-      (await this.sharedLinkRepository.exist({
-        where: {
-          id: sharedLinkId,
-          album: {
+    hasSharedLinkAccess: async (sharedLinkId: string, assetId: string): Promise<boolean> => {
+      return this.sharedLinkRepository.exist({
+        where: [
+          {
+            id: sharedLinkId,
+            album: {
+              assets: {
+                id: assetId,
+              },
+            },
+          },
+          {
+            id: sharedLinkId,
             assets: {
               id: assetId,
             },
           },
+          // still part of a live photo is in a shared link
+          {
+            id: sharedLinkId,
+            album: {
+              assets: {
+                livePhotoVideoId: assetId,
+              },
+            },
+          },
+          {
+            id: sharedLinkId,
+            assets: {
+              livePhotoVideoId: assetId,
+            },
+          },
+        ],
+      });
+    },
+  };
+
+  album = {
+    hasOwnerAccess: (userId: string, albumId: string): Promise<boolean> => {
+      return this.albumRepository.exist({
+        where: {
+          id: albumId,
+          ownerId: userId,
+        },
+      });
+    },
+
+    hasSharedAlbumAccess: (userId: string, albumId: string): Promise<boolean> => {
+      return this.albumRepository.exist({
+        where: {
+          id: albumId,
+          ownerId: userId,
         },
-      })) ||
-      // individual asset
-      (await this.sharedLinkRepository.exist({
+      });
+    },
+
+    hasSharedLinkAccess: (sharedLinkId: string, albumId: string): Promise<boolean> => {
+      return this.sharedLinkRepository.exist({
         where: {
           id: sharedLinkId,
-          assets: {
-            id: assetId,
-          },
+          albumId,
         },
-      }))
-    );
-  }
-
-  hasAlbumOwnerAccess(userId: string, albumId: string): Promise<boolean> {
-    return this.albumRepository.exist({
-      where: {
-        id: albumId,
-        ownerId: userId,
-      },
-    });
-  }
+      });
+    },
+  };
 }

+ 21 - 7
server/test/repositories/access.repository.mock.ts

@@ -1,14 +1,28 @@
 import { IAccessRepository } from '@app/domain';
 
-export const newAccessRepositoryMock = (): jest.Mocked<IAccessRepository> => {
+export type IAccessRepositoryMock = {
+  asset: jest.Mocked<IAccessRepository['asset']>;
+  album: jest.Mocked<IAccessRepository['album']>;
+  library: jest.Mocked<IAccessRepository['library']>;
+};
+
+export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
   return {
-    hasPartnerAccess: jest.fn(),
+    asset: {
+      hasOwnerAccess: jest.fn(),
+      hasAlbumAccess: jest.fn(),
+      hasPartnerAccess: jest.fn(),
+      hasSharedLinkAccess: jest.fn(),
+    },
 
-    hasAlbumAssetAccess: jest.fn(),
-    hasOwnerAssetAccess: jest.fn(),
-    hasPartnerAssetAccess: jest.fn(),
-    hasSharedLinkAssetAccess: jest.fn(),
+    album: {
+      hasOwnerAccess: jest.fn(),
+      hasSharedAlbumAccess: jest.fn(),
+      hasSharedLinkAccess: jest.fn(),
+    },
 
-    hasAlbumOwnerAccess: jest.fn(),
+    library: {
+      hasPartnerAccess: jest.fn(),
+    },
   };
 };

+ 6 - 6
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -24,6 +24,7 @@
 	import VideoViewer from './video-viewer.svelte';
 
 	import { assetStore } from '$lib/stores/assets.store';
+	import { isShowDetail } from '$lib/stores/preferences.store';
 	import { addAssetsToAlbum, getFilenameExtension } from '$lib/utils/asset-utils';
 	import { browser } from '$app/environment';
 
@@ -35,7 +36,6 @@
 	const dispatch = createEventDispatcher();
 	let halfLeftHover = false;
 	let halfRightHover = false;
-	let isShowDetail = false;
 	let appearsInAlbums: AlbumResponseDto[] = [];
 	let isShowAlbumPicker = false;
 	let addToSharedAlbum = true;
@@ -81,7 +81,7 @@
 				deleteAsset();
 				return;
 			case 'i':
-				isShowDetail = !isShowDetail;
+				$isShowDetail = !$isShowDetail;
 				return;
 			case 'ArrowLeft':
 				navigateAssetBackward();
@@ -93,7 +93,7 @@
 	};
 
 	const handleCloseViewer = () => {
-		isShowDetail = false;
+		$isShowDetail = false;
 		closeViewer();
 	};
 
@@ -112,7 +112,7 @@
 	};
 
 	const showDetailInfoHandler = () => {
-		isShowDetail = !isShowDetail;
+		$isShowDetail = !$isShowDetail;
 	};
 
 	const handleDownload = () => {
@@ -401,7 +401,7 @@
 		</div>
 	{/if}
 
-	{#if isShowDetail}
+	{#if $isShowDetail}
 		<div
 			transition:fly={{ duration: 150 }}
 			id="detail-panel"
@@ -411,7 +411,7 @@
 			<DetailPanel
 				{asset}
 				albums={appearsInAlbums}
-				on:close={() => (isShowDetail = false)}
+				on:close={() => ($isShowDetail = false)}
 				on:close-viewer={handleCloseViewer}
 				on:description-focus-in={disableKeyDownEvent}
 				on:description-focus-out={enableKeyDownEvent}

+ 1 - 7
web/src/lib/components/assets/thumbnail/thumbnail.svelte

@@ -39,13 +39,7 @@
 			return [thumbnailWidth, thumbnailHeight];
 		}
 
-		if (asset.exifInfo?.orientation === 'Rotate 90 CW') {
-			return [176, 235];
-		} else if (asset.exifInfo?.orientation === 'Horizontal (normal)') {
-			return [313, 235];
-		} else {
-			return [235, 235];
-		}
+		return [235, 235];
 	})();
 
 	const thumbnailClickedHandler = () => {

+ 2 - 0
web/src/lib/stores/preferences.store.ts

@@ -38,6 +38,8 @@ export const mapSettings = persisted<MapSettings>('map-settings', {
 
 export const videoViewerVolume = persisted<number>('video-viewer-volume', 1, {});
 
+export const isShowDetail = persisted<boolean>('info-opened', false, {});
+
 export interface AlbumViewSettings {
 	sortBy: string;
 }

+ 2 - 31
web/src/lib/utils/asset-utils.spec.ts

@@ -98,36 +98,8 @@ describe('get file mime type', () => {
 		{ mimetype: 'image/x-sony-arw', extension: 'arw' },
 		{ mimetype: 'image/x-sony-sr2', extension: 'sr2' },
 		{ mimetype: 'image/x-sony-srf', extension: 'srf' },
-		/*** The following MIME types are allowed for upload but not returned by getFileMimeType() ***
-		{ mimetype: 'image/dng', extension: 'dng' },
-		{ mimetype: 'image/ari', extension: 'ari' },
-		{ mimetype: 'image/cr2', extension: 'cr2' },
-		{ mimetype: 'image/cr3', extension: 'cr3' },
-		{ mimetype: 'image/crw', extension: 'crw' },
-		{ mimetype: 'image/erf', extension: 'erf' },
-		{ mimetype: 'image/raf', extension: 'raf' },
-		{ mimetype: 'image/3fr', extension: '3fr' },
-		{ mimetype: 'image/fff', extension: 'fff' },
-		{ mimetype: 'image/dcr', extension: 'dcr' },
-		{ mimetype: 'image/k25', extension: 'k25' },
-		{ mimetype: 'image/kdc', extension: 'kdc' },
-		{ mimetype: 'image/rwl', extension: 'rwl' },
-		{ mimetype: 'image/mrw', extension: 'mrw' },
-		{ mimetype: 'image/nef', extension: 'nef' },
-		{ mimetype: 'image/orf', extension: 'orf' },
-		{ mimetype: 'image/ori', extension: 'ori' },
-		{ mimetype: 'image/raw', extension: 'raw' },
-		{ mimetype: 'image/pef', extension: 'pef' },
-		{ mimetype: 'image/cin', extension: 'cin' },
-		{ mimetype: 'image/cap', extension: 'cap' },
-		{ mimetype: 'image/iiq', extension: 'iiq' },
-		{ mimetype: 'image/srw', extension: 'srw' },
-		{ mimetype: 'image/x3f', extension: 'x3f' },
-		{ mimetype: 'image/arw', extension: 'arw' },
-		{ mimetype: 'image/sr2', extension: 'sr2' },
-		{ mimetype: 'image/srf', extension: 'srf' },
-**/
 		{ mimetype: 'video/3gpp', extension: '3gp' },
+		{ mimetype: 'video/avi', extension: 'avi' },
 		{ mimetype: 'video/mp2t', extension: 'm2ts' },
 		{ mimetype: 'video/mp2t', extension: 'mts' },
 		{ mimetype: 'video/mp4', extension: 'mp4' },
@@ -136,8 +108,7 @@ describe('get file mime type', () => {
 		{ mimetype: 'video/webm', extension: 'webm' },
 		{ mimetype: 'video/x-flv', extension: 'flv' },
 		{ mimetype: 'video/x-matroska', extension: 'mkv' },
-		{ mimetype: 'video/x-ms-wmv', extension: 'wmv' },
-		{ mimetype: 'video/x-msvideo', extension: 'avi' }
+		{ mimetype: 'video/x-ms-wmv', extension: 'wmv' }
 	]) {
 		it(`returns the mime type for ${extension}`, () => {
 			expect(getFileMimeType({ name: `filename.${extension}` } as File)).toEqual(mimetype);

+ 3 - 2
web/src/lib/utils/asset-utils.ts

@@ -130,7 +130,7 @@ export function getFileMimeType(file: File): string {
 		'3gp': 'video/3gpp',
 		ari: 'image/x-arriflex-ari',
 		arw: 'image/x-sony-arw',
-		avi: 'video/x-msvideo',
+		avi: 'video/avi',
 		avif: 'image/avif',
 		cap: 'image/x-phaseone-cap',
 		cin: 'image/x-phantom-cin',
@@ -189,7 +189,8 @@ export function getAssetRatio(asset: AssetResponseDto) {
 	let width = asset.exifInfo?.exifImageWidth || 235;
 	const orientation = Number(asset.exifInfo?.orientation);
 	if (orientation) {
-		if (orientation == 6 || orientation == -90) {
+		// 6 - Rotate 90 CW, 8 - Rotate 270 CW
+		if (orientation == 6 || orientation == 8) {
 			[width, height] = [height, width];
 		}
 	}

+ 8 - 1
web/src/routes/(user)/search/+page.svelte

@@ -43,7 +43,14 @@
 		}
 	});
 
-	$: term = $page.url.searchParams.get('q') || data.term || '';
+	$: term = (() => {
+		let term = $page.url.searchParams.get('q') || data.term || '';
+		const isMetadataSearch = $page.url.searchParams.get('clip') === 'false';
+		if (isMetadataSearch && term !== '') {
+			term = `m:${term}`;
+		}
+		return term;
+	})();
 
 	let selectedAssets: Set<AssetResponseDto> = new Set();
 	$: isMultiSelectionMode = selectedAssets.size > 0;