Explorar o código

refactor(server): shared links (#1385)

* refactor(server): shared links

* chore: tests

* fix: bugs and tests

* fix: missed one expired at

* fix: standardize file upload checks

* test: lower flutter version

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
Jason Rasmussen %!s(int64=2) %!d(string=hai) anos
pai
achega
8f304b8157
Modificáronse 70 ficheiros con 1634 adicións e 1073 borrados
  1. 1 1
      mobile/openapi/doc/CreateAlbumShareLinkDto.md
  2. 1 1
      mobile/openapi/doc/CreateAssetsShareLinkDto.md
  3. 1 2
      mobile/openapi/doc/EditSharedLinkDto.md
  4. 4 5
      mobile/openapi/doc/ShareApi.md
  5. 1 9
      mobile/openapi/lib/api/share_api.dart
  6. 9 9
      mobile/openapi/lib/model/create_album_share_link_dto.dart
  7. 9 9
      mobile/openapi/lib/model/create_assets_share_link_dto.dart
  8. 11 34
      mobile/openapi/lib/model/edit_shared_link_dto.dart
  9. 2 2
      mobile/openapi/test/create_album_share_link_dto_test.dart
  10. 2 2
      mobile/openapi/test/create_assets_share_link_dto_test.dart
  11. 2 7
      mobile/openapi/test/edit_shared_link_dto_test.dart
  12. 1 1
      mobile/openapi/test/share_api_test.dart
  13. 1 1
      server/apps/immich/src/api-v1/album/album.controller.ts
  14. 0 2
      server/apps/immich/src/api-v1/album/album.module.ts
  15. 13 13
      server/apps/immich/src/api-v1/album/album.service.spec.ts
  16. 10 11
      server/apps/immich/src/api-v1/album/album.service.ts
  17. 1 1
      server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts
  18. 1 1
      server/apps/immich/src/api-v1/album/response-dto/add-assets-response.dto.ts
  19. 2 2
      server/apps/immich/src/api-v1/asset/asset.controller.ts
  20. 0 2
      server/apps/immich/src/api-v1/asset/asset.module.ts
  21. 84 14
      server/apps/immich/src/api-v1/asset/asset.service.spec.ts
  22. 31 25
      server/apps/immich/src/api-v1/asset/asset.service.ts
  23. 1 1
      server/apps/immich/src/api-v1/asset/dto/create-asset-shared-link.dto.ts
  24. 0 101
      server/apps/immich/src/api-v1/share/share.core.ts
  25. 0 19
      server/apps/immich/src/api-v1/share/share.module.ts
  26. 0 137
      server/apps/immich/src/api-v1/share/shared-link.repository.ts
  27. 1 1
      server/apps/immich/src/api-v1/tag/tag.controller.ts
  28. 1 1
      server/apps/immich/src/api-v1/tag/tag.service.ts
  29. 2 3
      server/apps/immich/src/app.module.ts
  30. 3 7
      server/apps/immich/src/config/asset-upload.config.ts
  31. 1 0
      server/apps/immich/src/controllers/index.ts
  32. 9 11
      server/apps/immich/src/controllers/share.controller.ts
  33. 0 2
      server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts
  34. 1 2
      server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts
  35. 1 1
      server/apps/microservices/src/processors/thumbnail.processor.ts
  36. 688 560
      server/immich-openapi-specs.json
  37. 1 1
      server/libs/common/src/utils/asset-utils.ts
  38. 1 0
      server/libs/domain/src/album/index.ts
  39. 3 3
      server/libs/domain/src/album/response-dto/album-response.dto.ts
  40. 1 0
      server/libs/domain/src/album/response-dto/index.ts
  41. 1 0
      server/libs/domain/src/asset/index.ts
  42. 2 2
      server/libs/domain/src/asset/response-dto/asset-response.dto.ts
  43. 2 2
      server/libs/domain/src/asset/response-dto/exif-response.dto.ts
  44. 3 0
      server/libs/domain/src/asset/response-dto/index.ts
  45. 1 1
      server/libs/domain/src/asset/response-dto/smart-info-response.dto.ts
  46. 2 0
      server/libs/domain/src/domain.module.ts
  47. 4 0
      server/libs/domain/src/index.ts
  48. 1 1
      server/libs/domain/src/job/interfaces/metadata-extraction.interface.ts
  49. 3 4
      server/libs/domain/src/share/dto/create-shared-link.dto.ts
  50. 2 5
      server/libs/domain/src/share/dto/edit-shared-link.dto.ts
  51. 2 0
      server/libs/domain/src/share/dto/index.ts
  52. 5 0
      server/libs/domain/src/share/index.ts
  53. 1 0
      server/libs/domain/src/share/response-dto/index.ts
  54. 3 3
      server/libs/domain/src/share/response-dto/shared-link-response.dto.ts
  55. 81 0
      server/libs/domain/src/share/share.core.ts
  56. 170 0
      server/libs/domain/src/share/share.service.spec.ts
  57. 23 22
      server/libs/domain/src/share/share.service.ts
  58. 13 0
      server/libs/domain/src/share/shared-link.repository.ts
  59. 1 0
      server/libs/domain/src/tag/index.ts
  60. 1 0
      server/libs/domain/src/tag/response-dto/index.ts
  61. 1 1
      server/libs/domain/src/tag/response-dto/tag-response.dto.ts
  62. 260 2
      server/libs/domain/test/fixtures.ts
  63. 1 0
      server/libs/domain/test/index.ts
  64. 13 0
      server/libs/domain/test/shared-link.repository.mock.ts
  65. 1 1
      server/libs/infra/src/db/entities/exif.entity.ts
  66. 1 0
      server/libs/infra/src/db/repository/index.ts
  67. 119 0
      server/libs/infra/src/db/repository/shared-link.repository.ts
  68. 5 3
      server/libs/infra/src/infra.module.ts
  69. 5 11
      web/src/api/open-api/api.ts
  70. 6 11
      web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte

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

@@ -9,7 +9,7 @@ import 'package:openapi/api.dart';
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **albumId** | **String** |  | 
-**expiredAt** | **String** |  | [optional] 
+**expiresAt** | **String** |  | [optional] 
 **allowUpload** | **bool** |  | [optional] 
 **allowDownload** | **bool** |  | [optional] 
 **showExif** | **bool** |  | [optional] 

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

@@ -9,7 +9,7 @@ import 'package:openapi/api.dart';
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **assetIds** | **List<String>** |  | [default to const []]
-**expiredAt** | **String** |  | [optional] 
+**expiresAt** | **String** |  | [optional] 
 **allowUpload** | **bool** |  | [optional] 
 **allowDownload** | **bool** |  | [optional] 
 **showExif** | **bool** |  | [optional] 

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

@@ -9,11 +9,10 @@ import 'package:openapi/api.dart';
 Name | Type | Description | Notes
 ------------ | ------------- | ------------- | -------------
 **description** | **String** |  | [optional] 
-**expiredAt** | **String** |  | [optional] 
+**expiresAt** | **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)
 

+ 4 - 5
mobile/openapi/doc/ShareApi.md

@@ -183,7 +183,7 @@ No authorization required
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **removeSharedLink**
-> String removeSharedLink(id)
+> removeSharedLink(id)
 
 
 
@@ -197,8 +197,7 @@ final api_instance = ShareApi();
 final id = id_example; // String | 
 
 try {
-    final result = api_instance.removeSharedLink(id);
-    print(result);
+    api_instance.removeSharedLink(id);
 } catch (e) {
     print('Exception when calling ShareApi->removeSharedLink: $e\n');
 }
@@ -212,7 +211,7 @@ Name | Type | Description  | Notes
 
 ### Return type
 
-**String**
+void (empty response body)
 
 ### Authorization
 
@@ -221,7 +220,7 @@ No authorization required
 ### HTTP request headers
 
  - **Content-Type**: Not defined
- - **Accept**: application/json
+ - **Accept**: Not defined
 
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 

+ 1 - 9
mobile/openapi/lib/api/share_api.dart

@@ -255,18 +255,10 @@ class ShareApi {
   /// Parameters:
   ///
   /// * [String] id (required):
-  Future<String?> removeSharedLink(String id,) async {
+  Future<void> removeSharedLink(String id,) async {
     final response = await removeSharedLinkWithHttpInfo(id,);
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }
-    // When a remote server returns no body with a status of 204, we shall not decode it.
-    // At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
-    // FormatException when trying to decode an empty string.
-    if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
-      return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'String',) as String;
-    
-    }
-    return null;
   }
 }

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

@@ -14,7 +14,7 @@ class CreateAlbumShareLinkDto {
   /// Returns a new [CreateAlbumShareLinkDto] instance.
   CreateAlbumShareLinkDto({
     required this.albumId,
-    this.expiredAt,
+    this.expiresAt,
     this.allowUpload,
     this.allowDownload,
     this.showExif,
@@ -29,7 +29,7 @@ class CreateAlbumShareLinkDto {
   /// source code must fall back to having a nullable type.
   /// Consider adding a "default:" property in the specification file to hide this note.
   ///
-  String? expiredAt;
+  String? expiresAt;
 
   ///
   /// Please note: This property should have been non-nullable! Since the specification file
@@ -66,7 +66,7 @@ class CreateAlbumShareLinkDto {
   @override
   bool operator ==(Object other) => identical(this, other) || other is CreateAlbumShareLinkDto &&
      other.albumId == albumId &&
-     other.expiredAt == expiredAt &&
+     other.expiresAt == expiresAt &&
      other.allowUpload == allowUpload &&
      other.allowDownload == allowDownload &&
      other.showExif == showExif &&
@@ -76,22 +76,22 @@ class CreateAlbumShareLinkDto {
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     (albumId.hashCode) +
-    (expiredAt == null ? 0 : expiredAt!.hashCode) +
+    (expiresAt == null ? 0 : expiresAt!.hashCode) +
     (allowUpload == null ? 0 : allowUpload!.hashCode) +
     (allowDownload == null ? 0 : allowDownload!.hashCode) +
     (showExif == null ? 0 : showExif!.hashCode) +
     (description == null ? 0 : description!.hashCode);
 
   @override
-  String toString() => 'CreateAlbumShareLinkDto[albumId=$albumId, expiredAt=$expiredAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
+  String toString() => 'CreateAlbumShareLinkDto[albumId=$albumId, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
       json[r'albumId'] = this.albumId;
-    if (this.expiredAt != null) {
-      json[r'expiredAt'] = this.expiredAt;
+    if (this.expiresAt != null) {
+      json[r'expiresAt'] = this.expiresAt;
     } else {
-      // json[r'expiredAt'] = null;
+      // json[r'expiresAt'] = null;
     }
     if (this.allowUpload != null) {
       json[r'allowUpload'] = this.allowUpload;
@@ -136,7 +136,7 @@ class CreateAlbumShareLinkDto {
 
       return CreateAlbumShareLinkDto(
         albumId: mapValueOfType<String>(json, r'albumId')!,
-        expiredAt: mapValueOfType<String>(json, r'expiredAt'),
+        expiresAt: mapValueOfType<String>(json, r'expiresAt'),
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
         allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
         showExif: mapValueOfType<bool>(json, r'showExif'),

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

@@ -14,7 +14,7 @@ class CreateAssetsShareLinkDto {
   /// Returns a new [CreateAssetsShareLinkDto] instance.
   CreateAssetsShareLinkDto({
     this.assetIds = const [],
-    this.expiredAt,
+    this.expiresAt,
     this.allowUpload,
     this.allowDownload,
     this.showExif,
@@ -29,7 +29,7 @@ class CreateAssetsShareLinkDto {
   /// source code must fall back to having a nullable type.
   /// Consider adding a "default:" property in the specification file to hide this note.
   ///
-  String? expiredAt;
+  String? expiresAt;
 
   ///
   /// Please note: This property should have been non-nullable! Since the specification file
@@ -66,7 +66,7 @@ class CreateAssetsShareLinkDto {
   @override
   bool operator ==(Object other) => identical(this, other) || other is CreateAssetsShareLinkDto &&
      other.assetIds == assetIds &&
-     other.expiredAt == expiredAt &&
+     other.expiresAt == expiresAt &&
      other.allowUpload == allowUpload &&
      other.allowDownload == allowDownload &&
      other.showExif == showExif &&
@@ -76,22 +76,22 @@ class CreateAssetsShareLinkDto {
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     (assetIds.hashCode) +
-    (expiredAt == null ? 0 : expiredAt!.hashCode) +
+    (expiresAt == null ? 0 : expiresAt!.hashCode) +
     (allowUpload == null ? 0 : allowUpload!.hashCode) +
     (allowDownload == null ? 0 : allowDownload!.hashCode) +
     (showExif == null ? 0 : showExif!.hashCode) +
     (description == null ? 0 : description!.hashCode);
 
   @override
-  String toString() => 'CreateAssetsShareLinkDto[assetIds=$assetIds, expiredAt=$expiredAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
+  String toString() => 'CreateAssetsShareLinkDto[assetIds=$assetIds, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, description=$description]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
       json[r'assetIds'] = this.assetIds;
-    if (this.expiredAt != null) {
-      json[r'expiredAt'] = this.expiredAt;
+    if (this.expiresAt != null) {
+      json[r'expiresAt'] = this.expiresAt;
     } else {
-      // json[r'expiredAt'] = null;
+      // json[r'expiresAt'] = null;
     }
     if (this.allowUpload != null) {
       json[r'allowUpload'] = this.allowUpload;
@@ -138,7 +138,7 @@ class CreateAssetsShareLinkDto {
         assetIds: json[r'assetIds'] is List
             ? (json[r'assetIds'] as List).cast<String>()
             : const [],
-        expiredAt: mapValueOfType<String>(json, r'expiredAt'),
+        expiresAt: mapValueOfType<String>(json, r'expiresAt'),
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
         allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
         showExif: mapValueOfType<bool>(json, r'showExif'),

+ 11 - 34
mobile/openapi/lib/model/edit_shared_link_dto.dart

@@ -14,11 +14,10 @@ class EditSharedLinkDto {
   /// Returns a new [EditSharedLinkDto] instance.
   EditSharedLinkDto({
     this.description,
-    this.expiredAt,
+    this.expiresAt,
     this.allowUpload,
     this.allowDownload,
     this.showExif,
-    this.isEditExpireTime,
   });
 
   ///
@@ -29,13 +28,7 @@ class EditSharedLinkDto {
   ///
   String? description;
 
-  ///
-  /// Please note: This property should have been non-nullable! Since the specification file
-  /// does not include a default value (using the "default:" property), however, the generated
-  /// source code must fall back to having a nullable type.
-  /// Consider adding a "default:" property in the specification file to hide this note.
-  ///
-  String? expiredAt;
+  String? expiresAt;
 
   ///
   /// Please note: This property should have been non-nullable! Since the specification file
@@ -61,35 +54,25 @@ class EditSharedLinkDto {
   ///
   bool? showExif;
 
-  ///
-  /// Please note: This property should have been non-nullable! Since the specification file
-  /// does not include a default value (using the "default:" property), however, the generated
-  /// source code must fall back to having a nullable type.
-  /// Consider adding a "default:" property in the specification file to hide this note.
-  ///
-  bool? isEditExpireTime;
-
   @override
   bool operator ==(Object other) => identical(this, other) || other is EditSharedLinkDto &&
      other.description == description &&
-     other.expiredAt == expiredAt &&
+     other.expiresAt == expiresAt &&
      other.allowUpload == allowUpload &&
      other.allowDownload == allowDownload &&
-     other.showExif == showExif &&
-     other.isEditExpireTime == isEditExpireTime;
+     other.showExif == showExif;
 
   @override
   int get hashCode =>
     // ignore: unnecessary_parenthesis
     (description == null ? 0 : description!.hashCode) +
-    (expiredAt == null ? 0 : expiredAt!.hashCode) +
+    (expiresAt == null ? 0 : expiresAt!.hashCode) +
     (allowUpload == null ? 0 : allowUpload!.hashCode) +
     (allowDownload == null ? 0 : allowDownload!.hashCode) +
-    (showExif == null ? 0 : showExif!.hashCode) +
-    (isEditExpireTime == null ? 0 : isEditExpireTime!.hashCode);
+    (showExif == null ? 0 : showExif!.hashCode);
 
   @override
-  String toString() => 'EditSharedLinkDto[description=$description, expiredAt=$expiredAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif, isEditExpireTime=$isEditExpireTime]';
+  String toString() => 'EditSharedLinkDto[description=$description, expiresAt=$expiresAt, allowUpload=$allowUpload, allowDownload=$allowDownload, showExif=$showExif]';
 
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
@@ -98,10 +81,10 @@ class EditSharedLinkDto {
     } else {
       // json[r'description'] = null;
     }
-    if (this.expiredAt != null) {
-      json[r'expiredAt'] = this.expiredAt;
+    if (this.expiresAt != null) {
+      json[r'expiresAt'] = this.expiresAt;
     } else {
-      // json[r'expiredAt'] = null;
+      // json[r'expiresAt'] = null;
     }
     if (this.allowUpload != null) {
       json[r'allowUpload'] = this.allowUpload;
@@ -118,11 +101,6 @@ class EditSharedLinkDto {
     } else {
       // json[r'showExif'] = null;
     }
-    if (this.isEditExpireTime != null) {
-      json[r'isEditExpireTime'] = this.isEditExpireTime;
-    } else {
-      // json[r'isEditExpireTime'] = null;
-    }
     return json;
   }
 
@@ -146,11 +124,10 @@ class EditSharedLinkDto {
 
       return EditSharedLinkDto(
         description: mapValueOfType<String>(json, r'description'),
-        expiredAt: mapValueOfType<String>(json, r'expiredAt'),
+        expiresAt: mapValueOfType<String>(json, r'expiresAt'),
         allowUpload: mapValueOfType<bool>(json, r'allowUpload'),
         allowDownload: mapValueOfType<bool>(json, r'allowDownload'),
         showExif: mapValueOfType<bool>(json, r'showExif'),
-        isEditExpireTime: mapValueOfType<bool>(json, r'isEditExpireTime'),
       );
     }
     return null;

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

@@ -21,8 +21,8 @@ void main() {
       // TODO
     });
 
-    // String expiredAt
-    test('to test the property `expiredAt`', () async {
+    // String expiresAt
+    test('to test the property `expiresAt`', () async {
       // TODO
     });
 

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

@@ -21,8 +21,8 @@ void main() {
       // TODO
     });
 
-    // String expiredAt
-    test('to test the property `expiredAt`', () async {
+    // String expiresAt
+    test('to test the property `expiresAt`', () async {
       // TODO
     });
 

+ 2 - 7
mobile/openapi/test/edit_shared_link_dto_test.dart

@@ -21,8 +21,8 @@ void main() {
       // TODO
     });
 
-    // String expiredAt
-    test('to test the property `expiredAt`', () async {
+    // String expiresAt
+    test('to test the property `expiresAt`', () async {
       // TODO
     });
 
@@ -41,11 +41,6 @@ void main() {
       // TODO
     });
 
-    // bool isEditExpireTime
-    test('to test the property `isEditExpireTime`', () async {
-      // TODO
-    });
-
 
   });
 

+ 1 - 1
mobile/openapi/test/share_api_test.dart

@@ -47,7 +47,7 @@ void main() {
 
     // 
     //
-    //Future<String> removeSharedLink(String id) async
+    //Future removeSharedLink(String id) async
     test('test removeSharedLink', () async {
       // TODO
     });

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

@@ -23,7 +23,7 @@ import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { UpdateAlbumDto } from './dto/update-album.dto';
 import { GetAlbumsDto } from './dto/get-albums.dto';
 import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
-import { AlbumResponseDto } from './response-dto/album-response.dto';
+import { AlbumResponseDto } from '@app/domain';
 import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 import { Response as Res } from 'express';

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

@@ -6,7 +6,6 @@ import { AlbumEntity, AssetAlbumEntity, UserAlbumEntity } from '@app/infra';
 import { AlbumRepository, IAlbumRepository } from './album-repository';
 import { DownloadModule } from '../../modules/download/download.module';
 import { AssetModule } from '../asset/asset.module';
-import { ShareModule } from '../share/share.module';
 
 const ALBUM_REPOSITORY_PROVIDER = {
   provide: IAlbumRepository,
@@ -18,7 +17,6 @@ const ALBUM_REPOSITORY_PROVIDER = {
     TypeOrmModule.forFeature([AlbumEntity, AssetAlbumEntity, UserAlbumEntity]),
     DownloadModule,
     forwardRef(() => AssetModule),
-    ShareModule,
   ],
   controllers: [AlbumController],
   providers: [AlbumService, ALBUM_REPOSITORY_PROVIDER],

+ 13 - 13
server/apps/immich/src/api-v1/album/album.service.spec.ts

@@ -2,17 +2,19 @@ import { AlbumService } from './album.service';
 import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { BadRequestException, NotFoundException, ForbiddenException } from '@nestjs/common';
 import { AlbumEntity } from '@app/infra';
-import { AlbumResponseDto } from './response-dto/album-response.dto';
+import { AlbumResponseDto, ICryptoRepository } from '@app/domain';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 import { IAlbumRepository } from './album-repository';
 import { DownloadService } from '../../modules/download/download.service';
-import { ISharedLinkRepository } from '../share/shared-link.repository';
+import { ISharedLinkRepository } from '@app/domain';
+import { newCryptoRepositoryMock, newSharedLinkRepositoryMock } from '@app/domain/../test';
 
 describe('Album service', () => {
   let sut: AlbumService;
   let albumRepositoryMock: jest.Mocked<IAlbumRepository>;
   let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
   let downloadServiceMock: jest.Mocked<Partial<DownloadService>>;
+  let cryptoMock: jest.Mocked<ICryptoRepository>;
 
   const authUser: AuthUserDto = Object.freeze({
     id: '1111',
@@ -129,22 +131,20 @@ describe('Album service', () => {
       getSharedWithUserAlbumCount: jest.fn(),
     };
 
-    sharedLinkRepositoryMock = {
-      create: jest.fn(),
-      remove: jest.fn(),
-      get: jest.fn(),
-      getById: jest.fn(),
-      getByKey: jest.fn(),
-      save: jest.fn(),
-      hasAssetAccess: jest.fn(),
-      getByIdAndUserId: jest.fn(),
-    };
+    sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
 
     downloadServiceMock = {
       downloadArchive: jest.fn(),
     };
 
-    sut = new AlbumService(albumRepositoryMock, sharedLinkRepositoryMock, downloadServiceMock as DownloadService);
+    cryptoMock = newCryptoRepositoryMock();
+
+    sut = new AlbumService(
+      albumRepositoryMock,
+      sharedLinkRepositoryMock,
+      downloadServiceMock as DownloadService,
+      cryptoMock,
+    );
   });
 
   it('creates album', async () => {

+ 10 - 11
server/apps/immich/src/api-v1/album/album.service.ts

@@ -6,16 +6,14 @@ import { AddUsersDto } from './dto/add-users.dto';
 import { RemoveAssetsDto } from './dto/remove-assets.dto';
 import { UpdateAlbumDto } from './dto/update-album.dto';
 import { GetAlbumsDto } from './dto/get-albums.dto';
-import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from './response-dto/album-response.dto';
+import { AlbumResponseDto, mapAlbum, mapAlbumExcludeAssetInfo } from '@app/domain';
 import { IAlbumRepository } from './album-repository';
 import { AlbumCountResponseDto } from './response-dto/album-count-response.dto';
 import { AddAssetsResponseDto } from './response-dto/add-assets-response.dto';
 import { AddAssetsDto } from './dto/add-assets.dto';
 import { DownloadService } from '../../modules/download/download.service';
 import { DownloadDto } from '../asset/dto/download-library.dto';
-import { ShareCore } from '../share/share.core';
-import { ISharedLinkRepository } from '../share/shared-link.repository';
-import { mapSharedLink, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
+import { ShareCore, ISharedLinkRepository, mapSharedLink, SharedLinkResponseDto, ICryptoRepository } from '@app/domain';
 import { CreateAlbumShareLinkDto } from './dto/create-album-shared-link.dto';
 import _ from 'lodash';
 
@@ -26,10 +24,11 @@ export class AlbumService {
 
   constructor(
     @Inject(IAlbumRepository) private _albumRepository: IAlbumRepository,
-    @Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
+    @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
     private downloadService: DownloadService,
+    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
   ) {
-    this.shareCore = new ShareCore(sharedLinkRepository);
+    this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
   }
 
   private async _getAlbum({
@@ -102,7 +101,7 @@ export class AlbumService {
     const album = await this._getAlbum({ authUser, albumId });
 
     for (const sharedLink of album.sharedLinks) {
-      await this.shareCore.removeSharedLink(sharedLink.id, authUser.id);
+      await this.shareCore.remove(sharedLink.id, authUser.id);
     }
 
     await this._albumRepository.delete(album);
@@ -203,11 +202,11 @@ export class AlbumService {
   async createAlbumSharedLink(authUser: AuthUserDto, dto: CreateAlbumShareLinkDto): Promise<SharedLinkResponseDto> {
     const album = await this._getAlbum({ authUser, albumId: dto.albumId });
 
-    const sharedLink = await this.shareCore.createSharedLink(authUser.id, {
-      sharedType: SharedLinkType.ALBUM,
-      expiredAt: dto.expiredAt,
+    const sharedLink = await this.shareCore.create(authUser.id, {
+      type: SharedLinkType.ALBUM,
+      expiresAt: dto.expiresAt,
       allowUpload: dto.allowUpload,
-      album: album,
+      album,
       assets: [],
       description: dto.description,
       allowDownload: dto.allowDownload,

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

@@ -7,7 +7,7 @@ export class CreateAlbumShareLinkDto {
 
   @IsString()
   @IsOptional()
-  expiredAt?: string;
+  expiresAt?: string;
 
   @IsBoolean()
   @IsOptional()

+ 1 - 1
server/apps/immich/src/api-v1/album/response-dto/add-assets-response.dto.ts

@@ -1,5 +1,5 @@
 import { ApiProperty } from '@nestjs/swagger';
-import { AlbumResponseDto } from './album-response.dto';
+import { AlbumResponseDto } from '@app/domain';
 
 export class AddAssetsResponseDto {
   @ApiProperty({ type: 'integer' })

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

@@ -30,7 +30,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 import { ApiBearerAuth, ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
 import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
 import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
-import { AssetResponseDto } from './response-dto/asset-response.dto';
+import { AssetResponseDto } from '@app/domain';
 import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
 import { AssetFileUploadDto } from './dto/asset-file-upload.dto';
 import { CreateAssetDto } from './dto/create-asset.dto';
@@ -52,7 +52,7 @@ import {
 } from '../../constants/download.constant';
 import { DownloadFilesDto } from './dto/download-files.dto';
 import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
-import { SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
+import { SharedLinkResponseDto } from '@app/domain';
 import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
 import { AssetSearchDto } from './dto/asset-search.dto';
 

+ 0 - 2
server/apps/immich/src/api-v1/asset/asset.module.ts

@@ -11,7 +11,6 @@ import { DownloadModule } from '../../modules/download/download.module';
 import { TagModule } from '../tag/tag.module';
 import { AlbumModule } from '../album/album.module';
 import { StorageModule } from '@app/storage';
-import { ShareModule } from '../share/share.module';
 
 const ASSET_REPOSITORY_PROVIDER = {
   provide: IAssetRepository,
@@ -27,7 +26,6 @@ const ASSET_REPOSITORY_PROVIDER = {
     TagModule,
     StorageModule,
     forwardRef(() => AlbumModule),
-    ShareModule,
   ],
   controllers: [AssetController],
   providers: [AssetService, BackgroundTaskService, ASSET_REPOSITORY_PROVIDER],

+ 84 - 14
server/apps/immich/src/api-v1/asset/asset.service.spec.ts

@@ -9,11 +9,19 @@ import { TimeGroupEnum } from './dto/get-asset-count-by-time-bucket.dto';
 import { AssetCountByUserIdResponseDto } from './response-dto/asset-count-by-user-id-response.dto';
 import { DownloadService } from '../../modules/download/download.service';
 import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
-import { IAlbumRepository } from '../album/album-repository';
+import { AlbumRepository, IAlbumRepository } from '../album/album-repository';
 import { StorageService } from '@app/storage';
-import { ISharedLinkRepository } from '../share/shared-link.repository';
-import { IJobRepository } from '@app/domain';
-import { newJobRepositoryMock } from '@app/domain/../test';
+import { ICryptoRepository, IJobRepository, ISharedLinkRepository } from '@app/domain';
+import {
+  authStub,
+  newCryptoRepositoryMock,
+  newJobRepositoryMock,
+  newSharedLinkRepositoryMock,
+  sharedLinkResponseStub,
+  sharedLinkStub,
+} from '@app/domain/../test';
+import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
+import { BadRequestException, ForbiddenException } from '@nestjs/common';
 
 describe('AssetService', () => {
   let sui: AssetService;
@@ -24,6 +32,7 @@ describe('AssetService', () => {
   let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
   let storageSeriveMock: jest.Mocked<StorageService>;
   let sharedLinkRepositoryMock: jest.Mocked<ISharedLinkRepository>;
+  let cryptoMock: jest.Mocked<ICryptoRepository>;
   let jobMock: jest.Mocked<IJobRepository>;
   const authUser: AuthUserDto = Object.freeze({
     id: 'user_id_1',
@@ -132,22 +141,18 @@ describe('AssetService', () => {
       countByIdAndUser: jest.fn(),
     };
 
+    albumRepositoryMock = {
+      getSharedWithUserAlbumCount: jest.fn(),
+    } as unknown as jest.Mocked<AlbumRepository>;
+
     downloadServiceMock = {
       downloadArchive: jest.fn(),
     };
 
-    sharedLinkRepositoryMock = {
-      create: jest.fn(),
-      get: jest.fn(),
-      getById: jest.fn(),
-      getByKey: jest.fn(),
-      remove: jest.fn(),
-      save: jest.fn(),
-      hasAssetAccess: jest.fn(),
-      getByIdAndUserId: jest.fn(),
-    };
+    sharedLinkRepositoryMock = newSharedLinkRepositoryMock();
 
     jobMock = newJobRepositoryMock();
+    cryptoMock = newCryptoRepositoryMock();
 
     sui = new AssetService(
       assetRepositoryMock,
@@ -158,9 +163,64 @@ describe('AssetService', () => {
       storageSeriveMock,
       sharedLinkRepositoryMock,
       jobMock,
+      cryptoMock,
     );
   });
 
+  describe('createAssetsSharedLink', () => {
+    it('should create an individual share link', async () => {
+      const asset1 = _getAsset_1();
+      const dto: CreateAssetsShareLinkDto = { assetIds: [asset1.id] };
+
+      assetRepositoryMock.getById.mockResolvedValue(asset1);
+      assetRepositoryMock.countByIdAndUser.mockResolvedValue(1);
+      sharedLinkRepositoryMock.create.mockResolvedValue(sharedLinkStub.valid);
+
+      await expect(sui.createAssetsSharedLink(authStub.user1, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
+
+      expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
+      expect(assetRepositoryMock.countByIdAndUser).toHaveBeenCalledWith(asset1.id, authStub.user1.id);
+    });
+  });
+
+  describe('updateAssetsInSharedLink', () => {
+    it('should require a valid shared link', async () => {
+      const asset1 = _getAsset_1();
+
+      const authDto = authStub.adminSharedLink;
+      const dto = { assetIds: [asset1.id] };
+
+      assetRepositoryMock.getById.mockResolvedValue(asset1);
+      sharedLinkRepositoryMock.get.mockResolvedValue(null);
+      sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
+
+      await expect(sui.updateAssetsInSharedLink(authDto, dto)).rejects.toBeInstanceOf(BadRequestException);
+
+      expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
+      expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
+      expect(sharedLinkRepositoryMock.hasAssetAccess).toHaveBeenCalledWith(authDto.sharedLinkId, asset1.id);
+      expect(sharedLinkRepositoryMock.save).not.toHaveBeenCalled();
+    });
+
+    it('should remove assets from a shared link', async () => {
+      const asset1 = _getAsset_1();
+
+      const authDto = authStub.adminSharedLink;
+      const dto = { assetIds: [asset1.id] };
+
+      assetRepositoryMock.getById.mockResolvedValue(asset1);
+      sharedLinkRepositoryMock.get.mockResolvedValue(sharedLinkStub.valid);
+      sharedLinkRepositoryMock.hasAssetAccess.mockResolvedValue(true);
+      sharedLinkRepositoryMock.save.mockResolvedValue(sharedLinkStub.valid);
+
+      await expect(sui.updateAssetsInSharedLink(authDto, dto)).resolves.toEqual(sharedLinkResponseStub.valid);
+
+      expect(assetRepositoryMock.getById).toHaveBeenCalledWith(asset1.id);
+      expect(sharedLinkRepositoryMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
+      expect(sharedLinkRepositoryMock.hasAssetAccess).toHaveBeenCalledWith(authDto.sharedLinkId, asset1.id);
+    });
+  });
+
   // Currently failing due to calculate checksum from a file
   it('create an asset', async () => {
     const assetEntity = _getAsset_1();
@@ -224,4 +284,14 @@ describe('AssetService', () => {
 
     expect(result).toEqual(assetCount);
   });
+
+  describe('checkDownloadAccess', () => {
+    it('should validate download access', async () => {
+      await sui.checkDownloadAccess(authStub.adminSharedLink);
+    });
+
+    it('should not allow when user is not allowed to download', async () => {
+      expect(() => sui.checkDownloadAccess(authStub.readonlySharedLink)).toThrow(ForbiddenException);
+    });
+  });
 });

+ 31 - 25
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, mapAssetWithoutExif } from './response-dto/asset-response.dto';
+import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '@app/domain';
 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';
@@ -43,16 +43,16 @@ import { CheckExistingAssetsResponseDto } from './response-dto/check-existing-as
 import { UpdateAssetDto } from './dto/update-asset.dto';
 import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
 import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
-import { IJobRepository, JobName } from '@app/domain';
+import { ICryptoRepository, IJobRepository, JobName } from '@app/domain';
 import { DownloadService } from '../../modules/download/download.service';
 import { DownloadDto } from './dto/download-library.dto';
 import { IAlbumRepository } from '../album/album-repository';
 import { StorageService } from '@app/storage';
-import { ShareCore } from '../share/share.core';
-import { ISharedLinkRepository } from '../share/shared-link.repository';
+import { ShareCore } from '@app/domain';
+import { ISharedLinkRepository } from '@app/domain';
 import { DownloadFilesDto } from './dto/download-files.dto';
 import { CreateAssetsShareLinkDto } from './dto/create-asset-shared-link.dto';
-import { mapSharedLink, SharedLinkResponseDto } from '../share/response-dto/shared-link-response.dto';
+import { mapSharedLink, SharedLinkResponseDto } from '@app/domain';
 import { UpdateAssetsToSharedLinkDto } from './dto/add-assets-to-shared-link.dto';
 import { AssetSearchDto } from './dto/asset-search.dto';
 
@@ -73,8 +73,9 @@ export class AssetService {
     private storageService: StorageService,
     @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
     @Inject(IJobRepository) private jobRepository: IJobRepository,
+    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
   ) {
-    this.shareCore = new ShareCore(sharedLinkRepository);
+    this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
   }
 
   public async handleUploadedAsset(
@@ -669,23 +670,24 @@ export class AssetService {
       // Step 1: Check if asset is part of a public shared
       if (authUser.sharedLinkId) {
         const canAccess = await this.shareCore.hasAssetAccess(authUser.sharedLinkId, assetId);
-        if (!canAccess) {
-          throw new ForbiddenException();
+        if (canAccess) {
+          continue;
         }
-      }
-
-      // Step 2: Check if user owns asset
-      if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
-        continue;
-      }
-
-      // Avoid additional checks if ownership is required
-      if (!mustBeOwner) {
-        // Step 2: Check if asset is part of an album shared with me
-        if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
+      } else {
+        // Step 2: Check if user owns asset
+        if ((await this._assetRepository.countByIdAndUser(assetId, authUser.id)) == 1) {
           continue;
         }
+
+        // Avoid additional checks if ownership is required
+        if (!mustBeOwner) {
+          // Step 2: Check if asset is part of an album shared with me
+          if ((await this._albumRepository.getSharedWithUserAlbumCount(authUser.id, assetId)) > 0) {
+            continue;
+          }
+        }
       }
+
       throw new ForbiddenException();
     }
   }
@@ -703,11 +705,11 @@ export class AssetService {
       assets.push(asset);
     }
 
-    const sharedLink = await this.shareCore.createSharedLink(authUser.id, {
-      sharedType: SharedLinkType.INDIVIDUAL,
-      expiredAt: dto.expiredAt,
+    const sharedLink = await this.shareCore.create(authUser.id, {
+      type: SharedLinkType.INDIVIDUAL,
+      expiresAt: dto.expiresAt,
       allowUpload: dto.allowUpload,
-      assets: assets,
+      assets,
       description: dto.description,
       allowDownload: dto.allowDownload,
       showExif: dto.showExif,
@@ -720,15 +722,19 @@ export class AssetService {
     authUser: AuthUserDto,
     dto: UpdateAssetsToSharedLinkDto,
   ): Promise<SharedLinkResponseDto> {
-    if (!authUser.sharedLinkId) throw new ForbiddenException();
+    if (!authUser.sharedLinkId) {
+      throw new ForbiddenException();
+    }
+
     const assets = [];
 
+    await this.checkAssetsAccess(authUser, dto.assetIds);
     for (const assetId of dto.assetIds) {
       const asset = await this._assetRepository.getById(assetId);
       assets.push(asset);
     }
 
-    const updatedLink = await this.shareCore.updateAssetsInSharedLink(authUser.sharedLinkId, assets);
+    const updatedLink = await this.shareCore.updateAssets(authUser.id, authUser.sharedLinkId, assets);
     return mapSharedLink(updatedLink);
   }
 

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

@@ -19,7 +19,7 @@ export class CreateAssetsShareLinkDto {
 
   @IsString()
   @IsOptional()
-  expiredAt?: string;
+  expiresAt?: string;
 
   @IsBoolean()
   @IsOptional()

+ 0 - 101
server/apps/immich/src/api-v1/share/share.core.ts

@@ -1,101 +0,0 @@
-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, 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);
-
-  constructor(private sharedLinkRepository: ISharedLinkRepository) {}
-
-  async createSharedLink(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
-    try {
-      const sharedLink = new SharedLinkEntity();
-
-      sharedLink.key = Buffer.from(crypto.randomBytes(50));
-      sharedLink.description = dto.description;
-      sharedLink.userId = userId;
-      sharedLink.createdAt = new Date().toISOString();
-      sharedLink.expiresAt = dto.expiredAt ?? null;
-      sharedLink.type = dto.sharedType;
-      sharedLink.assets = dto.assets;
-      sharedLink.album = dto.album;
-      sharedLink.allowUpload = dto.allowUpload ?? false;
-      sharedLink.allowDownload = dto.allowDownload ?? true;
-      sharedLink.showExif = dto.showExif ?? true;
-
-      return this.sharedLinkRepository.create(sharedLink);
-    } catch (error: any) {
-      this.logger.error(error, error.stack);
-      throw new InternalServerErrorException('failed to create shared link');
-    }
-  }
-
-  getSharedLinks(userId: string): Promise<SharedLinkEntity[]> {
-    return this.sharedLinkRepository.get(userId);
-  }
-
-  async removeSharedLink(id: string, userId: string): Promise<SharedLinkEntity> {
-    const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId);
-
-    if (!link) {
-      throw new BadRequestException('Shared link not found');
-    }
-
-    return await this.sharedLinkRepository.remove(link);
-  }
-
-  getSharedLinkById(id: string): Promise<SharedLinkEntity | null> {
-    return this.sharedLinkRepository.getById(id);
-  }
-
-  getSharedLinkByKey(key: string): Promise<SharedLinkEntity | null> {
-    return this.sharedLinkRepository.getByKey(key);
-  }
-
-  async updateAssetsInSharedLink(sharedLinkId: string, assets: AssetEntity[]) {
-    const link = await this.getSharedLinkById(sharedLinkId);
-    if (!link) {
-      throw new BadRequestException('Shared link not found');
-    }
-
-    link.assets = assets;
-
-    return await this.sharedLinkRepository.save(link);
-  }
-
-  async updateSharedLink(id: string, userId: string, dto: EditSharedLinkDto): Promise<SharedLinkEntity> {
-    const link = await this.sharedLinkRepository.getByIdAndUserId(id, userId);
-
-    if (!link) {
-      throw new BadRequestException('Shared link not found');
-    }
-
-    link.description = dto.description ?? link.description;
-    link.allowUpload = dto.allowUpload ?? link.allowUpload;
-    link.allowDownload = dto.allowDownload ?? link.allowDownload;
-    link.showExif = dto.showExif ?? link.showExif;
-
-    if (dto.isEditExpireTime && dto.expiredAt) {
-      link.expiresAt = dto.expiredAt;
-    } else if (dto.isEditExpireTime && !dto.expiredAt) {
-      link.expiresAt = null;
-    }
-
-    return await this.sharedLinkRepository.save(link);
-  }
-
-  async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
-    return this.sharedLinkRepository.hasAssetAccess(id, assetId);
-  }
-
-  checkDownloadAccess(user: AuthUserDto) {
-    if (user.isPublicUser && !user.isAllowDownload) {
-      throw new ForbiddenException();
-    }
-  }
-}

+ 0 - 19
server/apps/immich/src/api-v1/share/share.module.ts

@@ -1,19 +0,0 @@
-import { Module } from '@nestjs/common';
-import { ShareService } from './share.service';
-import { ShareController } from './share.controller';
-import { SharedLinkEntity } from '@app/infra';
-import { TypeOrmModule } from '@nestjs/typeorm';
-import { SharedLinkRepository, ISharedLinkRepository } from './shared-link.repository';
-
-const SHARED_LINK_REPOSITORY_PROVIDER = {
-  provide: ISharedLinkRepository,
-  useClass: SharedLinkRepository,
-};
-
-@Module({
-  imports: [TypeOrmModule.forFeature([SharedLinkEntity])],
-  controllers: [ShareController],
-  providers: [ShareService, SHARED_LINK_REPOSITORY_PROVIDER],
-  exports: [SHARED_LINK_REPOSITORY_PROVIDER, ShareService],
-})
-export class ShareModule {}

+ 0 - 137
server/apps/immich/src/api-v1/share/shared-link.repository.ts

@@ -1,137 +0,0 @@
-import { SharedLinkEntity } from '@app/infra';
-import { InjectRepository } from '@nestjs/typeorm';
-import { Repository } from 'typeorm';
-
-import { Logger } from '@nestjs/common';
-
-export interface ISharedLinkRepository {
-  get(userId: string): Promise<SharedLinkEntity[]>;
-  getById(id: string): Promise<SharedLinkEntity | null>;
-  getByIdAndUserId(id: string, userId: string): Promise<SharedLinkEntity | null>;
-  getByKey(key: string): Promise<SharedLinkEntity | null>;
-  create(payload: SharedLinkEntity): Promise<SharedLinkEntity>;
-  remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
-  save(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
-  hasAssetAccess(id: string, assetId: string): Promise<boolean>;
-}
-
-export const ISharedLinkRepository = 'ISharedLinkRepository';
-
-export class SharedLinkRepository implements ISharedLinkRepository {
-  readonly logger = new Logger(SharedLinkRepository.name);
-  constructor(
-    @InjectRepository(SharedLinkEntity)
-    private readonly sharedLinkRepository: Repository<SharedLinkEntity>,
-  ) {}
-  async getByIdAndUserId(id: string, userId: string): Promise<SharedLinkEntity | null> {
-    return await this.sharedLinkRepository.findOne({
-      where: {
-        userId: userId,
-        id: id,
-      },
-      order: {
-        createdAt: 'DESC',
-      },
-    });
-  }
-
-  async get(userId: string): Promise<SharedLinkEntity[]> {
-    return await this.sharedLinkRepository.find({
-      where: {
-        userId: userId,
-      },
-      relations: ['assets', 'album'],
-      order: {
-        createdAt: 'DESC',
-      },
-    });
-  }
-
-  async create(payload: SharedLinkEntity): Promise<SharedLinkEntity> {
-    return await this.sharedLinkRepository.save(payload);
-  }
-
-  async getById(id: string): Promise<SharedLinkEntity | null> {
-    return await this.sharedLinkRepository.findOne({
-      where: {
-        id: id,
-      },
-      relations: {
-        assets: {
-          exifInfo: true,
-        },
-        album: {
-          assets: {
-            assetInfo: {
-              exifInfo: true,
-            },
-          },
-        },
-      },
-      order: {
-        createdAt: 'DESC',
-        assets: {
-          createdAt: 'ASC',
-        },
-        album: {
-          assets: {
-            assetInfo: {
-              createdAt: 'ASC',
-            },
-          },
-        },
-      },
-    });
-  }
-
-  async getByKey(key: string): Promise<SharedLinkEntity | null> {
-    return await this.sharedLinkRepository.findOne({
-      where: {
-        key: Buffer.from(key, 'hex'),
-      },
-      relations: {
-        assets: true,
-        album: {
-          assets: {
-            assetInfo: true,
-          },
-        },
-      },
-      order: {
-        createdAt: 'DESC',
-      },
-    });
-  }
-
-  async remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
-    return await this.sharedLinkRepository.remove(entity);
-  }
-
-  async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
-    return await this.sharedLinkRepository.save(entity);
-  }
-
-  async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
-    const count1 = await this.sharedLinkRepository.count({
-      where: {
-        id,
-        assets: {
-          id: assetId,
-        },
-      },
-    });
-
-    const count2 = await this.sharedLinkRepository.count({
-      where: {
-        id,
-        album: {
-          assets: {
-            assetId,
-          },
-        },
-      },
-    });
-
-    return Boolean(count1 + count2);
-  }
-}

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

@@ -5,7 +5,7 @@ import { UpdateTagDto } from './dto/update-tag.dto';
 import { Authenticated } from '../../decorators/authenticated.decorator';
 import { ApiTags } from '@nestjs/swagger';
 import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
-import { mapTag, TagResponseDto } from './response-dto/tag-response.dto';
+import { mapTag, TagResponseDto } from '@app/domain';
 
 @Authenticated()
 @ApiTags('Tag')

+ 1 - 1
server/apps/immich/src/api-v1/tag/tag.service.ts

@@ -4,7 +4,7 @@ import { AuthUserDto } from '../../decorators/auth-user.decorator';
 import { CreateTagDto } from './dto/create-tag.dto';
 import { UpdateTagDto } from './dto/update-tag.dto';
 import { ITagRepository } from './tag.repository';
-import { mapTag, TagResponseDto } from './response-dto/tag-response.dto';
+import { mapTag, TagResponseDto } from '@app/domain';
 
 @Injectable()
 export class TagService {

+ 2 - 3
server/apps/immich/src/app.module.ts

@@ -13,13 +13,13 @@ import { ScheduleModule } from '@nestjs/schedule';
 import { ScheduleTasksModule } from './modules/schedule-tasks/schedule-tasks.module';
 import { JobModule } from './api-v1/job/job.module';
 import { TagModule } from './api-v1/tag/tag.module';
-import { ShareModule } from './api-v1/share/share.module';
 import { DomainModule } from '@app/domain';
 import { InfraModule } from '@app/infra';
 import {
   APIKeyController,
   AuthController,
   OAuthController,
+  ShareController,
   SystemConfigController,
   UserController,
 } from './controllers';
@@ -53,8 +53,6 @@ import {
     JobModule,
 
     TagModule,
-
-    ShareModule,
   ],
   controllers: [
     //
@@ -62,6 +60,7 @@ import {
     APIKeyController,
     AuthController,
     OAuthController,
+    ShareController,
     SystemConfigController,
     UserController,
   ],

+ 3 - 7
server/apps/immich/src/config/asset-upload.config.ts

@@ -23,7 +23,7 @@ export const assetUploadOption: MulterOptions = {
 export const multerUtils = { fileFilter, filename, destination };
 
 function fileFilter(req: Request, file: any, cb: any) {
-  if (!req.user) {
+  if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
     return cb(new UnauthorizedException());
   }
   if (
@@ -39,16 +39,12 @@ function fileFilter(req: Request, file: any, cb: any) {
 }
 
 function destination(req: Request, file: Express.Multer.File, cb: any) {
-  if (!req.user) {
+  if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
     return cb(new UnauthorizedException());
   }
 
   const user = req.user as AuthUserDto;
 
-  if (user.isPublicUser && !user.isAllowUpload) {
-    return cb(new UnauthorizedException());
-  }
-
   const basePath = APP_UPLOAD_LOCATION;
   const sanitizedDeviceId = sanitize(String(req.body['deviceId']));
   const originalUploadFolder = join(basePath, user.id, 'original', sanitizedDeviceId);
@@ -62,7 +58,7 @@ function destination(req: Request, file: Express.Multer.File, cb: any) {
 }
 
 function filename(req: Request, file: Express.Multer.File, cb: any) {
-  if (!req.user) {
+  if (!req.user || (req.user.isPublicUser && !req.user.isAllowUpload)) {
     return cb(new UnauthorizedException());
   }
 

+ 1 - 0
server/apps/immich/src/controllers/index.ts

@@ -1,5 +1,6 @@
 export * from './api-key.controller';
 export * from './auth.controller';
 export * from './oauth.controller';
+export * from './share.controller';
 export * from './system-config.controller';
 export * from './user.controller';

+ 9 - 11
server/apps/immich/src/api-v1/share/share.controller.ts → server/apps/immich/src/controllers/share.controller.ts

@@ -1,10 +1,8 @@
 import { Body, Controller, Delete, Get, Param, Patch, ValidationPipe } from '@nestjs/common';
 import { ApiTags } from '@nestjs/swagger';
-import { AuthUserDto, GetAuthUser } from '../../decorators/auth-user.decorator';
-import { Authenticated } from '../../decorators/authenticated.decorator';
-import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
-import { SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
-import { ShareService } from './share.service';
+import { GetAuthUser } from '../decorators/auth-user.decorator';
+import { Authenticated } from '../decorators/authenticated.decorator';
+import { AuthUserDto, EditSharedLinkDto, SharedLinkResponseDto, ShareService } from '@app/domain';
 
 @ApiTags('share')
 @Controller('share')
@@ -24,23 +22,23 @@ export class ShareController {
 
   @Authenticated()
   @Get(':id')
-  getSharedLinkById(@Param('id') id: string): Promise<SharedLinkResponseDto> {
-    return this.shareService.getById(id, true);
+  getSharedLinkById(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string): Promise<SharedLinkResponseDto> {
+    return this.shareService.getById(authUser, id, true);
   }
 
   @Authenticated()
   @Delete(':id')
-  removeSharedLink(@Param('id') id: string, @GetAuthUser() authUser: AuthUserDto): Promise<string> {
-    return this.shareService.remove(id, authUser.id);
+  removeSharedLink(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string): Promise<void> {
+    return this.shareService.remove(authUser, id);
   }
 
   @Authenticated()
   @Patch(':id')
   editSharedLink(
-    @Param('id') id: string,
     @GetAuthUser() authUser: AuthUserDto,
+    @Param('id') id: string,
     @Body(new ValidationPipe()) dto: EditSharedLinkDto,
   ): Promise<SharedLinkResponseDto> {
-    return this.shareService.edit(id, authUser, dto);
+    return this.shareService.edit(authUser, id, dto);
   }
 }

+ 0 - 2
server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts

@@ -1,11 +1,9 @@
 import { Module } from '@nestjs/common';
-import { ShareModule } from '../../api-v1/share/share.module';
 import { APIKeyStrategy } from './strategies/api-key.strategy';
 import { JwtStrategy } from './strategies/jwt.strategy';
 import { PublicShareStrategy } from './strategies/public-share.strategy';
 
 @Module({
-  imports: [ShareModule],
   providers: [JwtStrategy, APIKeyStrategy, PublicShareStrategy],
 })
 export class ImmichJwtModule {}

+ 1 - 2
server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts

@@ -1,8 +1,7 @@
 import { Injectable } from '@nestjs/common';
 import { PassportStrategy } from '@nestjs/passport';
 import { IStrategyOptions, Strategy } from 'passport-http-header-strategy';
-import { ShareService } from '../../../api-v1/share/share.service';
-import { AuthUserDto } from '../../../decorators/auth-user.decorator';
+import { AuthUserDto, ShareService } from '@app/domain';
 
 export const PUBLIC_SHARE_STRATEGY = 'public-share';
 

+ 1 - 1
server/apps/microservices/src/processors/thumbnail.processor.ts

@@ -4,7 +4,7 @@ import { WebpGeneratorProcessor, JpegGeneratorProcessor, QueueName, JobName } fr
 import { InjectQueue, Process, Processor } from '@nestjs/bull';
 import { Logger } from '@nestjs/common';
 import { InjectRepository } from '@nestjs/typeorm';
-import { mapAsset } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
+import { mapAsset } from '@app/domain';
 import { Job, Queue } from 'bull';
 import ffmpeg from 'fluent-ffmpeg';
 import { existsSync, mkdirSync } from 'node:fs';

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 688 - 560
server/immich-openapi-specs.json


+ 1 - 1
server/libs/common/src/utils/asset-utils.ts

@@ -1,5 +1,5 @@
 import { AssetEntity } from '@app/infra';
-import { AssetResponseDto } from 'apps/immich/src/api-v1/asset/response-dto/asset-response.dto';
+import { AssetResponseDto } from '@app/domain';
 import fs from 'fs';
 
 const deleteFiles = (asset: AssetEntity | AssetResponseDto) => {

+ 1 - 0
server/libs/domain/src/album/index.ts

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

+ 3 - 3
server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts → server/libs/domain/src/album/response-dto/album-response.dto.ts

@@ -1,7 +1,7 @@
-import { AlbumEntity } from '@app/infra';
-import { UserResponseDto, mapUser } from '@app/domain';
-import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
+import { AlbumEntity } from '@app/infra/db/entities';
 import { ApiProperty } from '@nestjs/swagger';
+import { AssetResponseDto, mapAsset } from '../../asset';
+import { mapUser, UserResponseDto } from '../../user';
 
 export class AlbumResponseDto {
   id!: string;

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

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

+ 1 - 0
server/libs/domain/src/asset/index.ts

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

+ 2 - 2
server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts → server/libs/domain/src/asset/response-dto/asset-response.dto.ts

@@ -1,6 +1,6 @@
-import { AssetEntity, AssetType } from '@app/infra';
+import { AssetEntity, AssetType } from '@app/infra/db/entities';
 import { ApiProperty } from '@nestjs/swagger';
-import { mapTag, TagResponseDto } from '../../tag/response-dto/tag-response.dto';
+import { mapTag, TagResponseDto } from '../../tag';
 import { ExifResponseDto, mapExif } from './exif-response.dto';
 import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
 

+ 2 - 2
server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts → server/libs/domain/src/asset/response-dto/exif-response.dto.ts

@@ -1,4 +1,4 @@
-import { ExifEntity } from '@app/infra';
+import { ExifEntity } from '@app/infra/db/entities';
 import { ApiProperty } from '@nestjs/swagger';
 
 export class ExifResponseDto {
@@ -29,7 +29,7 @@ export class ExifResponseDto {
 
 export function mapExif(entity: ExifEntity): ExifResponseDto {
   return {
-    id: parseInt(entity.id),
+    id: entity.id,
     make: entity.make,
     model: entity.model,
     imageName: entity.imageName,

+ 3 - 0
server/libs/domain/src/asset/response-dto/index.ts

@@ -0,0 +1,3 @@
+export * from './asset-response.dto';
+export * from './exif-response.dto';
+export * from './smart-info-response.dto';

+ 1 - 1
server/apps/immich/src/api-v1/asset/response-dto/smart-info-response.dto.ts → server/libs/domain/src/asset/response-dto/smart-info-response.dto.ts

@@ -1,4 +1,4 @@
-import { SmartInfoEntity } from '@app/infra';
+import { SmartInfoEntity } from '@app/infra/db/entities';
 
 export class SmartInfoResponseDto {
   id?: string;

+ 2 - 0
server/libs/domain/src/domain.module.ts

@@ -1,5 +1,6 @@
 import { DynamicModule, Global, Module, ModuleMetadata, Provider } from '@nestjs/common';
 import { APIKeyService } from './api-key';
+import { ShareService } from './share';
 import { AuthService } from './auth';
 import { OAuthService } from './oauth';
 import { INITIAL_SYSTEM_CONFIG, SystemConfigService } from './system-config';
@@ -11,6 +12,7 @@ const providers: Provider[] = [
   OAuthService,
   SystemConfigService,
   UserService,
+  ShareService,
 
   {
     provide: INITIAL_SYSTEM_CONFIG,

+ 4 - 0
server/libs/domain/src/index.ts

@@ -1,7 +1,11 @@
+export * from './album';
 export * from './api-key';
+export * from './asset';
 export * from './auth';
 export * from './domain.module';
 export * from './job';
 export * from './oauth';
+export * from './share';
 export * from './system-config';
+export * from './tag';
 export * from './user';

+ 1 - 1
server/libs/domain/src/job/interfaces/metadata-extraction.interface.ts

@@ -25,7 +25,7 @@ export interface IVideoLengthExtractionProcessor {
 }
 
 export interface IReverseGeocodingProcessor {
-  exifId: string;
+  exifId: number;
   latitude: number;
   longitude: number;
 }

+ 3 - 4
server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts → server/libs/domain/src/share/dto/create-shared-link.dto.ts

@@ -1,10 +1,9 @@
-import { AlbumEntity, AssetEntity } from '@app/infra';
-import { SharedLinkType } from '@app/infra';
+import { AlbumEntity, AssetEntity, SharedLinkType } from '@app/infra/db/entities';
 
 export class CreateSharedLinkDto {
   description?: string;
-  expiredAt?: string;
-  sharedType!: SharedLinkType;
+  expiresAt?: string;
+  type!: SharedLinkType;
   assets!: AssetEntity[];
   album?: AlbumEntity;
   allowUpload?: boolean;

+ 2 - 5
server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts → server/libs/domain/src/share/dto/edit-shared-link.dto.ts

@@ -1,11 +1,11 @@
-import { IsNotEmpty, IsOptional } from 'class-validator';
+import { IsOptional } from 'class-validator';
 
 export class EditSharedLinkDto {
   @IsOptional()
   description?: string;
 
   @IsOptional()
-  expiredAt?: string;
+  expiresAt?: string | null;
 
   @IsOptional()
   allowUpload?: boolean;
@@ -15,7 +15,4 @@ export class EditSharedLinkDto {
 
   @IsOptional()
   showExif?: boolean;
-
-  @IsNotEmpty()
-  isEditExpireTime?: boolean;
 }

+ 2 - 0
server/libs/domain/src/share/dto/index.ts

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

+ 5 - 0
server/libs/domain/src/share/index.ts

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

+ 1 - 0
server/libs/domain/src/share/response-dto/index.ts

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

+ 3 - 3
server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts → server/libs/domain/src/share/response-dto/shared-link-response.dto.ts

@@ -1,8 +1,8 @@
-import { SharedLinkEntity, SharedLinkType } from '@app/infra';
+import { SharedLinkEntity, SharedLinkType } from '@app/infra/db/entities';
 import { ApiProperty } from '@nestjs/swagger';
 import _ from 'lodash';
-import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album/response-dto/album-response.dto';
-import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset/response-dto/asset-response.dto';
+import { AlbumResponseDto, mapAlbumExcludeAssetInfo } from '../../album';
+import { AssetResponseDto, mapAsset, mapAssetWithoutExif } from '../../asset';
 
 export class SharedLinkResponseDto {
   id!: string;

+ 81 - 0
server/libs/domain/src/share/share.core.ts

@@ -0,0 +1,81 @@
+import { AssetEntity, SharedLinkEntity } from '@app/infra/db/entities';
+import { BadRequestException, ForbiddenException, InternalServerErrorException, Logger } from '@nestjs/common';
+import { AuthUserDto, ICryptoRepository } from '../auth';
+import { CreateSharedLinkDto } from './dto';
+import { ISharedLinkRepository } from './shared-link.repository';
+
+export class ShareCore {
+  readonly logger = new Logger(ShareCore.name);
+
+  constructor(private repository: ISharedLinkRepository, private cryptoRepository: ICryptoRepository) {}
+
+  getAll(userId: string): Promise<SharedLinkEntity[]> {
+    return this.repository.getAll(userId);
+  }
+
+  get(userId: string, id: string): Promise<SharedLinkEntity | null> {
+    return this.repository.get(userId, id);
+  }
+
+  getByKey(key: string): Promise<SharedLinkEntity | null> {
+    return this.repository.getByKey(key);
+  }
+
+  create(userId: string, dto: CreateSharedLinkDto): Promise<SharedLinkEntity> {
+    try {
+      return this.repository.create({
+        key: Buffer.from(this.cryptoRepository.randomBytes(50)),
+        description: dto.description,
+        userId,
+        createdAt: new Date().toISOString(),
+        expiresAt: dto.expiresAt ?? null,
+        type: dto.type,
+        assets: dto.assets,
+        album: dto.album,
+        allowUpload: dto.allowUpload ?? false,
+        allowDownload: dto.allowDownload ?? true,
+        showExif: dto.showExif ?? true,
+      });
+    } catch (error: any) {
+      this.logger.error(error, error.stack);
+      throw new InternalServerErrorException('failed to create shared link');
+    }
+  }
+
+  async save(userId: string, id: string, entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity> {
+    const link = await this.get(userId, id);
+    if (!link) {
+      throw new BadRequestException('Shared link not found');
+    }
+
+    return this.repository.save({ ...entity, userId, id });
+  }
+
+  async remove(userId: string, id: string): Promise<SharedLinkEntity> {
+    const link = await this.get(userId, id);
+    if (!link) {
+      throw new BadRequestException('Shared link not found');
+    }
+
+    return this.repository.remove(link);
+  }
+
+  async updateAssets(userId: string, id: string, assets: AssetEntity[]) {
+    const link = await this.get(userId, id);
+    if (!link) {
+      throw new BadRequestException('Shared link not found');
+    }
+
+    return this.repository.save({ ...link, assets });
+  }
+
+  async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
+    return this.repository.hasAssetAccess(id, assetId);
+  }
+
+  checkDownloadAccess(user: AuthUserDto) {
+    if (user.isPublicUser && !user.isAllowDownload) {
+      throw new ForbiddenException();
+    }
+  }
+}

+ 170 - 0
server/libs/domain/src/share/share.service.spec.ts

@@ -0,0 +1,170 @@
+import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
+import {
+  authStub,
+  entityStub,
+  newCryptoRepositoryMock,
+  newSharedLinkRepositoryMock,
+  newUserRepositoryMock,
+  sharedLinkResponseStub,
+  sharedLinkStub,
+} from '../../test';
+import { ICryptoRepository } from '../auth';
+import { IUserRepository } from '../user';
+import { ShareService } from './share.service';
+import { ISharedLinkRepository } from './shared-link.repository';
+
+describe(ShareService.name, () => {
+  let sut: ShareService;
+  let cryptoMock: jest.Mocked<ICryptoRepository>;
+  let shareMock: jest.Mocked<ISharedLinkRepository>;
+  let userMock: jest.Mocked<IUserRepository>;
+
+  beforeEach(async () => {
+    cryptoMock = newCryptoRepositoryMock();
+    shareMock = newSharedLinkRepositoryMock();
+    userMock = newUserRepositoryMock();
+
+    sut = new ShareService(cryptoMock, shareMock, userMock);
+  });
+
+  it('should work', () => {
+    expect(sut).toBeDefined();
+  });
+
+  describe('validate', () => {
+    it('should not accept a non-existant key', async () => {
+      shareMock.getByKey.mockResolvedValue(null);
+      await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
+    });
+
+    it('should not accept an expired key', async () => {
+      shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
+      await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
+    });
+
+    it('should not accept a key without a user', async () => {
+      shareMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
+      userMock.get.mockResolvedValue(null);
+      await expect(sut.validate('key')).rejects.toBeInstanceOf(UnauthorizedException);
+    });
+
+    it('should accept a valid key', async () => {
+      shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
+      userMock.get.mockResolvedValue(entityStub.admin);
+      await expect(sut.validate('key')).resolves.toEqual(authStub.adminSharedLink);
+    });
+  });
+
+  describe('getAll', () => {
+    it('should return all keys for a user', async () => {
+      shareMock.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
+      await expect(sut.getAll(authStub.user1)).resolves.toEqual([
+        sharedLinkResponseStub.expired,
+        sharedLinkResponseStub.valid,
+      ]);
+      expect(shareMock.getAll).toHaveBeenCalledWith(authStub.user1.id);
+    });
+  });
+
+  describe('getMine', () => {
+    it('should only work for a public user', async () => {
+      await expect(sut.getMine(authStub.admin)).rejects.toBeInstanceOf(ForbiddenException);
+      expect(shareMock.get).not.toHaveBeenCalled();
+    });
+
+    it('should return the key for the public user (auth dto)', async () => {
+      const authDto = authStub.adminSharedLink;
+      shareMock.get.mockResolvedValue(sharedLinkStub.valid);
+      await expect(sut.getMine(authDto)).resolves.toEqual(sharedLinkResponseStub.valid);
+      expect(shareMock.get).toHaveBeenCalledWith(authDto.id, authDto.sharedLinkId);
+    });
+  });
+
+  describe('get', () => {
+    it('should not work on a missing key', async () => {
+      shareMock.get.mockResolvedValue(null);
+      await expect(sut.getById(authStub.user1, sharedLinkStub.valid.id, true)).rejects.toBeInstanceOf(
+        BadRequestException,
+      );
+      expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
+      expect(shareMock.remove).not.toHaveBeenCalled();
+    });
+
+    it('should get a key by id', async () => {
+      shareMock.get.mockResolvedValue(sharedLinkStub.valid);
+      await expect(sut.getById(authStub.user1, sharedLinkStub.valid.id, false)).resolves.toEqual(
+        sharedLinkResponseStub.valid,
+      );
+      expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
+    });
+
+    it('should include exif', async () => {
+      shareMock.get.mockResolvedValue(sharedLinkStub.readonly);
+      await expect(sut.getById(authStub.user1, sharedLinkStub.readonly.id, true)).resolves.toEqual(
+        sharedLinkResponseStub.readonly,
+      );
+      expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.readonly.id);
+    });
+
+    it('should exclude exif', async () => {
+      shareMock.get.mockResolvedValue(sharedLinkStub.readonly);
+      await expect(sut.getById(authStub.user1, sharedLinkStub.readonly.id, false)).resolves.toEqual(
+        sharedLinkResponseStub.readonlyNoExif,
+      );
+      expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.readonly.id);
+    });
+  });
+
+  describe('remove', () => {
+    it('should not work on a missing key', async () => {
+      shareMock.get.mockResolvedValue(null);
+      await expect(sut.remove(authStub.user1, sharedLinkStub.valid.id)).rejects.toBeInstanceOf(BadRequestException);
+      expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
+      expect(shareMock.remove).not.toHaveBeenCalled();
+    });
+
+    it('should remove a key', async () => {
+      shareMock.get.mockResolvedValue(sharedLinkStub.valid);
+      await sut.remove(authStub.user1, sharedLinkStub.valid.id);
+      expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
+      expect(shareMock.remove).toHaveBeenCalledWith(sharedLinkStub.valid);
+    });
+  });
+
+  describe('getByKey', () => {
+    it('should not work on a missing key', async () => {
+      shareMock.getByKey.mockResolvedValue(null);
+      await expect(sut.getByKey('secret-key')).rejects.toBeInstanceOf(BadRequestException);
+      expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key');
+    });
+
+    it('should find a key', async () => {
+      shareMock.getByKey.mockResolvedValue(sharedLinkStub.valid);
+      await expect(sut.getByKey('secret-key')).resolves.toEqual(sharedLinkResponseStub.valid);
+      expect(shareMock.getByKey).toHaveBeenCalledWith('secret-key');
+    });
+  });
+
+  describe('edit', () => {
+    it('should not work on a missing key', async () => {
+      shareMock.get.mockResolvedValue(null);
+      await expect(sut.edit(authStub.user1, sharedLinkStub.valid.id, {})).rejects.toBeInstanceOf(BadRequestException);
+      expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
+      expect(shareMock.save).not.toHaveBeenCalled();
+    });
+
+    it('should edit a key', async () => {
+      shareMock.get.mockResolvedValue(sharedLinkStub.valid);
+      shareMock.save.mockResolvedValue(sharedLinkStub.valid);
+      const dto = { allowDownload: false };
+      await sut.edit(authStub.user1, sharedLinkStub.valid.id, dto);
+      // await expect(sut.edit(authStub.user1, sharedLinkStub.valid.id, dto)).rejects.toBeInstanceOf(BadRequestException);
+      expect(shareMock.get).toHaveBeenCalledWith(authStub.user1.id, sharedLinkStub.valid.id);
+      expect(shareMock.save).toHaveBeenCalledWith({
+        id: sharedLinkStub.valid.id,
+        userId: authStub.user1.id,
+        allowDownload: false,
+      });
+    });
+  });
+});

+ 23 - 22
server/apps/immich/src/api-v1/share/share.service.ts → server/libs/domain/src/share/share.service.ts

@@ -6,10 +6,10 @@ import {
   Logger,
   UnauthorizedException,
 } from '@nestjs/common';
-import { UserService } from '@app/domain';
-import { AuthUserDto } from '../../decorators/auth-user.decorator';
-import { EditSharedLinkDto } from './dto/edit-shared-link.dto';
-import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto/shared-link-response.dto';
+import { AuthUserDto, ICryptoRepository } from '../auth';
+import { IUserRepository, UserCore } from '../user';
+import { EditSharedLinkDto } from './dto';
+import { mapSharedLink, mapSharedLinkWithNoExif, SharedLinkResponseDto } from './response-dto';
 import { ShareCore } from './share.core';
 import { ISharedLinkRepository } from './shared-link.repository';
 
@@ -17,20 +17,22 @@ import { ISharedLinkRepository } from './shared-link.repository';
 export class ShareService {
   readonly logger = new Logger(ShareService.name);
   private shareCore: ShareCore;
+  private userCore: UserCore;
 
   constructor(
-    @Inject(ISharedLinkRepository)
-    sharedLinkRepository: ISharedLinkRepository,
-    private userService: UserService,
+    @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
+    @Inject(ISharedLinkRepository) sharedLinkRepository: ISharedLinkRepository,
+    @Inject(IUserRepository) userRepository: IUserRepository,
   ) {
-    this.shareCore = new ShareCore(sharedLinkRepository);
+    this.shareCore = new ShareCore(sharedLinkRepository, cryptoRepository);
+    this.userCore = new UserCore(userRepository);
   }
 
   async validate(key: string): Promise<AuthUserDto> {
-    const link = await this.shareCore.getSharedLinkByKey(key);
+    const link = await this.shareCore.getByKey(key);
     if (link) {
       if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
-        const user = await this.userService.getUserById(link.userId).catch(() => null);
+        const user = await this.userCore.get(link.userId);
         if (user) {
           return {
             id: user.id,
@@ -49,7 +51,7 @@ export class ShareService {
   }
 
   async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
-    const links = await this.shareCore.getSharedLinks(authUser.id);
+    const links = await this.shareCore.getAll(authUser.id);
     return links.map(mapSharedLink);
   }
 
@@ -63,11 +65,11 @@ export class ShareService {
       allowExif = authUser.isShowExif;
     }
 
-    return this.getById(authUser.sharedLinkId, allowExif);
+    return this.getById(authUser, authUser.sharedLinkId, allowExif);
   }
 
-  async getById(id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
-    const link = await this.shareCore.getSharedLinkById(id);
+  async getById(authUser: AuthUserDto, id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
+    const link = await this.shareCore.get(authUser.id, id);
     if (!link) {
       throw new BadRequestException('Shared link not found');
     }
@@ -79,21 +81,20 @@ export class ShareService {
     }
   }
 
-  async remove(id: string, userId: string): Promise<string> {
-    await this.shareCore.removeSharedLink(id, userId);
-    return id;
-  }
-
   async getByKey(key: string): Promise<SharedLinkResponseDto> {
-    const link = await this.shareCore.getSharedLinkByKey(key);
+    const link = await this.shareCore.getByKey(key);
     if (!link) {
       throw new BadRequestException('Shared link not found');
     }
     return mapSharedLink(link);
   }
 
-  async edit(id: string, authUser: AuthUserDto, dto: EditSharedLinkDto) {
-    const link = await this.shareCore.updateSharedLink(id, authUser.id, dto);
+  async remove(authUser: AuthUserDto, id: string): Promise<void> {
+    await this.shareCore.remove(authUser.id, id);
+  }
+
+  async edit(authUser: AuthUserDto, id: string, dto: EditSharedLinkDto) {
+    const link = await this.shareCore.save(authUser.id, id, dto);
     return mapSharedLink(link);
   }
 }

+ 13 - 0
server/libs/domain/src/share/shared-link.repository.ts

@@ -0,0 +1,13 @@
+import { SharedLinkEntity } from '@app/infra/db/entities';
+
+export const ISharedLinkRepository = 'ISharedLinkRepository';
+
+export interface ISharedLinkRepository {
+  getAll(userId: string): Promise<SharedLinkEntity[]>;
+  get(userId: string, id: string): Promise<SharedLinkEntity | null>;
+  getByKey(key: string): Promise<SharedLinkEntity | null>;
+  create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity>;
+  remove(entity: SharedLinkEntity): Promise<SharedLinkEntity>;
+  save(entity: Partial<SharedLinkEntity>): Promise<SharedLinkEntity>;
+  hasAssetAccess(id: string, assetId: string): Promise<boolean>;
+}

+ 1 - 0
server/libs/domain/src/tag/index.ts

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

+ 1 - 0
server/libs/domain/src/tag/response-dto/index.ts

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

+ 1 - 1
server/apps/immich/src/api-v1/tag/response-dto/tag-response.dto.ts → server/libs/domain/src/tag/response-dto/tag-response.dto.ts

@@ -1,4 +1,4 @@
-import { TagEntity, TagType } from '@app/infra';
+import { TagEntity, TagType } from '@app/infra/db/entities';
 import { ApiProperty } from '@nestjs/swagger';
 
 export class TagResponseDto {

+ 260 - 2
server/libs/domain/test/fixtures.ts

@@ -1,5 +1,71 @@
-import { SystemConfig, UserEntity } from '@app/infra/db/entities';
-import { AuthUserDto } from '../src';
+import { AssetType, SharedLinkEntity, SharedLinkType, SystemConfig, UserEntity } from '@app/infra/db/entities';
+import { AlbumResponseDto, AssetResponseDto, AuthUserDto, ExifResponseDto, SharedLinkResponseDto } from '../src';
+
+const today = new Date();
+const tomorrow = new Date();
+const yesterday = new Date();
+tomorrow.setDate(today.getDate() + 1);
+yesterday.setDate(yesterday.getDate() - 1);
+
+const assetInfo: ExifResponseDto = {
+  id: 1,
+  make: 'camera-make',
+  model: 'camera-model',
+  imageName: 'fancy-image',
+  exifImageWidth: 500,
+  exifImageHeight: 500,
+  fileSizeInByte: 100,
+  orientation: 'orientation',
+  dateTimeOriginal: today,
+  modifyDate: today,
+  lensModel: 'fancy',
+  fNumber: 100,
+  focalLength: 100,
+  iso: 100,
+  exposureTime: 100,
+  latitude: 100,
+  longitude: 100,
+  city: 'city',
+  state: 'state',
+  country: 'country',
+};
+
+const assetResponse: AssetResponseDto = {
+  id: 'id_1',
+  deviceAssetId: 'device_asset_id_1',
+  ownerId: 'user_id_1',
+  deviceId: 'device_id_1',
+  type: AssetType.VIDEO,
+  originalPath: 'fake_path/jpeg',
+  resizePath: '',
+  createdAt: today.toISOString(),
+  modifiedAt: today.toISOString(),
+  isFavorite: false,
+  mimeType: 'image/jpeg',
+  smartInfo: {
+    id: 'should-be-a-number',
+    tags: [],
+    objects: ['a', 'b', 'c'],
+  },
+  webpPath: '',
+  encodedVideoPath: '',
+  duration: '0:00:00.00000',
+  exifInfo: assetInfo,
+  livePhotoVideoId: null,
+  tags: [],
+};
+
+const albumResponse: AlbumResponseDto = {
+  albumName: 'Test Album',
+  albumThumbnailAssetId: null,
+  createdAt: today.toISOString(),
+  id: 'album-123',
+  ownerId: 'admin_id',
+  sharedUsers: [],
+  shared: false,
+  assets: [],
+  assetCount: 1,
+};
 
 export const authStub = {
   admin: Object.freeze<AuthUserDto>({
@@ -16,6 +82,26 @@ export const authStub = {
     isPublicUser: false,
     isAllowUpload: true,
   }),
+  adminSharedLink: Object.freeze<AuthUserDto>({
+    id: 'admin_id',
+    email: 'admin@test.com',
+    isAdmin: true,
+    isAllowUpload: true,
+    isAllowDownload: true,
+    isPublicUser: true,
+    isShowExif: true,
+    sharedLinkId: '123',
+  }),
+  readonlySharedLink: Object.freeze<AuthUserDto>({
+    id: 'admin_id',
+    email: 'admin@test.com',
+    isAdmin: true,
+    isAllowUpload: false,
+    isAllowDownload: false,
+    isPublicUser: true,
+    isShowExif: true,
+    sharedLinkId: '123',
+  }),
 };
 
 export const entityStub = {
@@ -165,3 +251,175 @@ export const loginResponseStub = {
     ],
   },
 };
+
+export const sharedLinkStub = {
+  valid: Object.freeze({
+    id: '123',
+    userId: authStub.admin.id,
+    key: Buffer.from('secret-key', 'utf8'),
+    type: SharedLinkType.ALBUM,
+    createdAt: today.toISOString(),
+    expiresAt: tomorrow.toISOString(),
+    allowUpload: true,
+    allowDownload: true,
+    showExif: true,
+    album: undefined,
+    assets: [],
+  } as SharedLinkEntity),
+  expired: Object.freeze({
+    id: '123',
+    userId: authStub.admin.id,
+    key: Buffer.from('secret-key', 'utf8'),
+    type: SharedLinkType.ALBUM,
+    createdAt: today.toISOString(),
+    expiresAt: yesterday.toISOString(),
+    allowUpload: true,
+    allowDownload: true,
+    showExif: true,
+    assets: [],
+  } as SharedLinkEntity),
+  readonly: Object.freeze<SharedLinkEntity>({
+    id: '123',
+    userId: authStub.admin.id,
+    key: Buffer.from('secret-key', 'utf8'),
+    type: SharedLinkType.ALBUM,
+    createdAt: today.toISOString(),
+    expiresAt: tomorrow.toISOString(),
+    allowUpload: false,
+    allowDownload: false,
+    showExif: true,
+    assets: [],
+    album: {
+      id: 'album-123',
+      ownerId: authStub.admin.id,
+      albumName: 'Test Album',
+      createdAt: today.toISOString(),
+      albumThumbnailAssetId: null,
+      sharedUsers: [],
+      sharedLinks: [],
+      assets: [
+        {
+          id: 'album-asset-123',
+          albumId: 'album-123',
+          assetId: 'asset-123',
+          albumInfo: {} as any,
+          assetInfo: {
+            id: 'id_1',
+            userId: 'user_id_1',
+            deviceAssetId: 'device_asset_id_1',
+            deviceId: 'device_id_1',
+            type: AssetType.VIDEO,
+            originalPath: 'fake_path/jpeg',
+            resizePath: '',
+            createdAt: today.toISOString(),
+            modifiedAt: today.toISOString(),
+            isFavorite: false,
+            mimeType: 'image/jpeg',
+            smartInfo: {
+              id: 'should-be-a-number',
+              assetId: 'id_1',
+              tags: [],
+              objects: ['a', 'b', 'c'],
+              asset: null as any,
+            },
+            webpPath: '',
+            encodedVideoPath: '',
+            duration: null,
+            isVisible: true,
+            livePhotoVideoId: null,
+            exifInfo: {
+              id: 1,
+              assetId: 'id_1',
+              description: 'description',
+              exifImageWidth: 500,
+              exifImageHeight: 500,
+              fileSizeInByte: 100,
+              orientation: 'orientation',
+              dateTimeOriginal: today,
+              modifyDate: today,
+              latitude: 100,
+              longitude: 100,
+              city: 'city',
+              state: 'state',
+              country: 'country',
+              make: 'camera-make',
+              model: 'camera-model',
+              imageName: 'fancy-image',
+              lensModel: 'fancy',
+              fNumber: 100,
+              focalLength: 100,
+              iso: 100,
+              exposureTime: 100,
+              fps: 100,
+              asset: null as any,
+              exifTextSearchableColumn: '',
+            },
+            tags: [],
+            sharedLinks: [],
+          },
+        },
+      ],
+    },
+  }),
+};
+
+export const sharedLinkResponseStub = {
+  valid: Object.freeze<SharedLinkResponseDto>({
+    allowDownload: true,
+    allowUpload: true,
+    assets: [],
+    createdAt: today.toISOString(),
+    description: undefined,
+    expiresAt: tomorrow.toISOString(),
+    id: '123',
+    key: '7365637265742d6b6579',
+    showExif: true,
+    type: SharedLinkType.ALBUM,
+    userId: 'admin_id',
+  }),
+  expired: Object.freeze<SharedLinkResponseDto>({
+    album: undefined,
+    allowDownload: true,
+    allowUpload: true,
+    assets: [],
+    createdAt: today.toISOString(),
+    description: undefined,
+    expiresAt: yesterday.toISOString(),
+    id: '123',
+    key: '7365637265742d6b6579',
+    showExif: true,
+    type: SharedLinkType.ALBUM,
+    userId: 'admin_id',
+  }),
+  readonly: Object.freeze<SharedLinkResponseDto>({
+    id: '123',
+    userId: 'admin_id',
+    key: '7365637265742d6b6579',
+    type: SharedLinkType.ALBUM,
+    createdAt: today.toISOString(),
+    expiresAt: tomorrow.toISOString(),
+    description: undefined,
+    allowUpload: false,
+    allowDownload: false,
+    showExif: true,
+    album: albumResponse,
+    assets: [assetResponse],
+  }),
+  readonlyNoExif: Object.freeze<SharedLinkResponseDto>({
+    id: '123',
+    userId: 'admin_id',
+    key: '7365637265742d6b6579',
+    type: SharedLinkType.ALBUM,
+    createdAt: today.toISOString(),
+    expiresAt: tomorrow.toISOString(),
+    description: undefined,
+    allowUpload: false,
+    allowDownload: false,
+    showExif: true,
+    album: albumResponse,
+    assets: [{ ...assetResponse, exifInfo: undefined }],
+  }),
+};
+
+// TODO - the constructor isn't used anywhere, so not test coverage
+new ExifResponseDto();

+ 1 - 0
server/libs/domain/test/index.ts

@@ -2,5 +2,6 @@ export * from './api-key.repository.mock';
 export * from './crypto.repository.mock';
 export * from './fixtures';
 export * from './job.repository.mock';
+export * from './shared-link.repository.mock';
 export * from './system-config.repository.mock';
 export * from './user.repository.mock';

+ 13 - 0
server/libs/domain/test/shared-link.repository.mock.ts

@@ -0,0 +1,13 @@
+import { ISharedLinkRepository } from '../src';
+
+export const newSharedLinkRepositoryMock = (): jest.Mocked<ISharedLinkRepository> => {
+  return {
+    getAll: jest.fn(),
+    get: jest.fn(),
+    getByKey: jest.fn(),
+    create: jest.fn(),
+    remove: jest.fn(),
+    save: jest.fn(),
+    hasAssetAccess: jest.fn(),
+  };
+};

+ 1 - 1
server/libs/infra/src/db/entities/exif.entity.ts

@@ -7,7 +7,7 @@ import { AssetEntity } from './asset.entity';
 @Entity('exif')
 export class ExifEntity {
   @PrimaryGeneratedColumn()
-  id!: string;
+  id!: number;
 
   @Index({ unique: true })
   @Column({ type: 'uuid' })

+ 1 - 0
server/libs/infra/src/db/repository/index.ts

@@ -1,2 +1,3 @@
 export * from './api-key.repository';
+export * from './shared-link.repository';
 export * from './user.repository';

+ 119 - 0
server/libs/infra/src/db/repository/shared-link.repository.ts

@@ -0,0 +1,119 @@
+import { ISharedLinkRepository } from '@app/domain';
+import { Injectable, Logger } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { SharedLinkEntity } from '../entities';
+
+@Injectable()
+export class SharedLinkRepository implements ISharedLinkRepository {
+  readonly logger = new Logger(SharedLinkRepository.name);
+  constructor(
+    @InjectRepository(SharedLinkEntity)
+    private readonly repository: Repository<SharedLinkEntity>,
+  ) {}
+
+  get(userId: string, id: string): Promise<SharedLinkEntity | null> {
+    return this.repository.findOne({
+      where: {
+        id,
+        userId,
+      },
+      relations: {
+        assets: {
+          exifInfo: true,
+        },
+        album: {
+          assets: {
+            assetInfo: {
+              exifInfo: true,
+            },
+          },
+        },
+      },
+      order: {
+        createdAt: 'DESC',
+        assets: {
+          createdAt: 'ASC',
+        },
+        album: {
+          assets: {
+            assetInfo: {
+              createdAt: 'ASC',
+            },
+          },
+        },
+      },
+    });
+  }
+
+  getAll(userId: string): Promise<SharedLinkEntity[]> {
+    return this.repository.find({
+      where: {
+        userId,
+      },
+      relations: {
+        assets: true,
+        album: true,
+      },
+      order: {
+        createdAt: 'DESC',
+      },
+    });
+  }
+
+  async getByKey(key: string): Promise<SharedLinkEntity | null> {
+    return await this.repository.findOne({
+      where: {
+        key: Buffer.from(key, 'hex'),
+      },
+      relations: {
+        assets: true,
+        album: {
+          assets: {
+            assetInfo: true,
+          },
+        },
+      },
+      order: {
+        createdAt: 'DESC',
+      },
+    });
+  }
+
+  create(entity: Omit<SharedLinkEntity, 'id'>): Promise<SharedLinkEntity> {
+    return this.repository.save(entity);
+  }
+
+  remove(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
+    return this.repository.remove(entity);
+  }
+
+  async save(entity: SharedLinkEntity): Promise<SharedLinkEntity> {
+    await this.repository.save(entity);
+    return this.repository.findOneOrFail({ where: { id: entity.id } });
+  }
+
+  async hasAssetAccess(id: string, assetId: string): Promise<boolean> {
+    const count1 = await this.repository.count({
+      where: {
+        id,
+        assets: {
+          id: assetId,
+        },
+      },
+    });
+
+    const count2 = await this.repository.count({
+      where: {
+        id,
+        album: {
+          assets: {
+            assetId,
+          },
+        },
+      },
+    });
+
+    return Boolean(count1 + count2);
+  }
+}

+ 5 - 3
server/libs/infra/src/infra.module.ts

@@ -2,6 +2,7 @@ import {
   ICryptoRepository,
   IJobRepository,
   IKeyRepository,
+  ISharedLinkRepository,
   ISystemConfigRepository,
   IUserRepository,
   QueueName,
@@ -11,10 +12,10 @@ import { BullModule } from '@nestjs/bull';
 import { Global, Module, Provider } from '@nestjs/common';
 import { JwtModule } from '@nestjs/jwt';
 import { TypeOrmModule } from '@nestjs/typeorm';
+import { APIKeyEntity, SharedLinkEntity, SystemConfigEntity, UserRepository } from './db';
+import { APIKeyRepository, SharedLinkRepository } from './db/repository';
 import { jwtConfig } from '@app/domain';
 import { CryptoRepository } from './auth/crypto.repository';
-import { APIKeyEntity, SystemConfigEntity, UserRepository } from './db';
-import { APIKeyRepository } from './db/repository';
 import { SystemConfigRepository } from './db/repository/system-config.repository';
 import { JobRepository } from './job';
 
@@ -22,6 +23,7 @@ const providers: Provider[] = [
   { provide: ICryptoRepository, useClass: CryptoRepository },
   { provide: IKeyRepository, useClass: APIKeyRepository },
   { provide: IJobRepository, useClass: JobRepository },
+  { provide: ISharedLinkRepository, useClass: SharedLinkRepository },
   { provide: ISystemConfigRepository, useClass: SystemConfigRepository },
   { provide: IUserRepository, useClass: UserRepository },
 ];
@@ -31,7 +33,7 @@ const providers: Provider[] = [
   imports: [
     JwtModule.register(jwtConfig),
     TypeOrmModule.forRoot(databaseConfig),
-    TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SystemConfigEntity]),
+    TypeOrmModule.forFeature([APIKeyEntity, UserEntity, SharedLinkEntity, SystemConfigEntity]),
     BullModule.forRootAsync({
       useFactory: async () => ({
         prefix: 'immich_bull',

+ 5 - 11
web/src/api/open-api/api.ts

@@ -658,7 +658,7 @@ export interface CreateAlbumShareLinkDto {
      * @type {string}
      * @memberof CreateAlbumShareLinkDto
      */
-    'expiredAt'?: string;
+    'expiresAt'?: string;
     /**
      * 
      * @type {boolean}
@@ -701,7 +701,7 @@ export interface CreateAssetsShareLinkDto {
      * @type {string}
      * @memberof CreateAssetsShareLinkDto
      */
-    'expiredAt'?: string;
+    'expiresAt'?: string;
     /**
      * 
      * @type {boolean}
@@ -1004,7 +1004,7 @@ export interface EditSharedLinkDto {
      * @type {string}
      * @memberof EditSharedLinkDto
      */
-    'expiredAt'?: string;
+    'expiresAt'?: string | null;
     /**
      * 
      * @type {boolean}
@@ -1023,12 +1023,6 @@ export interface EditSharedLinkDto {
      * @memberof EditSharedLinkDto
      */
     'showExif'?: boolean;
-    /**
-     * 
-     * @type {boolean}
-     * @memberof EditSharedLinkDto
-     */
-    'isEditExpireTime'?: boolean;
 }
 /**
  * 
@@ -6745,7 +6739,7 @@ export const ShareApiFp = function(configuration?: Configuration) {
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        async removeSharedLink(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<string>> {
+        async removeSharedLink(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
             const localVarAxiosArgs = await localVarAxiosParamCreator.removeSharedLink(id, options);
             return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
         },
@@ -6800,7 +6794,7 @@ export const ShareApiFactory = function (configuration?: Configuration, basePath
          * @param {*} [options] Override http request option.
          * @throws {RequiredError}
          */
-        removeSharedLink(id: string, options?: any): AxiosPromise<string> {
+        removeSharedLink(id: string, options?: any): AxiosPromise<void> {
             return localVarFp.removeSharedLink(id, options).then((request) => request(axios, basePath));
         },
     };

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

@@ -60,7 +60,7 @@
 			if (shareType === SharedLinkType.Album && album) {
 				const { data } = await api.albumApi.createAlbumSharedLink({
 					albumId: album.id,
-					expiredAt: expirationDate,
+					expiresAt: expirationDate,
 					allowUpload: isAllowUpload,
 					description: description,
 					allowDownload: isAllowDownload,
@@ -70,7 +70,7 @@
 			} else {
 				const { data } = await api.assetApi.createAssetsSharedLink({
 					assetIds: sharedAssets.map((a) => a.id),
-					expiredAt: expirationDate,
+					expiresAt: expirationDate,
 					allowUpload: isAllowUpload,
 					description: description,
 					allowDownload: isAllowDownload,
@@ -128,19 +128,14 @@
 			try {
 				const expirationTime = getExpirationTimeInMillisecond();
 				const currentTime = new Date().getTime();
-				let expirationDate = expirationTime
+				const expirationDate: string | null = expirationTime
 					? new Date(currentTime + expirationTime).toISOString()
-					: undefined;
-
-				if (expirationTime === 0) {
-					expirationDate = undefined;
-				}
+					: null;
 
 				await api.shareApi.editSharedLink(editingLink.id, {
-					description: description,
-					expiredAt: expirationDate,
+					description,
+					expiresAt: shouldChangeExpirationTime ? expirationDate : undefined,
 					allowUpload: isAllowUpload,
-					isEditExpireTime: shouldChangeExpirationTime,
 					allowDownload: isAllowDownload,
 					showExif: shouldShowExif
 				});

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio