Browse Source

fix(server,web): correctly remove metadata from shared links (#4464)

* wip: strip metadata

* fix: authenticate time buckets

* hide detail panel

* fix tests

* fix lint

* add e2e tests

* chore: open api

* fix web compilation error

* feat: test with asset with gps position

* fix: only import fs.promises.cp

* fix: cleanup mapasset

* fix: format

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Jonathan Jogenfors 1 year ago
parent
commit
dadcf49eca
39 changed files with 331 additions and 149 deletions
  1. 10 4
      cli/src/api/open-api/api.ts
  2. 2 1
      mobile/openapi/doc/AssetResponseDto.md
  3. 1 1
      mobile/openapi/doc/SharedLinkCreateDto.md
  4. 1 1
      mobile/openapi/doc/SharedLinkEditDto.md
  5. 1 1
      mobile/openapi/doc/SharedLinkResponseDto.md
  6. 9 2
      mobile/openapi/lib/model/asset_response_dto.dart
  7. 7 7
      mobile/openapi/lib/model/shared_link_create_dto.dart
  8. 9 9
      mobile/openapi/lib/model/shared_link_edit_dto.dart
  9. 8 8
      mobile/openapi/lib/model/shared_link_response_dto.dart
  10. 5 1
      mobile/openapi/test/asset_response_dto_test.dart
  11. 2 2
      mobile/openapi/test/shared_link_create_dto_test.dart
  12. 2 2
      mobile/openapi/test/shared_link_edit_dto_test.dart
  13. 2 2
      mobile/openapi/test/shared_link_response_dto_test.dart
  14. 12 9
      server/immich-openapi-specs.json
  15. 10 2
      server/src/domain/asset/asset.service.ts
  16. 31 19
      server/src/domain/asset/response-dto/asset-response.dto.ts
  17. 12 0
      server/src/domain/asset/response-dto/exif-response.dto.ts
  18. 2 2
      server/src/domain/auth/auth.service.ts
  19. 1 1
      server/src/domain/auth/dto/auth-user.dto.ts
  20. 1 1
      server/src/domain/person/person.service.ts
  21. 1 1
      server/src/domain/search/search.service.ts
  22. 8 7
      server/src/domain/shared-link/shared-link-response.dto.ts
  23. 2 2
      server/src/domain/shared-link/shared-link.dto.ts
  24. 3 3
      server/src/domain/shared-link/shared-link.service.spec.ts
  25. 5 5
      server/src/domain/shared-link/shared-link.service.ts
  26. 1 1
      server/src/domain/tag/tag.service.ts
  27. 1 1
      server/src/immich/api-v1/asset/asset.controller.ts
  28. 19 12
      server/src/immich/api-v1/asset/asset.service.ts
  29. 1 1
      server/src/immich/controllers/asset.controller.ts
  30. 7 0
      server/test/api/shared-link-api.ts
  31. 1 1
      server/test/assets
  32. 98 12
      server/test/e2e/shared-link.e2e-spec.ts
  33. 6 6
      server/test/fixtures/auth.stub.ts
  34. 18 6
      server/test/fixtures/shared-link.stub.ts
  35. 10 4
      web/src/api/open-api/api.ts
  36. 9 1
      web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte
  37. 5 3
      web/src/lib/components/asset-viewer/asset-viewer.svelte
  38. 7 7
      web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte
  39. 1 1
      web/src/lib/components/sharedlinks-page/shared-link-card.svelte

+ 10 - 4
cli/src/api/open-api/api.ts

@@ -640,6 +640,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      */
     'fileModifiedAt': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetResponseDto
+     */
+    'hasMetadata': boolean;
     /**
      * 
      * @type {string}
@@ -749,7 +755,7 @@ export interface AssetResponseDto {
      */
     'tags'?: Array<TagResponseDto>;
     /**
-     * base64 encoded thumbhash
+     * 
      * @type {string}
      * @memberof AssetResponseDto
      */
@@ -2882,7 +2888,7 @@ export interface SharedLinkCreateDto {
      * @type {boolean}
      * @memberof SharedLinkCreateDto
      */
-    'showExif'?: boolean;
+    'showMetadata'?: boolean;
     /**
      * 
      * @type {SharedLinkType}
@@ -2927,7 +2933,7 @@ export interface SharedLinkEditDto {
      * @type {boolean}
      * @memberof SharedLinkEditDto
      */
-    'showExif'?: boolean;
+    'showMetadata'?: boolean;
 }
 /**
  * 
@@ -2994,7 +3000,7 @@ export interface SharedLinkResponseDto {
      * @type {boolean}
      * @memberof SharedLinkResponseDto
      */
-    'showExif': boolean;
+    'showMetadata': boolean;
     /**
      * 
      * @type {SharedLinkType}

+ 2 - 1
mobile/openapi/doc/AssetResponseDto.md

@@ -15,6 +15,7 @@ Name | Type | Description | Notes
 **exifInfo** | [**ExifResponseDto**](ExifResponseDto.md) |  | [optional] 
 **fileCreatedAt** | [**DateTime**](DateTime.md) |  | 
 **fileModifiedAt** | [**DateTime**](DateTime.md) |  | 
+**hasMetadata** | **bool** |  | 
 **id** | **String** |  | 
 **isArchived** | **bool** |  | 
 **isExternal** | **bool** |  | 
@@ -33,7 +34,7 @@ Name | Type | Description | Notes
 **resized** | **bool** |  | 
 **smartInfo** | [**SmartInfoResponseDto**](SmartInfoResponseDto.md) |  | [optional] 
 **tags** | [**List<TagResponseDto>**](TagResponseDto.md) |  | [optional] [default to const []]
-**thumbhash** | **String** | base64 encoded thumbhash | 
+**thumbhash** | **String** |  | 
 **type** | [**AssetTypeEnum**](AssetTypeEnum.md) |  | 
 **updatedAt** | [**DateTime**](DateTime.md) |  | 
 

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

@@ -14,7 +14,7 @@ Name | Type | Description | Notes
 **assetIds** | **List<String>** |  | [optional] [default to const []]
 **description** | **String** |  | [optional] 
 **expiresAt** | [**DateTime**](DateTime.md) |  | [optional] 
-**showExif** | **bool** |  | [optional] [default to true]
+**showMetadata** | **bool** |  | [optional] [default to true]
 **type** | [**SharedLinkType**](SharedLinkType.md) |  | 
 
 [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

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

@@ -12,7 +12,7 @@ Name | Type | Description | Notes
 **allowUpload** | **bool** |  | [optional] 
 **description** | **String** |  | [optional] 
 **expiresAt** | [**DateTime**](DateTime.md) |  | [optional] 
-**showExif** | **bool** |  | [optional] 
+**showMetadata** | **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)
 

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

@@ -17,7 +17,7 @@ Name | Type | Description | Notes
 **expiresAt** | [**DateTime**](DateTime.md) |  | 
 **id** | **String** |  | 
 **key** | **String** |  | 
-**showExif** | **bool** |  | 
+**showMetadata** | **bool** |  | 
 **type** | [**SharedLinkType**](SharedLinkType.md) |  | 
 **userId** | **String** |  | 
 

+ 9 - 2
mobile/openapi/lib/model/asset_response_dto.dart

@@ -20,6 +20,7 @@ class AssetResponseDto {
     this.exifInfo,
     required this.fileCreatedAt,
     required this.fileModifiedAt,
+    required this.hasMetadata,
     required this.id,
     required this.isArchived,
     required this.isExternal,
@@ -64,6 +65,8 @@ class AssetResponseDto {
 
   DateTime fileModifiedAt;
 
+  bool hasMetadata;
+
   String id;
 
   bool isArchived;
@@ -112,7 +115,6 @@ class AssetResponseDto {
 
   List<TagResponseDto> tags;
 
-  /// base64 encoded thumbhash
   String? thumbhash;
 
   AssetTypeEnum type;
@@ -128,6 +130,7 @@ class AssetResponseDto {
      other.exifInfo == exifInfo &&
      other.fileCreatedAt == fileCreatedAt &&
      other.fileModifiedAt == fileModifiedAt &&
+     other.hasMetadata == hasMetadata &&
      other.id == id &&
      other.isArchived == isArchived &&
      other.isExternal == isExternal &&
@@ -160,6 +163,7 @@ class AssetResponseDto {
     (exifInfo == null ? 0 : exifInfo!.hashCode) +
     (fileCreatedAt.hashCode) +
     (fileModifiedAt.hashCode) +
+    (hasMetadata.hashCode) +
     (id.hashCode) +
     (isArchived.hashCode) +
     (isExternal.hashCode) +
@@ -183,7 +187,7 @@ class AssetResponseDto {
     (updatedAt.hashCode);
 
   @override
-  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
+  String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, hasMetadata=$hasMetadata, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, isTrashed=$isTrashed, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -198,6 +202,7 @@ class AssetResponseDto {
     }
       json[r'fileCreatedAt'] = this.fileCreatedAt.toUtc().toIso8601String();
       json[r'fileModifiedAt'] = this.fileModifiedAt.toUtc().toIso8601String();
+      json[r'hasMetadata'] = this.hasMetadata;
       json[r'id'] = this.id;
       json[r'isArchived'] = this.isArchived;
       json[r'isExternal'] = this.isExternal;
@@ -253,6 +258,7 @@ class AssetResponseDto {
         exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']),
         fileCreatedAt: mapDateTime(json, r'fileCreatedAt', '')!,
         fileModifiedAt: mapDateTime(json, r'fileModifiedAt', '')!,
+        hasMetadata: mapValueOfType<bool>(json, r'hasMetadata')!,
         id: mapValueOfType<String>(json, r'id')!,
         isArchived: mapValueOfType<bool>(json, r'isArchived')!,
         isExternal: mapValueOfType<bool>(json, r'isExternal')!,
@@ -327,6 +333,7 @@ class AssetResponseDto {
     'duration',
     'fileCreatedAt',
     'fileModifiedAt',
+    'hasMetadata',
     'id',
     'isArchived',
     'isExternal',

+ 7 - 7
mobile/openapi/lib/model/shared_link_create_dto.dart

@@ -19,7 +19,7 @@ class SharedLinkCreateDto {
     this.assetIds = const [],
     this.description,
     this.expiresAt,
-    this.showExif = true,
+    this.showMetadata = true,
     required this.type,
   });
 
@@ -47,7 +47,7 @@ class SharedLinkCreateDto {
 
   DateTime? expiresAt;
 
-  bool showExif;
+  bool showMetadata;
 
   SharedLinkType type;
 
@@ -59,7 +59,7 @@ class SharedLinkCreateDto {
      other.assetIds == assetIds &&
      other.description == description &&
      other.expiresAt == expiresAt &&
-     other.showExif == showExif &&
+     other.showMetadata == showMetadata &&
      other.type == type;
 
   @override
@@ -71,11 +71,11 @@ class SharedLinkCreateDto {
     (assetIds.hashCode) +
     (description == null ? 0 : description!.hashCode) +
     (expiresAt == null ? 0 : expiresAt!.hashCode) +
-    (showExif.hashCode) +
+    (showMetadata.hashCode) +
     (type.hashCode);
 
   @override
-  String toString() => 'SharedLinkCreateDto[albumId=$albumId, allowDownload=$allowDownload, allowUpload=$allowUpload, assetIds=$assetIds, description=$description, expiresAt=$expiresAt, showExif=$showExif, type=$type]';
+  String toString() => 'SharedLinkCreateDto[albumId=$albumId, allowDownload=$allowDownload, allowUpload=$allowUpload, assetIds=$assetIds, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata, type=$type]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -97,7 +97,7 @@ class SharedLinkCreateDto {
     } else {
     //  json[r'expiresAt'] = null;
     }
-      json[r'showExif'] = this.showExif;
+      json[r'showMetadata'] = this.showMetadata;
       json[r'type'] = this.type;
     return json;
   }
@@ -118,7 +118,7 @@ class SharedLinkCreateDto {
             : const [],
         description: mapValueOfType<String>(json, r'description'),
         expiresAt: mapDateTime(json, r'expiresAt', ''),
-        showExif: mapValueOfType<bool>(json, r'showExif') ?? true,
+        showMetadata: mapValueOfType<bool>(json, r'showMetadata') ?? true,
         type: SharedLinkType.fromJson(json[r'type'])!,
       );
     }

+ 9 - 9
mobile/openapi/lib/model/shared_link_edit_dto.dart

@@ -17,7 +17,7 @@ class SharedLinkEditDto {
     this.allowUpload,
     this.description,
     this.expiresAt,
-    this.showExif,
+    this.showMetadata,
   });
 
   ///
@@ -52,7 +52,7 @@ class SharedLinkEditDto {
   /// source code must fall back to having a nullable type.
   /// Consider adding a "default:" property in the specification file to hide this note.
   ///
-  bool? showExif;
+  bool? showMetadata;
 
   @override
   bool operator ==(Object other) => identical(this, other) || other is SharedLinkEditDto &&
@@ -60,7 +60,7 @@ class SharedLinkEditDto {
      other.allowUpload == allowUpload &&
      other.description == description &&
      other.expiresAt == expiresAt &&
-     other.showExif == showExif;
+     other.showMetadata == showMetadata;
 
   @override
   int get hashCode =>
@@ -69,10 +69,10 @@ class SharedLinkEditDto {
     (allowUpload == null ? 0 : allowUpload!.hashCode) +
     (description == null ? 0 : description!.hashCode) +
     (expiresAt == null ? 0 : expiresAt!.hashCode) +
-    (showExif == null ? 0 : showExif!.hashCode);
+    (showMetadata == null ? 0 : showMetadata!.hashCode);
 
   @override
-  String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showExif=$showExif]';
+  String toString() => 'SharedLinkEditDto[allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, showMetadata=$showMetadata]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -96,10 +96,10 @@ class SharedLinkEditDto {
     } else {
     //  json[r'expiresAt'] = null;
     }
-    if (this.showExif != null) {
-      json[r'showExif'] = this.showExif;
+    if (this.showMetadata != null) {
+      json[r'showMetadata'] = this.showMetadata;
     } else {
-    //  json[r'showExif'] = null;
+    //  json[r'showMetadata'] = null;
     }
     return json;
   }
@@ -116,7 +116,7 @@ class SharedLinkEditDto {
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
         description: mapValueOfType<String>(json, r'description'),
         expiresAt: mapDateTime(json, r'expiresAt', ''),
-        showExif: mapValueOfType<bool>(json, r'showExif'),
+        showMetadata: mapValueOfType<bool>(json, r'showMetadata'),
       );
     }
     return null;

+ 8 - 8
mobile/openapi/lib/model/shared_link_response_dto.dart

@@ -22,7 +22,7 @@ class SharedLinkResponseDto {
     required this.expiresAt,
     required this.id,
     required this.key,
-    required this.showExif,
+    required this.showMetadata,
     required this.type,
     required this.userId,
   });
@@ -51,7 +51,7 @@ class SharedLinkResponseDto {
 
   String key;
 
-  bool showExif;
+  bool showMetadata;
 
   SharedLinkType type;
 
@@ -68,7 +68,7 @@ class SharedLinkResponseDto {
      other.expiresAt == expiresAt &&
      other.id == id &&
      other.key == key &&
-     other.showExif == showExif &&
+     other.showMetadata == showMetadata &&
      other.type == type &&
      other.userId == userId;
 
@@ -84,12 +84,12 @@ class SharedLinkResponseDto {
     (expiresAt == null ? 0 : expiresAt!.hashCode) +
     (id.hashCode) +
     (key.hashCode) +
-    (showExif.hashCode) +
+    (showMetadata.hashCode) +
     (type.hashCode) +
     (userId.hashCode);
 
   @override
-  String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, showExif=$showExif, type=$type, userId=$userId]';
+  String toString() => 'SharedLinkResponseDto[album=$album, allowDownload=$allowDownload, allowUpload=$allowUpload, assets=$assets, createdAt=$createdAt, description=$description, expiresAt=$expiresAt, id=$id, key=$key, showMetadata=$showMetadata, type=$type, userId=$userId]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -114,7 +114,7 @@ class SharedLinkResponseDto {
     }
       json[r'id'] = this.id;
       json[r'key'] = this.key;
-      json[r'showExif'] = this.showExif;
+      json[r'showMetadata'] = this.showMetadata;
       json[r'type'] = this.type;
       json[r'userId'] = this.userId;
     return json;
@@ -137,7 +137,7 @@ class SharedLinkResponseDto {
         expiresAt: mapDateTime(json, r'expiresAt', ''),
         id: mapValueOfType<String>(json, r'id')!,
         key: mapValueOfType<String>(json, r'key')!,
-        showExif: mapValueOfType<bool>(json, r'showExif')!,
+        showMetadata: mapValueOfType<bool>(json, r'showMetadata')!,
         type: SharedLinkType.fromJson(json[r'type'])!,
         userId: mapValueOfType<String>(json, r'userId')!,
       );
@@ -195,7 +195,7 @@ class SharedLinkResponseDto {
     'expiresAt',
     'id',
     'key',
-    'showExif',
+    'showMetadata',
     'type',
     'userId',
   };

+ 5 - 1
mobile/openapi/test/asset_response_dto_test.dart

@@ -52,6 +52,11 @@ void main() {
       // TODO
     });
 
+    // bool hasMetadata
+    test('to test the property `hasMetadata`', () async {
+      // TODO
+    });
+
     // String id
     test('to test the property `id`', () async {
       // TODO
@@ -142,7 +147,6 @@ void main() {
       // TODO
     });
 
-    // base64 encoded thumbhash
     // String thumbhash
     test('to test the property `thumbhash`', () async {
       // TODO

+ 2 - 2
mobile/openapi/test/shared_link_create_dto_test.dart

@@ -46,8 +46,8 @@ void main() {
       // TODO
     });
 
-    // bool showExif (default value: true)
-    test('to test the property `showExif`', () async {
+    // bool showMetadata (default value: true)
+    test('to test the property `showMetadata`', () async {
       // TODO
     });
 

+ 2 - 2
mobile/openapi/test/shared_link_edit_dto_test.dart

@@ -36,8 +36,8 @@ void main() {
       // TODO
     });
 
-    // bool showExif
-    test('to test the property `showExif`', () async {
+    // bool showMetadata
+    test('to test the property `showMetadata`', () async {
       // TODO
     });
 

+ 2 - 2
mobile/openapi/test/shared_link_response_dto_test.dart

@@ -61,8 +61,8 @@ void main() {
       // TODO
     });
 
-    // bool showExif
-    test('to test the property `showExif`', () async {
+    // bool showMetadata
+    test('to test the property `showMetadata`', () async {
       // TODO
     });
 

+ 12 - 9
server/immich-openapi-specs.json

@@ -5770,6 +5770,9 @@
             "format": "date-time",
             "type": "string"
           },
+          "hasMetadata": {
+            "type": "boolean"
+          },
           "id": {
             "type": "string"
           },
@@ -5833,7 +5836,6 @@
             "type": "array"
           },
           "thumbhash": {
-            "description": "base64 encoded thumbhash",
             "nullable": true,
             "type": "string"
           },
@@ -5847,7 +5849,6 @@
         },
         "required": [
           "type",
-          "id",
           "deviceAssetId",
           "deviceId",
           "ownerId",
@@ -5855,19 +5856,21 @@
           "originalPath",
           "originalFileName",
           "resized",
-          "thumbhash",
           "fileCreatedAt",
           "fileModifiedAt",
           "updatedAt",
           "isFavorite",
           "isArchived",
           "isTrashed",
-          "localDateTime",
           "isOffline",
           "isExternal",
           "isReadOnly",
+          "checksum",
+          "id",
+          "thumbhash",
+          "localDateTime",
           "duration",
-          "checksum"
+          "hasMetadata"
         ],
         "type": "object"
       },
@@ -7599,7 +7602,7 @@
             "nullable": true,
             "type": "string"
           },
-          "showExif": {
+          "showMetadata": {
             "default": true,
             "type": "boolean"
           },
@@ -7628,7 +7631,7 @@
             "nullable": true,
             "type": "string"
           },
-          "showExif": {
+          "showMetadata": {
             "type": "boolean"
           }
         },
@@ -7670,7 +7673,7 @@
           "key": {
             "type": "string"
           },
-          "showExif": {
+          "showMetadata": {
             "type": "boolean"
           },
           "type": {
@@ -7691,7 +7694,7 @@
           "assets",
           "allowUpload",
           "allowDownload",
-          "showExif"
+          "showMetadata"
         ],
         "type": "object"
       },

+ 10 - 2
server/src/domain/asset/asset.service.ts

@@ -47,6 +47,7 @@ import {
   BulkIdsDto,
   MapMarkerResponseDto,
   MemoryLaneResponseDto,
+  SanitizedAssetResponseDto,
   TimeBucketResponseDto,
   mapAsset,
 } from './response-dto';
@@ -198,10 +199,17 @@ export class AssetService {
     return this.assetRepository.getTimeBuckets(dto);
   }
 
-  async getByTimeBucket(authUser: AuthUserDto, dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
+  async getByTimeBucket(
+    authUser: AuthUserDto,
+    dto: TimeBucketAssetDto,
+  ): Promise<AssetResponseDto[] | SanitizedAssetResponseDto[]> {
     await this.timeBucketChecks(authUser, dto);
     const assets = await this.assetRepository.getByTimeBucket(dto.timeBucket, dto);
-    return assets.map(mapAsset);
+    if (authUser.isShowMetadata) {
+      return assets.map((asset) => mapAsset(asset));
+    } else {
+      return assets.map((asset) => mapAsset(asset, true));
+    }
   }
 
   async downloadFile(authUser: AuthUserDto, id: string): Promise<ImmichReadStream> {

+ 31 - 19
server/src/domain/asset/response-dto/asset-response.dto.ts

@@ -6,43 +6,62 @@ import { UserResponseDto, mapUser } from '../../user/response-dto/user-response.
 import { ExifResponseDto, mapExif } from './exif-response.dto';
 import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
 
-export class AssetResponseDto {
+export class SanitizedAssetResponseDto {
   id!: string;
+  @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
+  type!: AssetType;
+  thumbhash!: string | null;
+  resized!: boolean;
+  localDateTime!: Date;
+  duration!: string;
+  livePhotoVideoId?: string | null;
+  hasMetadata!: boolean;
+}
+
+export class AssetResponseDto extends SanitizedAssetResponseDto {
   deviceAssetId!: string;
   deviceId!: string;
   ownerId!: string;
   owner?: UserResponseDto;
   libraryId!: string;
-
-  @ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
-  type!: AssetType;
   originalPath!: string;
   originalFileName!: string;
   resized!: boolean;
-  /**base64 encoded thumbhash */
-  thumbhash!: string | null;
   fileCreatedAt!: Date;
   fileModifiedAt!: Date;
   updatedAt!: Date;
   isFavorite!: boolean;
   isArchived!: boolean;
   isTrashed!: boolean;
-  localDateTime!: Date;
   isOffline!: boolean;
   isExternal!: boolean;
   isReadOnly!: boolean;
-  duration!: string;
   exifInfo?: ExifResponseDto;
   smartInfo?: SmartInfoResponseDto;
-  livePhotoVideoId?: string | null;
   tags?: TagResponseDto[];
   people?: PersonResponseDto[];
   /**base64 encoded sha1 hash */
   checksum!: string;
 }
 
-function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
+export function mapAsset(entity: AssetEntity, stripMetadata = false): AssetResponseDto {
+  const sanitizedAssetResponse: SanitizedAssetResponseDto = {
+    id: entity.id,
+    type: entity.type,
+    thumbhash: entity.thumbhash?.toString('base64') ?? null,
+    localDateTime: entity.localDateTime,
+    resized: !!entity.resizePath,
+    duration: entity.duration ?? '0:00:00.00000',
+    livePhotoVideoId: entity.livePhotoVideoId,
+    hasMetadata: false,
+  };
+
+  if (stripMetadata) {
+    return sanitizedAssetResponse as AssetResponseDto;
+  }
+
   return {
+    ...sanitizedAssetResponse,
     id: entity.id,
     deviceAssetId: entity.deviceAssetId,
     ownerId: entity.ownerId,
@@ -62,7 +81,7 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
     isArchived: entity.isArchived,
     isTrashed: !!entity.deletedAt,
     duration: entity.duration ?? '0:00:00.00000',
-    exifInfo: withExif ? (entity.exifInfo ? mapExif(entity.exifInfo) : undefined) : undefined,
+    exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
     smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
     livePhotoVideoId: entity.livePhotoVideoId,
     tags: entity.tags?.map(mapTag),
@@ -71,17 +90,10 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
     isExternal: entity.isExternal,
     isOffline: entity.isOffline,
     isReadOnly: entity.isReadOnly,
+    hasMetadata: true,
   };
 }
 
-export function mapAsset(entity: AssetEntity): AssetResponseDto {
-  return _map(entity, true);
-}
-
-export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
-  return _map(entity, false);
-}
-
 export class MemoryLaneResponseDto {
   title!: string;
   assets!: AssetResponseDto[];

+ 12 - 0
server/src/domain/asset/response-dto/exif-response.dto.ts

@@ -52,3 +52,15 @@ export function mapExif(entity: ExifEntity): ExifResponseDto {
     projectionType: entity.projectionType,
   };
 }
+
+export function mapSanitizedExif(entity: ExifEntity): ExifResponseDto {
+  return {
+    fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
+    orientation: entity.orientation,
+    dateTimeOriginal: entity.dateTimeOriginal,
+    timeZone: entity.timeZone,
+    projectionType: entity.projectionType,
+    exifImageWidth: entity.exifImageWidth,
+    exifImageHeight: entity.exifImageHeight,
+  };
+}

+ 2 - 2
server/src/domain/auth/auth.service.ts

@@ -380,7 +380,7 @@ export class AuthService {
             sharedLinkId: link.id,
             isAllowUpload: link.allowUpload,
             isAllowDownload: link.allowDownload,
-            isShowExif: link.showExif,
+            isShowMetadata: link.showExif,
           };
         }
       }
@@ -431,7 +431,7 @@ export class AuthService {
         isPublicUser: false,
         isAllowUpload: true,
         isAllowDownload: true,
-        isShowExif: true,
+        isShowMetadata: true,
         accessTokenId: token.id,
       };
     }

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

@@ -6,7 +6,7 @@ export class AuthUserDto {
   sharedLinkId?: string;
   isAllowUpload?: boolean;
   isAllowDownload?: boolean;
-  isShowExif?: boolean;
+  isShowMetadata?: boolean;
   accessTokenId?: string;
   externalPath?: string | null;
 }

+ 1 - 1
server/src/domain/person/person.service.ts

@@ -97,7 +97,7 @@ export class PersonService {
   async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
     await this.access.requirePermission(authUser, Permission.PERSON_READ, id);
     const assets = await this.repository.getAssets(id);
-    return assets.map(mapAsset);
+    return assets.map((asset) => mapAsset(asset));
   }
 
   async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {

+ 1 - 1
server/src/domain/search/search.service.ts

@@ -154,7 +154,7 @@ export class SearchService {
         items: assets.items
           .map((item) => lookup[item.id])
           .filter((item) => !!item)
-          .map(mapAsset),
+          .map((asset) => mapAsset(asset)),
       },
     };
   }

+ 8 - 7
server/src/domain/shared-link/shared-link-response.dto.ts

@@ -2,7 +2,7 @@ import { SharedLinkEntity, SharedLinkType } from '@app/infra/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import _ from 'lodash';
 import { AlbumResponseDto, mapAlbumWithoutAssets } from '../album';
-import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../asset';
+import { AssetResponseDto, mapAsset } from '../asset';
 
 export class SharedLinkResponseDto {
   id!: string;
@@ -17,8 +17,9 @@ export class SharedLinkResponseDto {
   assets!: AssetResponseDto[];
   album?: AlbumResponseDto;
   allowUpload!: boolean;
+
   allowDownload!: boolean;
-  showExif!: boolean;
+  showMetadata!: boolean;
 }
 
 export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
@@ -35,15 +36,15 @@ export function mapSharedLink(sharedLink: SharedLinkEntity): SharedLinkResponseD
     type: sharedLink.type,
     createdAt: sharedLink.createdAt,
     expiresAt: sharedLink.expiresAt,
-    assets: assets.map(mapAsset),
+    assets: assets.map((asset) => mapAsset(asset)),
     album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
     allowUpload: sharedLink.allowUpload,
     allowDownload: sharedLink.allowDownload,
-    showExif: sharedLink.showExif,
+    showMetadata: sharedLink.showExif,
   };
 }
 
-export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
+export function mapSharedLinkWithoutMetadata(sharedLink: SharedLinkEntity): SharedLinkResponseDto {
   const linkAssets = sharedLink.assets || [];
   const albumAssets = (sharedLink?.album?.assets || []).map((asset) => asset);
 
@@ -57,10 +58,10 @@ export function mapSharedLinkWithNoExif(sharedLink: SharedLinkEntity): SharedLin
     type: sharedLink.type,
     createdAt: sharedLink.createdAt,
     expiresAt: sharedLink.expiresAt,
-    assets: assets.map(mapAssetWithoutExif),
+    assets: assets.map((asset) => mapAsset(asset, true)) as AssetResponseDto[],
     album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
     allowUpload: sharedLink.allowUpload,
     allowDownload: sharedLink.allowDownload,
-    showExif: sharedLink.showExif,
+    showMetadata: sharedLink.showExif,
   };
 }

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

@@ -34,7 +34,7 @@ export class SharedLinkCreateDto {
 
   @Optional()
   @IsBoolean()
-  showExif?: boolean = true;
+  showMetadata?: boolean = true;
 }
 
 export class SharedLinkEditDto {
@@ -51,5 +51,5 @@ export class SharedLinkEditDto {
   allowDownload?: boolean;
 
   @Optional()
-  showExif?: boolean;
+  showMetadata?: boolean;
 }

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

@@ -59,10 +59,10 @@ describe(SharedLinkService.name, () => {
       expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
     });
 
-    it('should return not return exif', async () => {
+    it('should not return metadata', async () => {
       const authDto = authStub.adminSharedLinkNoExif;
       shareMock.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
-      await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoExif);
+      await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.readonlyNoMetadata);
       expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
     });
   });
@@ -137,7 +137,7 @@ describe(SharedLinkService.name, () => {
       await sut.create(authStub.admin, {
         type: SharedLinkType.INDIVIDUAL,
         assetIds: [assetStub.image.id],
-        showExif: true,
+        showMetadata: true,
         allowDownload: true,
         allowUpload: true,
       });

+ 5 - 5
server/src/domain/shared-link/shared-link.service.ts

@@ -4,7 +4,7 @@ import { AccessCore, Permission } from '../access';
 import { AssetIdErrorReason, AssetIdsDto, AssetIdsResponseDto } from '../asset';
 import { AuthUserDto } from '../auth';
 import { IAccessRepository, ICryptoRepository, ISharedLinkRepository } from '../repositories';
-import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithNoExif } from './shared-link-response.dto';
+import { SharedLinkResponseDto, mapSharedLink, mapSharedLinkWithoutMetadata } from './shared-link-response.dto';
 import { SharedLinkCreateDto, SharedLinkEditDto } from './shared-link.dto';
 
 @Injectable()
@@ -24,7 +24,7 @@ export class SharedLinkService {
   }
 
   async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
-    const { sharedLinkId: id, isPublicUser, isShowExif } = authUser;
+    const { sharedLinkId: id, isPublicUser, isShowMetadata: isShowExif } = authUser;
 
     if (!isPublicUser || !id) {
       throw new ForbiddenException();
@@ -69,7 +69,7 @@ export class SharedLinkService {
       expiresAt: dto.expiresAt || null,
       allowUpload: dto.allowUpload ?? true,
       allowDownload: dto.allowDownload ?? true,
-      showExif: dto.showExif ?? true,
+      showExif: dto.showMetadata ?? true,
     });
 
     return this.map(sharedLink, { withExif: true });
@@ -84,7 +84,7 @@ export class SharedLinkService {
       expiresAt: dto.expiresAt,
       allowUpload: dto.allowUpload,
       allowDownload: dto.allowDownload,
-      showExif: dto.showExif,
+      showExif: dto.showMetadata,
     });
     return this.map(sharedLink, { withExif: true });
   }
@@ -157,6 +157,6 @@ export class SharedLinkService {
   }
 
   private map(sharedLink: SharedLinkEntity, { withExif }: { withExif: boolean }) {
-    return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithNoExif(sharedLink);
+    return withExif ? mapSharedLink(sharedLink) : mapSharedLinkWithoutMetadata(sharedLink);
   }
 }

+ 1 - 1
server/src/domain/tag/tag.service.ts

@@ -47,7 +47,7 @@ export class TagService {
   async getAssets(authUser: AuthUserDto, id: string): Promise<AssetResponseDto[]> {
     await this.findOrFail(authUser, id);
     const assets = await this.repository.getAssets(authUser.id, id);
-    return assets.map(mapAsset);
+    return assets.map((asset) => mapAsset(asset));
   }
 
   async addAssets(authUser: AuthUserDto, id: string, dto: AssetIdsDto): Promise<AssetIdsResponseDto[]> {

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

@@ -186,7 +186,7 @@ export class AssetController {
   @SharedLinkRoute()
   @Get('/assetById/:id')
   getAssetById(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto): Promise<AssetResponseDto> {
-    return this.assetService.getAssetById(authUser, id);
+    return this.assetService.getAssetById(authUser, id) as Promise<AssetResponseDto>;
   }
 
   /**

+ 19 - 12
server/src/immich/api-v1/asset/asset.service.ts

@@ -10,9 +10,9 @@ import {
   IStorageRepository,
   JobName,
   mapAsset,
-  mapAssetWithoutExif,
   mimeTypes,
   Permission,
+  SanitizedAssetResponseDto,
   UploadFile,
 } from '@app/domain';
 import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType, LibraryType } from '@app/infra/entities';
@@ -187,22 +187,29 @@ export class AssetService {
     return assets.map((asset) => mapAsset(asset));
   }
 
-  public async getAssetById(authUser: AuthUserDto, assetId: string): Promise<AssetResponseDto> {
+  public async getAssetById(
+    authUser: AuthUserDto,
+    assetId: string,
+  ): Promise<AssetResponseDto | SanitizedAssetResponseDto> {
     await this.access.requirePermission(authUser, Permission.ASSET_READ, assetId);
 
-    const allowExif = this.getExifPermission(authUser);
+    const includeMetadata = this.getExifPermission(authUser);
     const asset = await this._assetRepository.getById(assetId);
-    const data = allowExif ? mapAsset(asset) : mapAssetWithoutExif(asset);
+    if (includeMetadata) {
+      const data = mapAsset(asset);
 
-    if (data.ownerId !== authUser.id) {
-      data.people = [];
-    }
+      if (data.ownerId !== authUser.id) {
+        data.people = [];
+      }
 
-    if (authUser.isPublicUser) {
-      delete data.owner;
-    }
+      if (authUser.isPublicUser) {
+        delete data.owner;
+      }
 
-    return data;
+      return data;
+    } else {
+      return mapAsset(asset, true);
+    }
   }
 
   async serveThumbnail(authUser: AuthUserDto, assetId: string, query: GetAssetThumbnailDto, res: Res) {
@@ -374,7 +381,7 @@ export class AssetService {
   }
 
   getExifPermission(authUser: AuthUserDto) {
-    return !authUser.isPublicUser || authUser.isShowExif;
+    return !authUser.isPublicUser || authUser.isShowMetadata;
   }
 
   private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {

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

@@ -98,7 +98,7 @@ export class AssetController {
   @Authenticated({ isShared: true })
   @Get('time-bucket')
   getByTimeBucket(@AuthUser() authUser: AuthUserDto, @Query() dto: TimeBucketAssetDto): Promise<AssetResponseDto[]> {
-    return this.service.getByTimeBucket(authUser, dto);
+    return this.service.getByTimeBucket(authUser, dto) as Promise<AssetResponseDto[]>;
   }
 
   @Post('jobs')

+ 7 - 0
server/test/api/shared-link-api.ts

@@ -10,4 +10,11 @@ export const sharedLinkApi = {
     expect(status).toBe(201);
     return body as SharedLinkResponseDto;
   },
+
+  getMySharedLink: async (server: any, key: string) => {
+    const { status, body } = await request(server).get('/shared-link/me').query({ key });
+
+    expect(status).toBe(200);
+    return body as SharedLinkResponseDto;
+  },
 };

+ 1 - 1
server/test/assets

@@ -1 +1 @@
-Subproject commit 9e6e1bcc245e0ae0285bb596faf310ead851fac6
+Subproject commit 948f353e3c9b66156c86c86cf078e0746ec1598e

+ 98 - 12
server/test/e2e/shared-link.e2e-spec.ts

@@ -1,11 +1,17 @@
 import { AlbumResponseDto, LoginResponseDto, SharedLinkResponseDto } from '@app/domain';
 import { PartnerController } from '@app/immich';
-import { SharedLinkType } from '@app/infra/entities';
+import { LibraryType, SharedLinkType } from '@app/infra/entities';
 import { INestApplication } from '@nestjs/common';
 import { api } from '@test/api';
 import { db } from '@test/db';
 import { errorStub, uuidStub } from '@test/fixtures';
-import { createTestApp } from '@test/test-utils';
+import {
+  IMMICH_TEST_ASSET_PATH,
+  IMMICH_TEST_ASSET_TEMP_PATH,
+  createTestApp,
+  restoreTempFolder,
+} from '@test/test-utils';
+import { cp } from 'fs/promises';
 import request from 'supertest';
 
 const user1Dto = {
@@ -18,24 +24,22 @@ const user1Dto = {
 describe(`${PartnerController.name} (e2e)`, () => {
   let app: INestApplication;
   let server: any;
-  let loginResponse: LoginResponseDto;
-  let accessToken: string;
+  let admin: LoginResponseDto;
   let user1: LoginResponseDto;
   let album: AlbumResponseDto;
   let sharedLink: SharedLinkResponseDto;
 
   beforeAll(async () => {
-    app = await createTestApp();
+    app = await createTestApp(true);
     server = app.getHttpServer();
   });
 
   beforeEach(async () => {
     await db.reset();
     await api.authApi.adminSignUp(server);
-    loginResponse = await api.authApi.adminLogin(server);
-    accessToken = loginResponse.accessToken;
+    admin = await api.authApi.adminLogin(server);
 
-    await api.userApi.create(server, accessToken, user1Dto);
+    await api.userApi.create(server, admin.accessToken, user1Dto);
     user1 = await api.authApi.login(server, { email: user1Dto.email, password: user1Dto.password });
 
     album = await api.albumApi.create(server, user1.accessToken, { albumName: 'shared with link' });
@@ -48,6 +52,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
   afterAll(async () => {
     await db.disconnect();
     await app.close();
+    await restoreTempFolder();
   });
 
   describe('GET /shared-link', () => {
@@ -68,7 +73,9 @@ describe(`${PartnerController.name} (e2e)`, () => {
     });
 
     it('should not get shared links created by other users', async () => {
-      const { status, body } = await request(server).get('/shared-link').set('Authorization', `Bearer ${accessToken}`);
+      const { status, body } = await request(server)
+        .get('/shared-link')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
 
       expect(status).toBe(200);
       expect(body).toEqual([]);
@@ -77,7 +84,9 @@ describe(`${PartnerController.name} (e2e)`, () => {
 
   describe('GET /shared-link/me', () => {
     it('should not require admin authentication', async () => {
-      const { status } = await request(server).get('/shared-link/me').set('Authorization', `Bearer ${accessToken}`);
+      const { status } = await request(server)
+        .get('/shared-link/me')
+        .set('Authorization', `Bearer ${admin.accessToken}`);
 
       expect(status).toBe(403);
     });
@@ -104,7 +113,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
         type: SharedLinkType.ALBUM,
         albumId: softDeletedAlbum.id,
       });
-      await api.userApi.delete(server, accessToken, user1.userId);
+      await api.userApi.delete(server, admin.accessToken, user1.userId);
 
       const { status, body } = await request(server).get('/shared-link/me').query({ key: softDeletedAlbumLink.key });
 
@@ -133,7 +142,7 @@ describe(`${PartnerController.name} (e2e)`, () => {
     it('should not get shared link by id if user has not created the link or it does not exist', async () => {
       const { status, body } = await request(server)
         .get(`/shared-link/${sharedLink.id}`)
-        .set('Authorization', `Bearer ${accessToken}`);
+        .set('Authorization', `Bearer ${admin.accessToken}`);
 
       expect(status).toBe(400);
       expect(body).toEqual(expect.objectContaining({ message: 'Shared link not found' }));
@@ -248,4 +257,81 @@ describe(`${PartnerController.name} (e2e)`, () => {
       expect(status).toBe(200);
     });
   });
+
+  describe('Shared link metadata', () => {
+    beforeEach(async () => {
+      await restoreTempFolder();
+
+      await cp(
+        `${IMMICH_TEST_ASSET_PATH}/metadata/gps-position/thompson-springs.jpg`,
+        `${IMMICH_TEST_ASSET_TEMP_PATH}/thompson-springs.jpg`,
+      );
+
+      await api.userApi.setExternalPath(server, admin.accessToken, admin.userId, '/');
+
+      const library = await api.libraryApi.create(server, admin.accessToken, {
+        type: LibraryType.EXTERNAL,
+        importPaths: [`${IMMICH_TEST_ASSET_TEMP_PATH}`],
+      });
+
+      await api.libraryApi.scanLibrary(server, admin.accessToken, library.id);
+
+      const assets = await api.assetApi.getAllAssets(server, admin.accessToken);
+
+      expect(assets).toHaveLength(1);
+
+      album = await api.albumApi.create(server, admin.accessToken, { albumName: 'New album' });
+      await api.albumApi.addAssets(server, admin.accessToken, album.id, { ids: [assets[0].id] });
+    });
+
+    it('should return metadata for album shared link', async () => {
+      const sharedLink = await api.sharedLinkApi.create(server, admin.accessToken, {
+        type: SharedLinkType.ALBUM,
+        albumId: album.id,
+      });
+
+      const returnedLink = await api.sharedLinkApi.getMySharedLink(server, sharedLink.key);
+
+      expect(returnedLink.assets).toHaveLength(1);
+      expect(returnedLink.album).toBeDefined();
+
+      const returnedAsset = returnedLink.assets[0];
+      expect(returnedAsset).toEqual(
+        expect.objectContaining({
+          originalFileName: 'thompson-springs',
+          resized: true,
+          localDateTime: '2022-01-10T15:15:44.310Z',
+          fileCreatedAt: '2022-01-10T19:15:44.310Z',
+          exifInfo: expect.objectContaining({
+            longitude: -108.400968333333,
+            latitude: 39.115,
+            orientation: '1',
+            dateTimeOriginal: '2022-01-10T19:15:44.310Z',
+            timeZone: 'UTC-4',
+            state: 'Mesa County, Colorado',
+            country: 'United States of America',
+          }),
+        }),
+      );
+    });
+
+    it('should not return metadata for album shared link without metadata', async () => {
+      const sharedLink = await api.sharedLinkApi.create(server, admin.accessToken, {
+        type: SharedLinkType.ALBUM,
+        albumId: album.id,
+        showMetadata: false,
+      });
+
+      const returnedLink = await api.sharedLinkApi.getMySharedLink(server, sharedLink.key);
+
+      expect(returnedLink.assets).toHaveLength(1);
+      expect(returnedLink.album).toBeDefined();
+
+      const returnedAsset = returnedLink.assets[0];
+      expect(returnedAsset).not.toHaveProperty('exifInfo');
+      expect(returnedAsset).not.toHaveProperty('fileCreatedAt');
+      expect(returnedAsset).not.toHaveProperty('originalFilename');
+      expect(returnedAsset).not.toHaveProperty('originalPath');
+    });
+  });
 });

+ 6 - 6
server/test/fixtures/auth.stub.ts

@@ -48,7 +48,7 @@ export const authStub = {
     isPublicUser: false,
     isAllowUpload: true,
     isAllowDownload: true,
-    isShowExif: true,
+    isShowMetadata: true,
     accessTokenId: 'token-id',
     externalPath: null,
   }),
@@ -59,7 +59,7 @@ export const authStub = {
     isPublicUser: false,
     isAllowUpload: true,
     isAllowDownload: true,
-    isShowExif: true,
+    isShowMetadata: true,
     accessTokenId: 'token-id',
     externalPath: null,
   }),
@@ -70,7 +70,7 @@ export const authStub = {
     isPublicUser: false,
     isAllowUpload: true,
     isAllowDownload: true,
-    isShowExif: true,
+    isShowMetadata: true,
     accessTokenId: 'token-id',
     externalPath: '/data/user1',
   }),
@@ -81,7 +81,7 @@ export const authStub = {
     isAllowUpload: true,
     isAllowDownload: true,
     isPublicUser: true,
-    isShowExif: true,
+    isShowMetadata: true,
     sharedLinkId: '123',
   }),
   adminSharedLinkNoExif: Object.freeze<AuthUserDto>({
@@ -91,7 +91,7 @@ export const authStub = {
     isAllowUpload: true,
     isAllowDownload: true,
     isPublicUser: true,
-    isShowExif: false,
+    isShowMetadata: false,
     sharedLinkId: '123',
   }),
   readonlySharedLink: Object.freeze<AuthUserDto>({
@@ -101,7 +101,7 @@ export const authStub = {
     isAllowUpload: false,
     isAllowDownload: false,
     isPublicUser: true,
-    isShowExif: true,
+    isShowMetadata: true,
     sharedLinkId: '123',
     accessTokenId: 'token-id',
   }),

+ 18 - 6
server/test/fixtures/shared-link.stub.ts

@@ -71,8 +71,20 @@ const assetResponse: AssetResponseDto = {
   checksum: 'ZmlsZSBoYXNo',
   isTrashed: false,
   libraryId: 'library-id',
+  hasMetadata: true,
 };
 
+const assetResponseWithoutMetadata = {
+  id: 'id_1',
+  type: AssetType.VIDEO,
+  resized: false,
+  thumbhash: null,
+  localDateTime: today,
+  duration: '0:00:00.00000',
+  livePhotoVideoId: null,
+  hasMetadata: false,
+} as AssetResponseDto;
+
 const albumResponse: AlbumResponseDto = {
   albumName: 'Test Album',
   description: '',
@@ -253,7 +265,7 @@ export const sharedLinkResponseStub = {
     expiresAt: tomorrow,
     id: '123',
     key: sharedLinkBytes.toString('base64url'),
-    showExif: true,
+    showMetadata: true,
     type: SharedLinkType.ALBUM,
     userId: 'admin_id',
   }),
@@ -267,7 +279,7 @@ export const sharedLinkResponseStub = {
     expiresAt: yesterday,
     id: '123',
     key: sharedLinkBytes.toString('base64url'),
-    showExif: true,
+    showMetadata: true,
     type: SharedLinkType.ALBUM,
     userId: 'admin_id',
   }),
@@ -281,11 +293,11 @@ export const sharedLinkResponseStub = {
     description: null,
     allowUpload: false,
     allowDownload: false,
-    showExif: true,
+    showMetadata: true,
     album: albumResponse,
     assets: [assetResponse],
   }),
-  readonlyNoExif: Object.freeze<SharedLinkResponseDto>({
+  readonlyNoMetadata: Object.freeze<SharedLinkResponseDto>({
     id: '123',
     userId: 'admin_id',
     key: sharedLinkBytes.toString('base64url'),
@@ -295,8 +307,8 @@ export const sharedLinkResponseStub = {
     description: null,
     allowUpload: false,
     allowDownload: false,
-    showExif: false,
+    showMetadata: false,
     album: { ...albumResponse, startDate: assetResponse.fileCreatedAt, endDate: assetResponse.fileCreatedAt },
-    assets: [{ ...assetResponse, exifInfo: undefined }],
+    assets: [{ ...assetResponseWithoutMetadata, exifInfo: undefined }],
   }),
 };

+ 10 - 4
web/src/api/open-api/api.ts

@@ -640,6 +640,12 @@ export interface AssetResponseDto {
      * @memberof AssetResponseDto
      */
     'fileModifiedAt': string;
+    /**
+     * 
+     * @type {boolean}
+     * @memberof AssetResponseDto
+     */
+    'hasMetadata': boolean;
     /**
      * 
      * @type {string}
@@ -749,7 +755,7 @@ export interface AssetResponseDto {
      */
     'tags'?: Array<TagResponseDto>;
     /**
-     * base64 encoded thumbhash
+     * 
      * @type {string}
      * @memberof AssetResponseDto
      */
@@ -2882,7 +2888,7 @@ export interface SharedLinkCreateDto {
      * @type {boolean}
      * @memberof SharedLinkCreateDto
      */
-    'showExif'?: boolean;
+    'showMetadata'?: boolean;
     /**
      * 
      * @type {SharedLinkType}
@@ -2927,7 +2933,7 @@ export interface SharedLinkEditDto {
      * @type {boolean}
      * @memberof SharedLinkEditDto
      */
-    'showExif'?: boolean;
+    'showMetadata'?: boolean;
 }
 /**
  * 
@@ -2994,7 +3000,7 @@ export interface SharedLinkResponseDto {
      * @type {boolean}
      * @memberof SharedLinkResponseDto
      */
-    'showExif': boolean;
+    'showMetadata': boolean;
     /**
      * 
      * @type {SharedLinkType}

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

@@ -28,6 +28,7 @@
   export let showMotionPlayButton: boolean;
   export let isMotionPhotoPlaying = false;
   export let showDownloadButton: boolean;
+  export let showDetailButton: boolean;
   export let showSlideshow = false;
 
   const isOwner = asset.ownerId === $page.data.user?.id;
@@ -133,7 +134,14 @@
         title="Download"
       />
     {/if}
-    <CircleIconButton isOpacity={true} logo={InformationOutline} on:click={() => dispatch('showDetail')} title="Info" />
+    {#if showDetailButton}
+      <CircleIconButton
+        isOpacity={true}
+        logo={InformationOutline}
+        on:click={() => dispatch('showDetail')}
+        title="Info"
+      />
+    {/if}
     {#if isOwner}
       <CircleIconButton
         isOpacity={true}

+ 5 - 3
web/src/lib/components/asset-viewer/asset-viewer.svelte

@@ -55,6 +55,7 @@
   let shouldPlayMotionPhoto = false;
   let isShowProfileImageCrop = false;
   let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline;
+  let shouldShowDetailButton = asset.hasMetadata;
   let canCopyImagesToClipboard: boolean;
 
   const onKeyboardPress = (keyInfo: KeyboardEvent) => handleKeyboardPress(keyInfo);
@@ -392,6 +393,7 @@
         showZoomButton={asset.type === AssetTypeEnum.Image}
         showMotionPlayButton={!!asset.livePhotoVideoId}
         showDownloadButton={shouldShowDownloadButton}
+        showDetailButton={shouldShowDetailButton}
         showSlideshow={!!assetStore}
         on:goBack={closeViewer}
         on:showDetail={showDetailInfoHandler}
@@ -433,9 +435,9 @@
             on:close={closeViewer}
             on:onVideoEnded={() => (shouldPlayMotionPhoto = false)}
           />
-        {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || asset.originalPath
-            .toLowerCase()
-            .endsWith('.insp')}
+        {:else if asset.exifInfo?.projectionType === ProjectionType.EQUIRECTANGULAR || (asset.originalPath && asset.originalPath
+              .toLowerCase()
+              .endsWith('.insp'))}
           <PanoramaViewer {asset} />
         {:else}
           <PhotoViewer {asset} on:close={closeViewer} />

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

@@ -21,7 +21,7 @@
   let description = '';
   let allowDownload = true;
   let allowUpload = false;
-  let showExif = true;
+  let showMetadata = true;
   let expirationTime = '';
   let shouldChangeExpirationTime = false;
   let canCopyImagesToClipboard = true;
@@ -41,7 +41,7 @@
       }
       allowUpload = editingLink.allowUpload;
       allowDownload = editingLink.allowDownload;
-      showExif = editingLink.showExif;
+      showMetadata = editingLink.showMetadata;
 
       albumId = editingLink.album?.id;
       assetIds = editingLink.assets.map(({ id }) => id);
@@ -66,7 +66,7 @@
           allowUpload,
           description,
           allowDownload,
-          showExif,
+          showMetadata,
         },
       });
       sharedLink = `${window.location.origin}/share/${data.key}`;
@@ -119,9 +119,9 @@
         sharedLinkEditDto: {
           description,
           expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
-          allowUpload: allowUpload,
-          allowDownload: allowDownload,
-          showExif: showExif,
+          allowUpload,
+          allowDownload,
+          showMetadata,
         },
       });
 
@@ -184,7 +184,7 @@
         </div>
 
         <div class="my-3">
-          <SettingSwitch bind:checked={showExif} title={'Show metadata'} />
+          <SettingSwitch bind:checked={showMetadata} title={'Show metadata'} />
         </div>
 
         <div class="my-3">

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

@@ -136,7 +136,7 @@
         </div>
       {/if}
 
-      {#if link.showExif}
+      {#if link.showMetadata}
         <div
           class="flex w-[60px] place-content-center place-items-center rounded-full bg-immich-primary px-2 py-1 text-xs text-white dark:bg-immich-dark-primary dark:text-immich-dark-gray"
         >