浏览代码

feat(web/server) Add more options to public shared link (#1348)

* Added migration files

* Added logic for shared album level

* Added permission for EXIF

* Update shared link response dto

* Added condition to show download button

* Create and edit link with new parameter:

* Remove deadcode

* PR feedback

* More refactor

* Move logic of allow original file to service

* Simplify

* Wording
Alex 2 年之前
父节点
当前提交
b07891089f
共有 41 个文件被更改,包括 520 次插入73 次删除
  1. 1 1
      docs/docs/developer/setup.md
  2. 2 0
      mobile/openapi/doc/CreateAlbumShareLinkDto.md
  3. 2 0
      mobile/openapi/doc/CreateAssetsShareLinkDto.md
  4. 2 0
      mobile/openapi/doc/EditSharedLinkDto.md
  5. 2 0
      mobile/openapi/doc/SharedLinkResponseDto.md
  6. 35 1
      mobile/openapi/lib/model/create_album_share_link_dto.dart
  7. 35 1
      mobile/openapi/lib/model/create_assets_share_link_dto.dart
  8. 35 1
      mobile/openapi/lib/model/edit_shared_link_dto.dart
  9. 19 3
      mobile/openapi/lib/model/shared_link_response_dto.dart
  10. 10 0
      mobile/openapi/test/create_album_share_link_dto_test.dart
  11. 10 0
      mobile/openapi/test/create_assets_share_link_dto_test.dart
  12. 10 0
      mobile/openapi/test/edit_shared_link_dto_test.dart
  13. 10 0
      mobile/openapi/test/shared_link_response_dto_test.dart
  14. 2 0
      server/apps/immich/src/api-v1/album/album.controller.ts
  15. 8 2
      server/apps/immich/src/api-v1/album/album.service.ts
  16. 8 0
      server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts
  17. 10 4
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  18. 31 8
      server/apps/immich/src/api-v1/asset/asset.service.ts
  19. 8 0
      server/apps/immich/src/api-v1/asset/dto/create-asset-shared-link.dto.ts
  20. 23 0
      server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts
  21. 2 0
      server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts
  22. 6 0
      server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts
  23. 28 2
      server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts
  24. 1 1
      server/apps/immich/src/api-v1/share/share.controller.ts
  25. 12 1
      server/apps/immich/src/api-v1/share/share.core.ts
  26. 19 7
      server/apps/immich/src/api-v1/share/share.service.ts
  27. 0 1
      server/apps/microservices/src/processors/metadata-extraction.processor.ts
  28. 28 2
      server/immich-openapi-specs.json
  29. 2 0
      server/libs/domain/src/auth/dto/auth-user.dto.ts
  30. 7 1
      server/libs/infra/src/db/entities/shared-link.entity.ts
  31. 15 0
      server/libs/infra/src/db/migrations/1673907194740-AddMorePermissionToSharedLink.ts
  32. 48 0
      web/src/api/open-api/api.ts
  33. 9 10
      web/src/lib/components/album-page/album-viewer.svelte
  34. 9 5
      web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
  35. 11 1
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  36. 8 6
      web/src/lib/components/share-page/individual-shared-viewer.svelte
  37. 1 1
      web/src/lib/components/shared-components/base-modal.svelte
  38. 27 7
      web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte
  39. 1 1
      web/src/lib/components/shared-components/dropdown-button.svelte
  40. 5 4
      web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte
  41. 18 2
      web/src/lib/components/sharedlinks-page/shared-link-card.svelte

+ 1 - 1
docs/docs/developer/setup.md

@@ -99,7 +99,7 @@ After making any changes in the `server/libs/database/src/entities`, a database
 2. Run
 
 ```bash
-npm run typeorm -- migration:generate ./libs/database/src/<migration-name> -d libs/database/src/config/database.config.ts
+npm run typeorm -- migration:generate ./libs/infra/src/db/<migration-name> -d ./libs/infra/src/db/config/database.config.ts
 ```
 
 3. Check if the migration file makes sense.

+ 2 - 0
mobile/openapi/doc/CreateAlbumShareLinkDto.md

@@ -11,6 +11,8 @@ Name | Type | Description | Notes
 **albumId** | **String** |  | 
 **expiredAt** | **String** |  | [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)

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

@@ -11,6 +11,8 @@ Name | Type | Description | Notes
 **assetIds** | **List<String>** |  | [default to const []]
 **expiredAt** | **String** |  | [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)

+ 2 - 0
mobile/openapi/doc/EditSharedLinkDto.md

@@ -11,6 +11,8 @@ Name | Type | Description | Notes
 **description** | **String** |  | [optional] 
 **expiredAt** | **String** |  | [optional] 
 **allowUpload** | **bool** |  | [optional] 
+**allowDownload** | **bool** |  | [optional] 
+**showExif** | **bool** |  | [optional] 
 **isEditExpireTime** | **bool** |  | [optional] 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

+ 2 - 0
mobile/openapi/doc/SharedLinkResponseDto.md

@@ -18,6 +18,8 @@ Name | Type | Description | Notes
 **assets** | [**List<AssetResponseDto>**](AssetResponseDto.md) |  | [default to const []]
 **album** | [**AlbumResponseDto**](AlbumResponseDto.md) |  | [optional] 
 **allowUpload** | **bool** |  | 
+**allowDownload** | **bool** |  | 
+**showExif** | **bool** |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
 

+ 35 - 1
mobile/openapi/lib/model/create_album_share_link_dto.dart

@@ -16,6 +16,8 @@ class CreateAlbumShareLinkDto {
     required this.albumId,
     this.expiredAt,
     this.allowUpload,
+    this.allowDownload,
+    this.showExif,
     this.description,
   });
 
@@ -37,6 +39,22 @@ class CreateAlbumShareLinkDto {
   ///
   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
@@ -50,6 +68,8 @@ class CreateAlbumShareLinkDto {
      other.albumId == albumId &&
      other.expiredAt == expiredAt &&
      other.allowUpload == allowUpload &&
+     other.allowDownload == allowDownload &&
+     other.showExif == showExif &&
      other.description == description;
 
   @override
@@ -58,10 +78,12 @@ class CreateAlbumShareLinkDto {
     (albumId.hashCode) +
     (expiredAt == null ? 0 : expiredAt!.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, expiredAt=$expiredAt, allowUpload=$allowUpload, description=$description]';
+  String toString() => 'CreateAlbumShareLinkDto[albumId=$albumId, expiredAt=$expiredAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -76,6 +98,16 @@ class CreateAlbumShareLinkDto {
     } 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 {
@@ -106,6 +138,8 @@ class CreateAlbumShareLinkDto {
         albumId: mapValueOfType<String>(json, r'albumId')!,
         expiredAt: mapValueOfType<String>(json, r'expiredAt'),
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
+        allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
+        showExif: mapValueOfType<bool>(json, r'showExif'),
         description: mapValueOfType<String>(json, r'description'),
       );
     }

+ 35 - 1
mobile/openapi/lib/model/create_assets_share_link_dto.dart

@@ -16,6 +16,8 @@ class CreateAssetsShareLinkDto {
     this.assetIds = const [],
     this.expiredAt,
     this.allowUpload,
+    this.allowDownload,
+    this.showExif,
     this.description,
   });
 
@@ -37,6 +39,22 @@ class CreateAssetsShareLinkDto {
   ///
   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
@@ -50,6 +68,8 @@ class CreateAssetsShareLinkDto {
      other.assetIds == assetIds &&
      other.expiredAt == expiredAt &&
      other.allowUpload == allowUpload &&
+     other.allowDownload == allowDownload &&
+     other.showExif == showExif &&
      other.description == description;
 
   @override
@@ -58,10 +78,12 @@ class CreateAssetsShareLinkDto {
     (assetIds.hashCode) +
     (expiredAt == null ? 0 : expiredAt!.hashCode) +
     (allowUpload == null ? 0 : allowUpload!.hashCode) +
+    (allowDownload == null ? 0 : allowDownload!.hashCode) +
+    (showExif == null ? 0 : showExif!.hashCode) +
     (description == null ? 0 : description!.hashCode);
 
   @override
-  String toString() => 'CreateAssetsShareLinkDto[assetIds=$assetIds, expiredAt=$expiredAt, allowUpload=$allowUpload, description=$description]';
+  String toString() => 'CreateAssetsShareLinkDto[assetIds=$assetIds, expiredAt=$expiredAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -76,6 +98,16 @@ class CreateAssetsShareLinkDto {
     } 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 {
@@ -108,6 +140,8 @@ class CreateAssetsShareLinkDto {
             : const [],
         expiredAt: mapValueOfType<String>(json, r'expiredAt'),
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
+        allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
+        showExif: mapValueOfType<bool>(json, r'showExif'),
         description: mapValueOfType<String>(json, r'description'),
       );
     }

+ 35 - 1
mobile/openapi/lib/model/edit_shared_link_dto.dart

@@ -16,6 +16,8 @@ class EditSharedLinkDto {
     this.description,
     this.expiredAt,
     this.allowUpload,
+    this.allowDownload,
+    this.showExif,
     this.isEditExpireTime,
   });
 
@@ -43,6 +45,22 @@ class EditSharedLinkDto {
   ///
   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
@@ -56,6 +74,8 @@ class EditSharedLinkDto {
      other.description == description &&
      other.expiredAt == expiredAt &&
      other.allowUpload == allowUpload &&
+     other.allowDownload == allowDownload &&
+     other.showExif == showExif &&
      other.isEditExpireTime == isEditExpireTime;
 
   @override
@@ -64,10 +84,12 @@ class EditSharedLinkDto {
     (description == null ? 0 : description!.hashCode) +
     (expiredAt == null ? 0 : expiredAt!.hashCode) +
     (allowUpload == null ? 0 : allowUpload!.hashCode) +
+    (allowDownload == null ? 0 : allowDownload!.hashCode) +
+    (showExif == null ? 0 : showExif!.hashCode) +
     (isEditExpireTime == null ? 0 : isEditExpireTime!.hashCode);
 
   @override
-  String toString() => 'EditSharedLinkDto[description=$description, expiredAt=$expiredAt, allowUpload=$allowUpload, isEditExpireTime=$isEditExpireTime]';
+  String toString() => 'EditSharedLinkDto[description=$description, expiredAt=$expiredAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, isEditExpireTime=$isEditExpireTime]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -86,6 +108,16 @@ class EditSharedLinkDto {
     } 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.isEditExpireTime != null) {
       json[r'isEditExpireTime'] = this.isEditExpireTime;
     } else {
@@ -116,6 +148,8 @@ class EditSharedLinkDto {
         description: mapValueOfType<String>(json, r'description'),
         expiredAt: mapValueOfType<String>(json, r'expiredAt'),
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
+        allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
+        showExif: mapValueOfType<bool>(json, r'showExif'),
         isEditExpireTime: mapValueOfType<bool>(json, r'isEditExpireTime'),
       );
     }

+ 19 - 3
mobile/openapi/lib/model/shared_link_response_dto.dart

@@ -23,6 +23,8 @@ class SharedLinkResponseDto {
     this.assets = const [],
     this.album,
     required this.allowUpload,
+    required this.allowDownload,
+    required this.showExif,
   });
 
   SharedLinkType type;
@@ -57,6 +59,10 @@ class SharedLinkResponseDto {
 
   bool allowUpload;
 
+  bool allowDownload;
+
+  bool showExif;
+
   @override
   bool operator ==(Object other) => identical(this, other) || other is SharedLinkResponseDto &&
      other.type == type &&
@@ -68,7 +74,9 @@ class SharedLinkResponseDto {
      other.expiresAt == expiresAt &&
      other.assets == assets &&
      other.album == album &&
-     other.allowUpload == allowUpload;
+     other.allowUpload == allowUpload &&
+     other.allowDownload == allowDownload &&
+     other.showExif == showExif;
 
   @override
   int get hashCode =>
@@ -82,10 +90,12 @@ class SharedLinkResponseDto {
     (expiresAt == null ? 0 : expiresAt!.hashCode) +
     (assets.hashCode) +
     (album == null ? 0 : album!.hashCode) +
-    (allowUpload.hashCode);
+    (allowUpload.hashCode) +
+    (allowDownload.hashCode) +
+    (showExif.hashCode);
 
   @override
-  String toString() => 'SharedLinkResponseDto[type=$type, id=$id, description=$description, userId=$userId, key=$key, createdAt=$createdAt, expiresAt=$expiresAt, assets=$assets, album=$album, allowUpload=$allowUpload]';
+  String toString() => 'SharedLinkResponseDto[type=$type, id=$id, description=$description, userId=$userId, key=$key, createdAt=$createdAt, expiresAt=$expiresAt, assets=$assets, album=$album, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -111,6 +121,8 @@ class SharedLinkResponseDto {
       // json[r'album'] = null;
     }
       json[r'allowUpload'] = this.allowUpload;
+      json[r'allowDownload'] = this.allowDownload;
+      json[r'showExif'] = this.showExif;
     return json;
   }
 
@@ -143,6 +155,8 @@ class SharedLinkResponseDto {
         assets: AssetResponseDto.listFromJson(json[r'assets'])!,
         album: AlbumResponseDto.fromJson(json[r'album']),
         allowUpload: mapValueOfType<bool>(json, r'allowUpload')!,
+        allowDownload: mapValueOfType<bool>(json, r'allowDownload')!,
+        showExif: mapValueOfType<bool>(json, r'showExif')!,
       );
     }
     return null;
@@ -200,6 +214,8 @@ class SharedLinkResponseDto {
     'expiresAt',
     'assets',
     'allowUpload',
+    'allowDownload',
+    'showExif',
   };
 }
 

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

@@ -31,6 +31,16 @@ void main() {
       // 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

+ 10 - 0
mobile/openapi/test/create_assets_share_link_dto_test.dart

@@ -31,6 +31,16 @@ void main() {
       // 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

+ 10 - 0
mobile/openapi/test/edit_shared_link_dto_test.dart

@@ -31,6 +31,16 @@ void main() {
       // TODO
     });
 
+    // bool allowDownload
+    test('to test the property `allowDownload`', () async {
+      // TODO
+    });
+
+    // bool showExif
+    test('to test the property `showExif`', () async {
+      // TODO
+    });
+
     // bool isEditExpireTime
     test('to test the property `isEditExpireTime`', () async {
       // TODO

+ 10 - 0
mobile/openapi/test/shared_link_response_dto_test.dart

@@ -66,6 +66,16 @@ void main() {
       // TODO
     });
 
+    // bool allowDownload
+    test('to test the property `allowDownload`', () async {
+      // TODO
+    });
+
+    // bool showExif
+    test('to test the property `showExif`', () async {
+      // TODO
+    });
+
 
   });
 

+ 2 - 0
server/apps/immich/src/api-v1/album/album.controller.ts

@@ -140,6 +140,8 @@ export class AlbumController {
     @Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
     @Response({ passthrough: true }) res: Res,
   ): Promise<any> {
+    this.albumService.checkDownloadAccess(authUser);
+
     const { stream, fileName, fileSize, fileCount, complete } = await this.albumService.downloadArchive(
       authUser,
       albumId,

+ 8 - 2
server/apps/immich/src/api-v1/album/album.service.ts

@@ -15,7 +15,7 @@ import { DownloadService } from '../../modules/download/download.service';
 import { DownloadDto } from '../asset/dto/download-library.dto';
 import { ShareCore } from '../share/share.core';
 import { ISharedLinkRepository } from '../share/shared-link.repository';
-import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
+import { mapSharedLink, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
 import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
 import _ from 'lodash';
 
@@ -210,8 +210,14 @@ export class AlbumService {
       album: album,
       assets: [],
       description: dto.description,
+      allowDownload: dto.allowDownload,
+      showExif: dto.showExif,
     });
 
-    return mapSharedLinkToResponseDto(sharedLink);
+    return mapSharedLink(sharedLink);
+  }
+
+  checkDownloadAccess(authUser: AuthUserDto) {
+    this.shareCore.checkDownloadAccess(authUser);
   }
 }

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

@@ -13,6 +13,14 @@ export class CreateAlbumShareLinkDto {
   @IsOptional()
   allowUpload?: boolean;
 
+  @IsBoolean()
+  @IsOptional()
+  allowDownload?: boolean;
+
+  @IsBoolean()
+  @IsOptional()
+  showExif?: boolean;
+
   @IsString()
   @IsOptional()
   description?: string;

+ 10 - 4
server/apps/immich/src/api-v1/asset/asset.controller.ts

@@ -97,6 +97,7 @@ export class AssetController {
     @Query(new ValidationPipe({ transform: true })) query: ServeFileDto,
     @Param('assetId') assetId: string,
   ): Promise<any> {
+    this.assetService.checkDownloadAccess(authUser);
     await this.assetService.checkAssetsAccess(authUser, [assetId]);
     return this.assetService.downloadFile(query, assetId, res);
   }
@@ -108,6 +109,7 @@ export class AssetController {
     @Response({ passthrough: true }) res: Res,
     @Body(new ValidationPipe()) dto: DownloadFilesDto,
   ): Promise<any> {
+    this.assetService.checkDownloadAccess(authUser);
     await this.assetService.checkAssetsAccess(authUser, [...dto.assetIds]);
     const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadFiles(dto);
     res.attachment(fileName);
@@ -117,6 +119,9 @@ export class AssetController {
     return stream;
   }
 
+  /**
+   * Current this is not used in any UI element
+   */
   @Authenticated({ isShared: true })
   @Get('/download-library')
   async downloadLibrary(
@@ -124,6 +129,7 @@ export class AssetController {
     @Query(new ValidationPipe({ transform: true })) dto: DownloadDto,
     @Response({ passthrough: true }) res: Res,
   ): Promise<any> {
+    this.assetService.checkDownloadAccess(authUser);
     const { stream, fileName, fileSize, fileCount, complete } = await this.assetService.downloadLibrary(authUser, dto);
     res.attachment(fileName);
     res.setHeader(IMMICH_CONTENT_LENGTH_HINT, fileSize);
@@ -143,7 +149,7 @@ export class AssetController {
     @Param('assetId') assetId: string,
   ): Promise<any> {
     await this.assetService.checkAssetsAccess(authUser, [assetId]);
-    return this.assetService.serveFile(assetId, query, res, headers);
+    return this.assetService.serveFile(authUser, assetId, query, res, headers);
   }
 
   @Authenticated({ isShared: true })
@@ -246,7 +252,7 @@ export class AssetController {
     @Param('assetId') assetId: string,
   ): Promise<AssetResponseDto> {
     await this.assetService.checkAssetsAccess(authUser, [assetId]);
-    return await this.assetService.getAssetById(assetId);
+    return await this.assetService.getAssetById(authUser, assetId);
   }
 
   /**
@@ -274,14 +280,14 @@ export class AssetController {
     const deleteAssetList: AssetResponseDto[] = [];
 
     for (const id of assetIds.ids) {
-      const assets = await this.assetService.getAssetById(id);
+      const assets = await this.assetService.getAssetById(authUser, id);
       if (!assets) {
         continue;
       }
       deleteAssetList.push(assets);
 
       if (assets.livePhotoVideoId) {
-        const livePhotoVideo = await this.assetService.getAssetById(assets.livePhotoVideoId);
+        const livePhotoVideo = await this.assetService.getAssetById(authUser, assets.livePhotoVideoId);
         if (livePhotoVideo) {
           deleteAssetList.push(livePhotoVideo);
           assetIds.ids = [...assetIds.ids, livePhotoVideo.id];

+ 31 - 8
server/apps/immich/src/api-v1/asset/asset.service.ts

@@ -23,7 +23,7 @@ import { SearchAssetDto } from './dto/search-asset.dto';
 import fs from 'fs/promises';
 import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
-import { AssetResponseDto, mapAsset } from './response-dto/asset-response.dto';
+import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from './response-dto/asset-response.dto';
 import { CreateAssetDto } from './dto/create-asset.dto';
 import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
 import { GetAssetThumbnailDto, GetAssetThumbnailFormatEnum } from './dto/get-asset-thumbnail.dto';
@@ -52,7 +52,7 @@ import { ShareCore } from '../share/share.core';
 import { ISharedLinkRepository } from '../share/shared-link.repository';
 import { DownloadFilesDto } from './dto/download-files.dto';
 import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
-import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
+import { mapSharedLink, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
 import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
 
 const fileInfo = promisify(stat);
@@ -215,10 +215,15 @@ export class AssetService {
     return assets.map((asset) => mapAsset(asset));
   }
 
-  public async getAssetById(assetId: string): Promise<AssetResponseDto> {
+  public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
+    const allowExif = this.getExifPermission(authUser);
     const asset = await this._assetRepository.getById(assetId);
 
-    return mapAsset(asset);
+    if (allowExif) {
+      return mapAsset(asset);
+    } else {
+      return mapAssetWithoutExif(asset);
+    }
   }
 
   public async updateAsset(authUser: AuthUserDto, assetId: string, dto: UpdateAssetDto): Promise<AssetResponseDto> {
@@ -356,7 +361,15 @@ export class AssetService {
     }
   }
 
-  public async serveFile(assetId: string, query: ServeFileDto, res: Res, headers: Record<string, string>) {
+  public async serveFile(
+    authUser: AuthUserDto,
+    assetId: string,
+    query: ServeFileDto,
+    res: Res,
+    headers: Record<string, string>,
+  ) {
+    const allowOriginalFile = !authUser.isPublicUser || authUser.isAllowDownload;
+
     let fileReadStream: ReadStream;
     const asset = await this._assetRepository.getById(assetId);
 
@@ -390,7 +403,7 @@ export class AssetService {
         /**
          * Serve thumbnail image for both web and mobile app
          */
-        if (!query.isThumb) {
+        if (!query.isThumb && allowOriginalFile) {
           res.set({
             'Content-Type': asset.mimeType,
           });
@@ -676,6 +689,10 @@ export class AssetService {
     }
   }
 
+  checkDownloadAccess(authUser: AuthUserDto) {
+    this.shareCore.checkDownloadAccess(authUser);
+  }
+
   async createAssetsSharedLink(authUser: AuthUserDto, dto: CreateAssetsShareLinkDto): Promise<SharedLinkResponseDto> {
     const assets = [];
 
@@ -691,9 +708,11 @@ export class AssetService {
       allowUpload: dto.allowUpload,
       assets: assets,
       description: dto.description,
+      allowDownload: dto.allowDownload,
+      showExif: dto.showExif,
     });
 
-    return mapSharedLinkToResponseDto(sharedLink);
+    return mapSharedLink(sharedLink);
   }
 
   async updateAssetsInSharedLink(
@@ -709,7 +728,11 @@ export class AssetService {
     }
 
     const updatedLink = await this.shareCore.updateAssetsInSharedLink(authUser.sharedLinkId, assets);
-    return mapSharedLinkToResponseDto(updatedLink);
+    return mapSharedLink(updatedLink);
+  }
+
+  getExifPermission(authUser: AuthUserDto) {
+    return !authUser.isPublicUser || authUser.isShowExif;
   }
 }
 

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

@@ -25,6 +25,14 @@ export class CreateAssetsShareLinkDto {
   @IsOptional()
   allowUpload?: boolean;
 
+  @IsBoolean()
+  @IsOptional()
+  allowDownload?: boolean;
+
+  @IsBoolean()
+  @IsOptional()
+  showExif?: boolean;
+
   @IsString()
   @IsOptional()
   description?: string;

+ 23 - 0
server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts

@@ -49,3 +49,26 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
     tags: entity.tags?.map(mapTag),
   };
 }
+
+export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
+  return {
+    id: entity.id,
+    deviceAssetId: entity.deviceAssetId,
+    ownerId: entity.userId,
+    deviceId: entity.deviceId,
+    type: entity.type,
+    originalPath: entity.originalPath,
+    resizePath: entity.resizePath,
+    createdAt: entity.createdAt,
+    modifiedAt: entity.modifiedAt,
+    isFavorite: entity.isFavorite,
+    mimeType: entity.mimeType,
+    webpPath: entity.webpPath,
+    encodedVideoPath: entity.encodedVideoPath,
+    duration: entity.duration ?? '0:00:00.00000',
+    exifInfo: undefined,
+    smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
+    livePhotoVideoId: entity.livePhotoVideoId,
+    tags: entity.tags?.map(mapTag),
+  };
+}

+ 2 - 0
server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts

@@ -8,4 +8,6 @@ export class CreateSharedLinkDto {
   assets!: AssetEntity[];
   album?: AlbumEntity;
   allowUpload?: boolean;
+  allowDownload?: boolean;
+  showExif?: boolean;
 }

+ 6 - 0
server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts

@@ -10,6 +10,12 @@ export class EditSharedLinkDto {
   @IsOptional()
   allowUpload?: boolean;
 
+  @IsOptional()
+  allowDownload?: boolean;
+
+  @IsOptional()
+  showExif?: boolean;
+
   @IsNotEmpty()
   isEditExpireTime?: boolean;
 }

+ 28 - 2
server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts

@@ -2,7 +2,7 @@ import { SharedLinkEntity, SharedLinkType } from '@app/infra';
 import { ApiProperty } from '@nestjs/swagger';
 import _ from 'lodash';
 import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album/response-dto/album-response.dto';
-import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
+import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset/response-dto/asset-response.dto';
 
 export class SharedLinkResponseDto {
   id!: string;
@@ -17,9 +17,11 @@ export class SharedLinkResponseDto {
   assets!: AssetResponseDto[];
   album?: AlbumResponseDto;
   allowUpload!: boolean;
+  allowDownload!: boolean;
+  showExif!: boolean;
 }
 
-export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
+export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
   const linkAssets = sharedLink.assets || [];
   const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo);
 
@@ -36,5 +38,29 @@ export function mapSharedLinkToResponseDto(sharedLink: SharedLinkEntity): Shared
     assets: assets.map(mapAsset),
     album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined,
     allowUpload: sharedLink.allowUpload,
+    allowDownload: sharedLink.allowDownload,
+    showExif: sharedLink.showExif,
+  };
+}
+
+export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
+  const linkAssets = sharedLink.assets || [];
+  const albumAssets = (sharedLink?.album?.assets || []).map((albumAsset) => albumAsset.assetInfo);
+
+  const assets = _.uniqBy([...linkAssets, ...albumAssets], (asset) => asset.id);
+
+  return {
+    id: sharedLink.id,
+    description: sharedLink.description,
+    userId: sharedLink.userId,
+    key: sharedLink.key.toString('hex'),
+    type: sharedLink.type,
+    createdAt: sharedLink.createdAt,
+    expiresAt: sharedLink.expiresAt,
+    assets: assets.map(mapAssetWithoutExif),
+    album: sharedLink.album ? mapAlbumExcludeAssetInfo(sharedLink.album) : undefined,
+    allowUpload: sharedLink.allowUpload,
+    allowDownload: sharedLink.allowDownload,
+    showExif: sharedLink.showExif,
   };
 }

+ 1 - 1
server/apps/immich/src/api-v1/share/share.controller.ts

@@ -25,7 +25,7 @@ export class ShareController {
   @Authenticated()
   @Get(':id')
   getSharedLinkById(@Param('id') id: string): Promise<SharedLinkResponseDto> {
-    return this.shareService.getById(id);
+    return this.shareService.getById(id, true);
   }
 
   @Authenticated()

+ 12 - 1
server/apps/immich/src/api-v1/share/share.core.ts

@@ -2,9 +2,10 @@ import { SharedLinkEntity } from '@app/infra';
 import { CreateSharedLinkDto } from './dto/create-shared-link.dto';
 import { ISharedLinkRepository } from './shared-link.repository';
 import crypto from 'node:crypto';
-import { BadRequestException, InternalServerErrorException, Logger } from '@nestjs/common';
+import { BadRequestException, ForbiddenException, InternalServerErrorException, Logger } from '@nestjs/common';
 import { AssetEntity } from '@app/infra';
 import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
+import { AuthUserDto } from '../../decorators/auth-user.decorator';
 
 export class ShareCore {
   readonly logger = new Logger(ShareCore.name);
@@ -24,6 +25,8 @@ export class ShareCore {
       sharedLink.assets = dto.assets;
       sharedLink.album = dto.album;
       sharedLink.allowUpload = dto.allowUpload ?? false;
+      sharedLink.allowDownload = dto.allowDownload ?? true;
+      sharedLink.showExif = dto.showExif ?? true;
 
       return this.sharedLinkRepository.create(sharedLink);
     } catch (error: any) {
@@ -74,6 +77,8 @@ export class ShareCore {
 
     link.description = dto.description ?? link.description;
     link.allowUpload = dto.allowUpload ?? link.allowUpload;
+    link.allowDownload = dto.allowDownload ?? link.allowDownload;
+    link.showExif = dto.showExif ?? link.showExif;
 
     if (dto.isEditExpireTime && dto.expiredAt) {
       link.expiresAt = dto.expiredAt;
@@ -87,4 +92,10 @@ export class ShareCore {
   async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
     return this.sharedLinkRepository.hasAssetAccess(id, assetId);
   }
+
+  checkDownloadAccess(user: AuthUserDto) {
+    if (user.isPublicUser && !user.isAllowDownload) {
+      throw new ForbiddenException();
+    }
+  }
 }

+ 19 - 7
server/apps/immich/src/api-v1/share/share.service.ts

@@ -9,7 +9,7 @@ import {
 import { UserService } from '@app/domain';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
-import { mapSharedLinkToResponseDto, SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
+import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
 import { ShareCore } from './share.core';
 import { ISharedLinkRepository } from './shared-link.repository';
 
@@ -39,6 +39,8 @@ export class ShareService {
             isPublicUser: true,
             sharedLinkId: link.id,
             isAllowUpload: link.allowUpload,
+            isAllowDownload: link.allowDownload,
+            isShowExif: link.showExif,
           };
         }
       }
@@ -48,7 +50,7 @@ export class ShareService {
 
   async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
     const links = await this.shareCore.getSharedLinks(authUser.id);
-    return links.map(mapSharedLinkToResponseDto);
+    return links.map(mapSharedLink);
   }
 
   async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
@@ -56,15 +58,25 @@ export class ShareService {
       throw new ForbiddenException();
     }
 
-    return this.getById(authUser.sharedLinkId);
+    let allowExif = true;
+    if (authUser.isShowExif != undefined) {
+      allowExif = authUser.isShowExif;
+    }
+
+    return this.getById(authUser.sharedLinkId, allowExif);
   }
 
-  async getById(id: string): Promise<SharedLinkResponseDto> {
+  async getById(id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
     const link = await this.shareCore.getSharedLinkById(id);
     if (!link) {
       throw new BadRequestException('Shared link not found');
     }
-    return mapSharedLinkToResponseDto(link);
+
+    if (allowExif) {
+      return mapSharedLink(link);
+    } else {
+      return mapSharedLinkWithNoExif(link);
+    }
   }
 
   async remove(id: string, userId: string): Promise<string> {
@@ -77,11 +89,11 @@ export class ShareService {
     if (!link) {
       throw new BadRequestException('Shared link not found');
     }
-    return mapSharedLinkToResponseDto(link);
+    return mapSharedLink(link);
   }
 
   async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) {
     const link = await this.shareCore.updateSharedLink(id, authUser.id, dto);
-    return mapSharedLinkToResponseDto(link);
+    return mapSharedLink(link);
   }
 }

+ 0 - 1
server/apps/microservices/src/processors/metadata-extraction.processor.ts

@@ -139,7 +139,6 @@ export class MetadataExtractionProcessor {
   async extractExifInfo(job: Job<IExifExtractionProcessor>) {
     try {
       const { asset, fileName }: { asset: AssetEntity; fileName: string } = job.data;
-
       const exifData = await exiftool.read(asset.originalPath).catch((e) => {
         this.logger.warn(`The exifData parsing failed due to: ${e} on file ${asset.originalPath}`);
         return null;

+ 28 - 2
server/immich-openapi-specs.json

@@ -736,7 +736,7 @@
     "/asset/download-library": {
       "get": {
         "operationId": "downloadLibrary",
-        "description": "",
+        "description": "Current this is not used in any UI element",
         "parameters": [
           {
             "name": "skip",
@@ -3786,6 +3786,12 @@
           "allowUpload": {
             "type": "boolean"
           },
+          "allowDownload": {
+            "type": "boolean"
+          },
+          "showExif": {
+            "type": "boolean"
+          },
           "description": {
             "type": "string"
           }
@@ -3887,6 +3893,12 @@
           },
           "allowUpload": {
             "type": "boolean"
+          },
+          "allowDownload": {
+            "type": "boolean"
+          },
+          "showExif": {
+            "type": "boolean"
           }
         },
         "required": [
@@ -3897,7 +3909,9 @@
           "createdAt",
           "expiresAt",
           "assets",
-          "allowUpload"
+          "allowUpload",
+          "allowDownload",
+          "showExif"
         ]
       },
       "UpdateAssetsToSharedLinkDto": {
@@ -3926,6 +3940,12 @@
           "allowUpload": {
             "type": "boolean"
           },
+          "allowDownload": {
+            "type": "boolean"
+          },
+          "showExif": {
+            "type": "boolean"
+          },
           "isEditExpireTime": {
             "type": "boolean"
           }
@@ -4085,6 +4105,12 @@
           "allowUpload": {
             "type": "boolean"
           },
+          "allowDownload": {
+            "type": "boolean"
+          },
+          "showExif": {
+            "type": "boolean"
+          },
           "description": {
             "type": "string"
           }

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

@@ -5,4 +5,6 @@ export class AuthUserDto {
   isPublicUser?: boolean;
   sharedLinkId?: string;
   isAllowUpload?: boolean;
+  isAllowDownload?: boolean;
+  isShowExif?: boolean;
 }

+ 7 - 1
server/libs/infra/src/db/entities/shared-link.entity.ts

@@ -30,6 +30,12 @@ export class SharedLinkEntity {
   @Column({ type: 'boolean', default: false })
   allowUpload!: boolean;
 
+  @Column({ type: 'boolean', default: true })
+  allowDownload!: boolean;
+
+  @Column({ type: 'boolean', default: true })
+  showExif!: boolean;
+
   @ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks)
   assets!: AssetEntity[];
 
@@ -47,4 +53,4 @@ export enum SharedLinkType {
   INDIVIDUAL = 'INDIVIDUAL',
 }
 
-// npm run typeorm -- migration:generate ./libs/database/src/AddSharedLinkTable -d libs/database/src/config/database.config.ts
+// npm run typeorm -- migration:generate ./libs/infra/src/db/AddMorePermissionToSharedLink -d ./libs/infra/src/db/config/database.config.ts

+ 15 - 0
server/libs/infra/src/db/migrations/1673907194740-AddMorePermissionToSharedLink.ts

@@ -0,0 +1,15 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+export class AddMorePermissionToSharedLink1673907194740 implements MigrationInterface {
+  name = 'AddMorePermissionToSharedLink1673907194740';
+
+  public async up(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "shared_links" ADD "allowDownload" boolean NOT NULL DEFAULT true`);
+    await queryRunner.query(`ALTER TABLE "shared_links" ADD "showExif" boolean NOT NULL DEFAULT true`);
+  }
+
+  public async down(queryRunner: QueryRunner): Promise<void> {
+    await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "showExif"`);
+    await queryRunner.query(`ALTER TABLE "shared_links" DROP COLUMN "allowDownload"`);
+  }
+}

+ 48 - 0
web/src/api/open-api/api.ts

@@ -665,6 +665,18 @@ export interface CreateAlbumShareLinkDto {
      * @memberof CreateAlbumShareLinkDto
      */
     'allowUpload'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof CreateAlbumShareLinkDto
+     */
+    'allowDownload'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof CreateAlbumShareLinkDto
+     */
+    'showExif'?: boolean;
     /**
      * 
      * @type {string}
@@ -696,6 +708,18 @@ export interface CreateAssetsShareLinkDto {
      * @memberof CreateAssetsShareLinkDto
      */
     'allowUpload'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof CreateAssetsShareLinkDto
+     */
+    'allowDownload'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof CreateAssetsShareLinkDto
+     */
+    'showExif'?: boolean;
     /**
      * 
      * @type {string}
@@ -987,6 +1011,18 @@ export interface EditSharedLinkDto {
      * @memberof EditSharedLinkDto
      */
     'allowUpload'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof EditSharedLinkDto
+     */
+    'allowDownload'?: boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof EditSharedLinkDto
+     */
+    'showExif'?: boolean;
     /**
      * 
      * @type {boolean}
@@ -1612,6 +1648,18 @@ export interface SharedLinkResponseDto {
      * @memberof SharedLinkResponseDto
      */
     'allowUpload': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof SharedLinkResponseDto
+     */
+    'allowDownload': boolean;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof SharedLinkResponseDto
+     */
+    'showExif': boolean;
 }
 /**
  * 

+ 9 - 10
web/src/lib/components/album-page/album-viewer.svelte

@@ -320,6 +320,7 @@
 				}
 			}
 		} catch (e) {
+			$downloadAssets = {};
 			console.error('Error downloading file ', e);
 			notificationController.show({
 				type: NotificationType.Error,
@@ -460,11 +461,13 @@
 						<CircleIconButton title="Remove album" on:click={removeAlbum} logo={DeleteOutline} />
 					{/if}
 
-					<CircleIconButton
-						title="Download"
-						on:click={() => downloadAlbum()}
-						logo={FolderDownloadOutline}
-					/>
+					{#if !isPublicShared || (isPublicShared && sharedLink?.allowDownload)}
+						<CircleIconButton
+							title="Download"
+							on:click={() => downloadAlbum()}
+							logo={FolderDownloadOutline}
+						/>
+					{/if}
 
 					{#if !isPublicShared}
 						<CircleIconButton
@@ -534,11 +537,7 @@
 		{/if}
 
 		{#if album.assetCount > 0}
-			<GalleryViewer
-				assets={album.assets}
-				key={sharedLink?.key ?? ''}
-				bind:selectedAssets={multiSelectAsset}
-			/>
+			<GalleryViewer assets={album.assets} {sharedLink} bind:selectedAssets={multiSelectAsset} />
 		{:else}
 			<!-- Album is empty - Show asset selectection buttons -->
 			<section id="empty-album" class=" mt-[200px] flex place-content-center place-items-center">

+ 9 - 5
web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte

@@ -22,6 +22,7 @@
 	export let showCopyButton: boolean;
 	export let showMotionPlayButton: boolean;
 	export let isMotionPhotoPlaying = false;
+	export let showDownloadButton: boolean;
 
 	const isOwner = asset.ownerId === $page.data.user?.id;
 
@@ -77,11 +78,14 @@
 				}}
 			/>
 		{/if}
-		<CircleIconButton
-			logo={CloudDownloadOutline}
-			on:click={() => dispatch('download')}
-			title="Download"
-		/>
+
+		{#if showDownloadButton}
+			<CircleIconButton
+				logo={CloudDownloadOutline}
+				on:click={() => dispatch('download')}
+				title="Download"
+			/>
+		{/if}
 		<CircleIconButton
 			logo={InformationOutline}
 			on:click={() => dispatch('showDetail')}

+ 11 - 1
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -10,7 +10,13 @@
 	import { downloadAssets } from '$lib/stores/download';
 	import VideoViewer from './video-viewer.svelte';
 	import AlbumSelectionModal from '../shared-components/album-selection-modal.svelte';
-	import { api, AssetResponseDto, AssetTypeEnum, AlbumResponseDto } from '@api';
+	import {
+		api,
+		AssetResponseDto,
+		AssetTypeEnum,
+		AlbumResponseDto,
+		SharedLinkResponseDto
+	} from '@api';
 	import {
 		notificationController,
 		NotificationType
@@ -22,6 +28,7 @@
 	export let asset: AssetResponseDto;
 	export let publicSharedKey = '';
 	export let showNavigation = true;
+	export let sharedLink: SharedLinkResponseDto | undefined = undefined;
 
 	const dispatch = createEventDispatcher();
 	let halfLeftHover = false;
@@ -31,6 +38,7 @@
 	let isShowAlbumPicker = false;
 	let addToSharedAlbum = true;
 	let shouldPlayMotionPhoto = false;
+	let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : true;
 	const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo.key);
 
 	onMount(async () => {
@@ -166,6 +174,7 @@
 				}, 2000);
 			}
 		} catch (e) {
+			$downloadAssets = {};
 			console.error('Error downloading file ', e);
 			notificationController.show({
 				type: NotificationType.Error,
@@ -247,6 +256,7 @@
 			isMotionPhotoPlaying={shouldPlayMotionPhoto}
 			showCopyButton={asset.type === AssetTypeEnum.Image}
 			showMotionPlayButton={!!asset.livePhotoVideoId}
+			showDownloadButton={shouldShowDownloadButton}
 			on:goBack={closeViewer}
 			on:showDetail={showDetailInfoHandler}
 			on:download={handleDownload}

+ 8 - 6
web/src/lib/components/share-page/individual-shared-viewer.svelte

@@ -136,15 +136,17 @@
 					/>
 				{/if}
 
-				<CircleIconButton
-					title="Download"
-					on:click={() => downloadAssets(true)}
-					logo={FolderDownloadOutline}
-				/>
+				{#if sharedLink?.allowDownload}
+					<CircleIconButton
+						title="Download"
+						on:click={() => downloadAssets(true)}
+						logo={FolderDownloadOutline}
+					/>
+				{/if}
 			</svelte:fragment>
 		</ControlAppBar>
 	{/if}
 	<section class="flex flex-col my-[160px] px-6 sm:px-12 md:px-24 lg:px-40">
-		<GalleryViewer {assets} key={sharedLink.key} bind:selectedAssets />
+		<GalleryViewer {assets} {sharedLink} bind:selectedAssets />
 	</section>
 </section>

+ 1 - 1
web/src/lib/components/shared-components/base-modal.svelte

@@ -36,7 +36,7 @@
 	<div
 		use:clickOutside
 		on:outclick={() => dispatch('close')}
-		class="bg-immich-bg dark:bg-immich-dark-gray dark:text-immich-dark-fg w-[450px] min-h-[200px] max-h-[500px] rounded-lg shadow-md"
+		class="bg-immich-bg dark:bg-immich-dark-gray dark:text-immich-dark-fg w-[450px] min-h-[200px] max-h-[600px] rounded-lg shadow-md"
 	>
 		<div class="flex justify-between place-items-center px-5 py-3">
 			<div>

+ 27 - 7
web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte

@@ -29,6 +29,8 @@
 	let sharedLink = '';
 	let description = '';
 	let shouldChangeExpirationTime = false;
+	let isAllowDownload = true;
+	let shouldShowExif = true;
 	const dispatch = createEventDispatcher();
 
 	const expiredDateOption: ImmichDropDownOption = {
@@ -42,6 +44,8 @@
 				description = editingLink.description;
 			}
 			isAllowUpload = editingLink.allowUpload;
+			isAllowDownload = editingLink.allowDownload;
+			shouldShowExif = editingLink.showExif;
 		}
 	});
 
@@ -58,7 +62,9 @@
 					albumId: album.id,
 					expiredAt: expirationDate,
 					allowUpload: isAllowUpload,
-					description: description
+					description: description,
+					allowDownload: isAllowDownload,
+					showExif: shouldShowExif
 				});
 				buildSharedLink(data);
 			} else {
@@ -66,7 +72,9 @@
 					assetIds: sharedAssets.map((a) => a.id),
 					expiredAt: expirationDate,
 					allowUpload: isAllowUpload,
-					description: description
+					description: description,
+					allowDownload: isAllowDownload,
+					showExif: shouldShowExif
 				});
 				buildSharedLink(data);
 			}
@@ -132,7 +140,9 @@
 					description: description,
 					expiredAt: expirationDate,
 					allowUpload: isAllowUpload,
-					isEditExpireTime: shouldChangeExpirationTime
+					isEditExpireTime: shouldChangeExpirationTime,
+					allowDownload: isAllowDownload,
+					showExif: shouldShowExif
 				});
 
 				notificationController.show({
@@ -185,12 +195,12 @@
 			{/if}
 		{/if}
 
-		<div class="mt-6 mb-2">
+		<div class="mt-4 mb-2">
 			<p class="text-xs">LINK OPTIONS</p>
 		</div>
 		<div class="p-4 bg-gray-100 dark:bg-black/40 rounded-lg">
 			<div class="flex flex-col">
-				<div class="mb-4">
+				<div class="mb-2">
 					<SettingInputField
 						inputType={SettingInputFieldType.TEXT}
 						label="Description"
@@ -198,9 +208,19 @@
 					/>
 				</div>
 
-				<SettingSwitch bind:checked={isAllowUpload} title={'Allow public user to upload'} />
+				<div class="my-3">
+					<SettingSwitch bind:checked={shouldShowExif} title={'Show metadata'} />
+				</div>
+
+				<div class="my-3">
+					<SettingSwitch bind:checked={isAllowDownload} title={'Allow public user to download'} />
+				</div>
+
+				<div class="my-3">
+					<SettingSwitch bind:checked={isAllowUpload} title={'Allow public user to upload'} />
+				</div>
 
-				<div class="text-sm mt-4">
+				<div class="text-sm">
 					{#if editingLink}
 						<p class="my-2 immich-form-label">
 							<SettingSwitch

+ 1 - 1
web/src/lib/components/shared-components/dropdown-button.svelte

@@ -25,7 +25,7 @@
 		{disabled}
 		on:click={toggle}
 		aria-expanded={isOpen}
-		class="bg-gray-200 w-full flex p-2 rounded-lg dark:bg-gray-600 place-items-center justify-between disabled:cursor-not-allowed dark:disabled:bg-gray-300 disabled:bg-gray-600 "
+		class="bg-gray-200 w-full flex p-2 rounded-lg dark:bg-gray-600 place-items-center justify-between disabled:cursor-not-allowed dark:disabled:bg-gray-300 disabled:bg-gray-600"
 	>
 		<div>
 			{selected}

+ 5 - 4
web/src/lib/components/shared-components/gallery-viewer/gallery-viewer.svelte

@@ -1,13 +1,13 @@
 <script lang="ts">
 	import { page } from '$app/stores';
 	import { handleError } from '$lib/utils/handle-error';
-	import { AssetResponseDto, ThumbnailFormat } from '@api';
+	import { AssetResponseDto, SharedLinkResponseDto, ThumbnailFormat } from '@api';
 
 	import AssetViewer from '../../asset-viewer/asset-viewer.svelte';
 	import ImmichThumbnail from '../../shared-components/immich-thumbnail.svelte';
 
 	export let assets: AssetResponseDto[];
-	export let key: string;
+	export let sharedLink: SharedLinkResponseDto | undefined = undefined;
 	export let selectedAssets: Set<AssetResponseDto> = new Set();
 
 	let isShowAssetViewer = false;
@@ -96,7 +96,7 @@
 			<ImmichThumbnail
 				{asset}
 				{thumbnailSize}
-				publicSharedKey={key}
+				publicSharedKey={sharedLink?.key}
 				format={assets.length < 7 ? ThumbnailFormat.Jpeg : ThumbnailFormat.Webp}
 				on:click={(e) => (isMultiSelectionMode ? selectAssetHandler(e) : viewAssetHandler(e))}
 				on:select={selectAssetHandler}
@@ -110,7 +110,8 @@
 {#if isShowAssetViewer}
 	<AssetViewer
 		asset={selectedAsset}
-		publicSharedKey={key}
+		publicSharedKey={sharedLink?.key}
+		{sharedLink}
 		on:navigate-previous={navigateAssetBackward}
 		on:navigate-next={navigateAssetForward}
 		on:close={closeViewer}

+ 18 - 2
web/src/lib/components/sharedlinks-page/shared-link-card.svelte

@@ -122,12 +122,28 @@
 			</div>
 		</div>
 
-		<div class="info-bottom">
+		<div class="info-bottom flex gap-4">
 			{#if link.allowUpload}
+				<div
+					class="text-xs px-2 py-1 bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray flex place-items-center place-content-center rounded-full w-[80px]"
+				>
+					Upload
+				</div>
+			{/if}
+
+			{#if link.allowDownload}
 				<div
 					class="text-xs px-2 py-1 bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray flex place-items-center place-content-center rounded-full w-[100px]"
 				>
-					Allow upload
+					Download
+				</div>
+			{/if}
+
+			{#if link.showExif}
+				<div
+					class="text-xs px-2 py-1 bg-immich-primary dark:bg-immich-dark-primary text-white dark:text-immich-dark-gray flex place-items-center place-content-center rounded-full w-[60px]"
+				>
+					EXIF
 				</div>
 			{/if}
 		</div>