diff --git a/mobile/openapi/doc/CreateAlbumShareLinkDto.md b/mobile/openapi/doc/CreateAlbumShareLinkDto.md index 254f455899..a0010d761e 100644 Binary files a/mobile/openapi/doc/CreateAlbumShareLinkDto.md and b/mobile/openapi/doc/CreateAlbumShareLinkDto.md differ diff --git a/mobile/openapi/doc/CreateAssetsShareLinkDto.md b/mobile/openapi/doc/CreateAssetsShareLinkDto.md index adc23a608f..c36b519d76 100644 Binary files a/mobile/openapi/doc/CreateAssetsShareLinkDto.md and b/mobile/openapi/doc/CreateAssetsShareLinkDto.md differ diff --git a/mobile/openapi/doc/EditSharedLinkDto.md b/mobile/openapi/doc/EditSharedLinkDto.md index 2fe7039ad0..a875bdbe7c 100644 Binary files a/mobile/openapi/doc/EditSharedLinkDto.md and b/mobile/openapi/doc/EditSharedLinkDto.md differ diff --git a/mobile/openapi/doc/ShareApi.md b/mobile/openapi/doc/ShareApi.md index 045793b75b..7123b8ce39 100644 Binary files a/mobile/openapi/doc/ShareApi.md and b/mobile/openapi/doc/ShareApi.md differ diff --git a/mobile/openapi/lib/api/share_api.dart b/mobile/openapi/lib/api/share_api.dart index d574023ffb..eeaa563088 100644 Binary files a/mobile/openapi/lib/api/share_api.dart and b/mobile/openapi/lib/api/share_api.dart differ diff --git a/mobile/openapi/lib/model/create_album_share_link_dto.dart b/mobile/openapi/lib/model/create_album_share_link_dto.dart index 4883e3c71c..46b3812a94 100644 Binary files a/mobile/openapi/lib/model/create_album_share_link_dto.dart and b/mobile/openapi/lib/model/create_album_share_link_dto.dart differ diff --git a/mobile/openapi/lib/model/create_assets_share_link_dto.dart b/mobile/openapi/lib/model/create_assets_share_link_dto.dart index 3652523df3..7141825bb0 100644 Binary files a/mobile/openapi/lib/model/create_assets_share_link_dto.dart and b/mobile/openapi/lib/model/create_assets_share_link_dto.dart differ diff --git a/mobile/openapi/lib/model/edit_shared_link_dto.dart b/mobile/openapi/lib/model/edit_shared_link_dto.dart index 09d43db554..c6323e6672 100644 Binary files a/mobile/openapi/lib/model/edit_shared_link_dto.dart and b/mobile/openapi/lib/model/edit_shared_link_dto.dart differ diff --git a/mobile/openapi/test/create_album_share_link_dto_test.dart b/mobile/openapi/test/create_album_share_link_dto_test.dart index ebbfd4720b..7b44683c5b 100644 Binary files a/mobile/openapi/test/create_album_share_link_dto_test.dart and b/mobile/openapi/test/create_album_share_link_dto_test.dart differ diff --git a/mobile/openapi/test/create_assets_share_link_dto_test.dart b/mobile/openapi/test/create_assets_share_link_dto_test.dart index 612d60ad91..1f8dc10bad 100644 Binary files a/mobile/openapi/test/create_assets_share_link_dto_test.dart and b/mobile/openapi/test/create_assets_share_link_dto_test.dart differ diff --git a/mobile/openapi/test/edit_shared_link_dto_test.dart b/mobile/openapi/test/edit_shared_link_dto_test.dart index 7379e8b082..4e80594887 100644 Binary files a/mobile/openapi/test/edit_shared_link_dto_test.dart and b/mobile/openapi/test/edit_shared_link_dto_test.dart differ diff --git a/mobile/openapi/test/share_api_test.dart b/mobile/openapi/test/share_api_test.dart index 3fb8f8955d..af25d6e01f 100644 Binary files a/mobile/openapi/test/share_api_test.dart and b/mobile/openapi/test/share_api_test.dart differ diff --git a/server/apps/immich/src/api-v1/album/album.controller.ts b/server/apps/immich/src/api-v1/album/album.controller.ts index c7623e082c..7ac3f4f284 100644 --- a/server/apps/immich/src/api-v1/album/album.controller.ts +++ b/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'; diff --git a/server/apps/immich/src/api-v1/album/album.module.ts b/server/apps/immich/src/api-v1/album/album.module.ts index 15cd73d33a..8c8733a8c3 100644 --- a/server/apps/immich/src/api-v1/album/album.module.ts +++ b/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], diff --git a/server/apps/immich/src/api-v1/album/album.service.spec.ts b/server/apps/immich/src/api-v1/album/album.service.spec.ts index e78c64c6b7..eb00f5ac34 100644 --- a/server/apps/immich/src/api-v1/album/album.service.spec.ts +++ b/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; let sharedLinkRepositoryMock: jest.Mocked; let downloadServiceMock: jest.Mocked>; + let cryptoMock: jest.Mocked; 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 () => { diff --git a/server/apps/immich/src/api-v1/album/album.service.ts b/server/apps/immich/src/api-v1/album/album.service.ts index 0da631d997..a9e9145179 100644 --- a/server/apps/immich/src/api-v1/album/album.service.ts +++ b/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 { 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, diff --git a/server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts b/server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts index d34c6310de..e113b9d49e 100644 --- a/server/apps/immich/src/api-v1/album/dto/create-album-shared-link.dto.ts +++ b/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() diff --git a/server/apps/immich/src/api-v1/album/response-dto/add-assets-response.dto.ts b/server/apps/immich/src/api-v1/album/response-dto/add-assets-response.dto.ts index 9fb2b8d659..5072621625 100644 --- a/server/apps/immich/src/api-v1/album/response-dto/add-assets-response.dto.ts +++ b/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' }) diff --git a/server/apps/immich/src/api-v1/asset/asset.controller.ts b/server/apps/immich/src/api-v1/asset/asset.controller.ts index 226cda6b3c..2176ebe26a 100644 --- a/server/apps/immich/src/api-v1/asset/asset.controller.ts +++ b/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'; diff --git a/server/apps/immich/src/api-v1/asset/asset.module.ts b/server/apps/immich/src/api-v1/asset/asset.module.ts index f03f89c07a..8bc401e761 100644 --- a/server/apps/immich/src/api-v1/asset/asset.module.ts +++ b/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], diff --git a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts b/server/apps/immich/src/api-v1/asset/asset.service.spec.ts index 7a46efb6eb..44f84e3556 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.spec.ts +++ b/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; let storageSeriveMock: jest.Mocked; let sharedLinkRepositoryMock: jest.Mocked; + let cryptoMock: jest.Mocked; let jobMock: jest.Mocked; 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; + 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); + }); + }); }); diff --git a/server/apps/immich/src/api-v1/asset/asset.service.ts b/server/apps/immich/src/api-v1/asset/asset.service.ts index 830e49a19e..534cf5234a 100644 --- a/server/apps/immich/src/api-v1/asset/asset.service.ts +++ b/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(); - } - } - - // 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) { + if (canAccess) { continue; } + } 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 { - 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); } diff --git a/server/apps/immich/src/api-v1/asset/dto/create-asset-shared-link.dto.ts b/server/apps/immich/src/api-v1/asset/dto/create-asset-shared-link.dto.ts index 407aaa6285..3e6cb0960d 100644 --- a/server/apps/immich/src/api-v1/asset/dto/create-asset-shared-link.dto.ts +++ b/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() diff --git a/server/apps/immich/src/api-v1/share/share.core.ts b/server/apps/immich/src/api-v1/share/share.core.ts deleted file mode 100644 index 87797fa2f3..0000000000 --- a/server/apps/immich/src/api-v1/share/share.core.ts +++ /dev/null @@ -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 { - 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 { - return this.sharedLinkRepository.get(userId); - } - - async removeSharedLink(id: string, userId: string): Promise { - 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 { - return this.sharedLinkRepository.getById(id); - } - - getSharedLinkByKey(key: string): Promise { - 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 { - 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 { - return this.sharedLinkRepository.hasAssetAccess(id, assetId); - } - - checkDownloadAccess(user: AuthUserDto) { - if (user.isPublicUser && !user.isAllowDownload) { - throw new ForbiddenException(); - } - } -} diff --git a/server/apps/immich/src/api-v1/share/share.module.ts b/server/apps/immich/src/api-v1/share/share.module.ts deleted file mode 100644 index 1a4b759c4c..0000000000 --- a/server/apps/immich/src/api-v1/share/share.module.ts +++ /dev/null @@ -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 {} diff --git a/server/apps/immich/src/api-v1/share/shared-link.repository.ts b/server/apps/immich/src/api-v1/share/shared-link.repository.ts deleted file mode 100644 index bc3d1778ce..0000000000 --- a/server/apps/immich/src/api-v1/share/shared-link.repository.ts +++ /dev/null @@ -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; - getById(id: string): Promise; - getByIdAndUserId(id: string, userId: string): Promise; - getByKey(key: string): Promise; - create(payload: SharedLinkEntity): Promise; - remove(entity: SharedLinkEntity): Promise; - save(entity: SharedLinkEntity): Promise; - hasAssetAccess(id: string, assetId: string): Promise; -} - -export const ISharedLinkRepository = 'ISharedLinkRepository'; - -export class SharedLinkRepository implements ISharedLinkRepository { - readonly logger = new Logger(SharedLinkRepository.name); - constructor( - @InjectRepository(SharedLinkEntity) - private readonly sharedLinkRepository: Repository, - ) {} - async getByIdAndUserId(id: string, userId: string): Promise { - return await this.sharedLinkRepository.findOne({ - where: { - userId: userId, - id: id, - }, - order: { - createdAt: 'DESC', - }, - }); - } - - async get(userId: string): Promise { - return await this.sharedLinkRepository.find({ - where: { - userId: userId, - }, - relations: ['assets', 'album'], - order: { - createdAt: 'DESC', - }, - }); - } - - async create(payload: SharedLinkEntity): Promise { - return await this.sharedLinkRepository.save(payload); - } - - async getById(id: string): Promise { - 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 { - 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 { - return await this.sharedLinkRepository.remove(entity); - } - - async save(entity: SharedLinkEntity): Promise { - return await this.sharedLinkRepository.save(entity); - } - - async hasAssetAccess(id: string, assetId: string): Promise { - 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); - } -} diff --git a/server/apps/immich/src/api-v1/tag/tag.controller.ts b/server/apps/immich/src/api-v1/tag/tag.controller.ts index f5752710d9..b02b222276 100644 --- a/server/apps/immich/src/api-v1/tag/tag.controller.ts +++ b/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') diff --git a/server/apps/immich/src/api-v1/tag/tag.service.ts b/server/apps/immich/src/api-v1/tag/tag.service.ts index 929178c362..9a7288b1fd 100644 --- a/server/apps/immich/src/api-v1/tag/tag.service.ts +++ b/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 { diff --git a/server/apps/immich/src/app.module.ts b/server/apps/immich/src/app.module.ts index 4c1262ab55..d6100f6342 100644 --- a/server/apps/immich/src/app.module.ts +++ b/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, ], diff --git a/server/apps/immich/src/config/asset-upload.config.ts b/server/apps/immich/src/config/asset-upload.config.ts index cbbb3a2c3d..de09d0811b 100644 --- a/server/apps/immich/src/config/asset-upload.config.ts +++ b/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()); } diff --git a/server/apps/immich/src/controllers/index.ts b/server/apps/immich/src/controllers/index.ts index 615f841b7f..44498d3c3c 100644 --- a/server/apps/immich/src/controllers/index.ts +++ b/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'; diff --git a/server/apps/immich/src/api-v1/share/share.controller.ts b/server/apps/immich/src/controllers/share.controller.ts similarity index 57% rename from server/apps/immich/src/api-v1/share/share.controller.ts rename to server/apps/immich/src/controllers/share.controller.ts index 013c1dd92c..344a1d6826 100644 --- a/server/apps/immich/src/api-v1/share/share.controller.ts +++ b/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 { - return this.shareService.getById(id, true); + getSharedLinkById(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string): Promise { + return this.shareService.getById(authUser, id, true); } @Authenticated() @Delete(':id') - removeSharedLink(@Param('id') id: string, @GetAuthUser() authUser: AuthUserDto): Promise { - return this.shareService.remove(id, authUser.id); + removeSharedLink(@GetAuthUser() authUser: AuthUserDto, @Param('id') id: string): Promise { + 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 { - return this.shareService.edit(id, authUser, dto); + return this.shareService.edit(authUser, id, dto); } } diff --git a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts b/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts index 780cec682a..e3922d5fc1 100644 --- a/server/apps/immich/src/modules/immich-jwt/immich-jwt.module.ts +++ b/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 {} diff --git a/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts b/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts index 1c284c6da8..ff9ec02901 100644 --- a/server/apps/immich/src/modules/immich-jwt/strategies/public-share.strategy.ts +++ b/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'; diff --git a/server/apps/microservices/src/processors/thumbnail.processor.ts b/server/apps/microservices/src/processors/thumbnail.processor.ts index 0d51981ba6..b6656d86e5 100644 --- a/server/apps/microservices/src/processors/thumbnail.processor.ts +++ b/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'; diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index a3d74b7d34..73aba74a58 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -434,6 +434,145 @@ ] } }, + "/share": { + "get": { + "operationId": "getAllSharedLinks", + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, + "/share/me": { + "get": { + "operationId": "getMySharedLink", + "description": "", + "parameters": [], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, + "/share/{id}": { + "get": { + "operationId": "getSharedLinkById", + "description": "", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + }, + "delete": { + "operationId": "removeSharedLink", + "description": "", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "" + } + }, + "tags": [ + "share" + ] + }, + "patch": { + "operationId": "editSharedLink", + "description": "", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditSharedLinkDto" + } + } + } + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SharedLinkResponseDto" + } + } + } + } + }, + "tags": [ + "share" + ] + } + }, "/system-config": { "get": { "operationId": "getConfig", @@ -2366,152 +2505,6 @@ ] } }, - "/share": { - "get": { - "operationId": "getAllSharedLinks", - "description": "", - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SharedLinkResponseDto" - } - } - } - } - } - }, - "tags": [ - "share" - ] - } - }, - "/share/me": { - "get": { - "operationId": "getMySharedLink", - "description": "", - "parameters": [], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SharedLinkResponseDto" - } - } - } - } - }, - "tags": [ - "share" - ] - } - }, - "/share/{id}": { - "get": { - "operationId": "getSharedLinkById", - "description": "", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SharedLinkResponseDto" - } - } - } - } - }, - "tags": [ - "share" - ] - }, - "delete": { - "operationId": "removeSharedLink", - "description": "", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "string" - } - } - } - } - }, - "tags": [ - "share" - ] - }, - "patch": { - "operationId": "editSharedLink", - "description": "", - "parameters": [ - { - "name": "id", - "required": true, - "in": "path", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EditSharedLinkDto" - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SharedLinkResponseDto" - } - } - } - } - }, - "tags": [ - "share" - ] - } - }, "/device-info": { "post": { "operationId": "createDeviceInfo", @@ -3120,6 +3113,399 @@ "url" ] }, + "SharedLinkType": { + "type": "string", + "enum": [ + "ALBUM", + "INDIVIDUAL" + ] + }, + "AssetTypeEnum": { + "type": "string", + "enum": [ + "IMAGE", + "VIDEO", + "AUDIO", + "OTHER" + ] + }, + "ExifResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "nullable": true, + "default": null, + "format": "int64" + }, + "fileSizeInByte": { + "type": "integer", + "nullable": true, + "default": null, + "format": "int64" + }, + "make": { + "type": "string", + "nullable": true, + "default": null + }, + "model": { + "type": "string", + "nullable": true, + "default": null + }, + "imageName": { + "type": "string", + "nullable": true, + "default": null + }, + "exifImageWidth": { + "type": "number", + "nullable": true, + "default": null + }, + "exifImageHeight": { + "type": "number", + "nullable": true, + "default": null + }, + "orientation": { + "type": "string", + "nullable": true, + "default": null + }, + "dateTimeOriginal": { + "format": "date-time", + "type": "string", + "nullable": true, + "default": null + }, + "modifyDate": { + "format": "date-time", + "type": "string", + "nullable": true, + "default": null + }, + "lensModel": { + "type": "string", + "nullable": true, + "default": null + }, + "fNumber": { + "type": "number", + "nullable": true, + "default": null + }, + "focalLength": { + "type": "number", + "nullable": true, + "default": null + }, + "iso": { + "type": "number", + "nullable": true, + "default": null + }, + "exposureTime": { + "type": "number", + "nullable": true, + "default": null + }, + "latitude": { + "type": "number", + "nullable": true, + "default": null + }, + "longitude": { + "type": "number", + "nullable": true, + "default": null + }, + "city": { + "type": "string", + "nullable": true, + "default": null + }, + "state": { + "type": "string", + "nullable": true, + "default": null + }, + "country": { + "type": "string", + "nullable": true, + "default": null + } + } + }, + "SmartInfoResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tags": { + "nullable": true, + "type": "array", + "items": { + "type": "string" + } + }, + "objects": { + "nullable": true, + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "TagTypeEnum": { + "type": "string", + "enum": [ + "OBJECT", + "FACE", + "CUSTOM" + ] + }, + "TagResponseDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/TagTypeEnum" + }, + "name": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "renameTagId": { + "type": "string", + "nullable": true + } + }, + "required": [ + "id", + "type", + "name", + "userId" + ] + }, + "AssetResponseDto": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/AssetTypeEnum" + }, + "id": { + "type": "string" + }, + "deviceAssetId": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "originalPath": { + "type": "string" + }, + "resizePath": { + "type": "string", + "nullable": true + }, + "createdAt": { + "type": "string" + }, + "modifiedAt": { + "type": "string" + }, + "isFavorite": { + "type": "boolean" + }, + "mimeType": { + "type": "string", + "nullable": true + }, + "duration": { + "type": "string" + }, + "webpPath": { + "type": "string", + "nullable": true + }, + "encodedVideoPath": { + "type": "string", + "nullable": true + }, + "exifInfo": { + "$ref": "#/components/schemas/ExifResponseDto" + }, + "smartInfo": { + "$ref": "#/components/schemas/SmartInfoResponseDto" + }, + "livePhotoVideoId": { + "type": "string", + "nullable": true + }, + "tags": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TagResponseDto" + } + } + }, + "required": [ + "type", + "id", + "deviceAssetId", + "ownerId", + "deviceId", + "originalPath", + "resizePath", + "createdAt", + "modifiedAt", + "isFavorite", + "mimeType", + "duration", + "webpPath", + "tags" + ] + }, + "AlbumResponseDto": { + "type": "object", + "properties": { + "assetCount": { + "type": "integer" + }, + "id": { + "type": "string" + }, + "ownerId": { + "type": "string" + }, + "albumName": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "albumThumbnailAssetId": { + "type": "string", + "nullable": true + }, + "shared": { + "type": "boolean" + }, + "sharedUsers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/UserResponseDto" + } + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + } + }, + "required": [ + "assetCount", + "id", + "ownerId", + "albumName", + "createdAt", + "albumThumbnailAssetId", + "shared", + "sharedUsers", + "assets" + ] + }, + "SharedLinkResponseDto": { + "type": "object", + "properties": { + "type": { + "$ref": "#/components/schemas/SharedLinkType" + }, + "id": { + "type": "string" + }, + "description": { + "type": "string" + }, + "userId": { + "type": "string" + }, + "key": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "nullable": true + }, + "assets": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AssetResponseDto" + } + }, + "album": { + "$ref": "#/components/schemas/AlbumResponseDto" + }, + "allowUpload": { + "type": "boolean" + }, + "allowDownload": { + "type": "boolean" + }, + "showExif": { + "type": "boolean" + } + }, + "required": [ + "type", + "id", + "userId", + "key", + "createdAt", + "expiresAt", + "assets", + "allowUpload", + "allowDownload", + "showExif" + ] + }, + "EditSharedLinkDto": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "expiresAt": { + "type": "string", + "nullable": true + }, + "allowUpload": { + "type": "boolean" + }, + "allowDownload": { + "type": "boolean" + }, + "showExif": { + "type": "boolean" + } + } + }, "SystemConfigFFmpegDto": { "type": "object", "properties": { @@ -3506,264 +3892,6 @@ "searchTerm" ] }, - "AssetTypeEnum": { - "type": "string", - "enum": [ - "IMAGE", - "VIDEO", - "AUDIO", - "OTHER" - ] - }, - "ExifResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "nullable": true, - "default": null, - "format": "int64" - }, - "fileSizeInByte": { - "type": "integer", - "nullable": true, - "default": null, - "format": "int64" - }, - "make": { - "type": "string", - "nullable": true, - "default": null - }, - "model": { - "type": "string", - "nullable": true, - "default": null - }, - "imageName": { - "type": "string", - "nullable": true, - "default": null - }, - "exifImageWidth": { - "type": "number", - "nullable": true, - "default": null - }, - "exifImageHeight": { - "type": "number", - "nullable": true, - "default": null - }, - "orientation": { - "type": "string", - "nullable": true, - "default": null - }, - "dateTimeOriginal": { - "format": "date-time", - "type": "string", - "nullable": true, - "default": null - }, - "modifyDate": { - "format": "date-time", - "type": "string", - "nullable": true, - "default": null - }, - "lensModel": { - "type": "string", - "nullable": true, - "default": null - }, - "fNumber": { - "type": "number", - "nullable": true, - "default": null - }, - "focalLength": { - "type": "number", - "nullable": true, - "default": null - }, - "iso": { - "type": "number", - "nullable": true, - "default": null - }, - "exposureTime": { - "type": "number", - "nullable": true, - "default": null - }, - "latitude": { - "type": "number", - "nullable": true, - "default": null - }, - "longitude": { - "type": "number", - "nullable": true, - "default": null - }, - "city": { - "type": "string", - "nullable": true, - "default": null - }, - "state": { - "type": "string", - "nullable": true, - "default": null - }, - "country": { - "type": "string", - "nullable": true, - "default": null - } - } - }, - "SmartInfoResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "tags": { - "nullable": true, - "type": "array", - "items": { - "type": "string" - } - }, - "objects": { - "nullable": true, - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "TagTypeEnum": { - "type": "string", - "enum": [ - "OBJECT", - "FACE", - "CUSTOM" - ] - }, - "TagResponseDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "type": { - "$ref": "#/components/schemas/TagTypeEnum" - }, - "name": { - "type": "string" - }, - "userId": { - "type": "string" - }, - "renameTagId": { - "type": "string", - "nullable": true - } - }, - "required": [ - "id", - "type", - "name", - "userId" - ] - }, - "AssetResponseDto": { - "type": "object", - "properties": { - "type": { - "$ref": "#/components/schemas/AssetTypeEnum" - }, - "id": { - "type": "string" - }, - "deviceAssetId": { - "type": "string" - }, - "ownerId": { - "type": "string" - }, - "deviceId": { - "type": "string" - }, - "originalPath": { - "type": "string" - }, - "resizePath": { - "type": "string", - "nullable": true - }, - "createdAt": { - "type": "string" - }, - "modifiedAt": { - "type": "string" - }, - "isFavorite": { - "type": "boolean" - }, - "mimeType": { - "type": "string", - "nullable": true - }, - "duration": { - "type": "string" - }, - "webpPath": { - "type": "string", - "nullable": true - }, - "encodedVideoPath": { - "type": "string", - "nullable": true - }, - "exifInfo": { - "$ref": "#/components/schemas/ExifResponseDto" - }, - "smartInfo": { - "$ref": "#/components/schemas/SmartInfoResponseDto" - }, - "livePhotoVideoId": { - "type": "string", - "nullable": true - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TagResponseDto" - } - } - }, - "required": [ - "type", - "id", - "deviceAssetId", - "ownerId", - "deviceId", - "originalPath", - "resizePath", - "createdAt", - "modifiedAt", - "isFavorite", - "mimeType", - "duration", - "webpPath", - "tags" - ] - }, "TimeGroupEnum": { "type": "string", "enum": [ @@ -4005,7 +4133,7 @@ "type": "string" } }, - "expiredAt": { + "expiresAt": { "type": "string" }, "allowUpload": { @@ -4025,120 +4153,6 @@ "assetIds" ] }, - "SharedLinkType": { - "type": "string", - "enum": [ - "ALBUM", - "INDIVIDUAL" - ] - }, - "AlbumResponseDto": { - "type": "object", - "properties": { - "assetCount": { - "type": "integer" - }, - "id": { - "type": "string" - }, - "ownerId": { - "type": "string" - }, - "albumName": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "albumThumbnailAssetId": { - "type": "string", - "nullable": true - }, - "shared": { - "type": "boolean" - }, - "sharedUsers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/UserResponseDto" - } - }, - "assets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - } - } - }, - "required": [ - "assetCount", - "id", - "ownerId", - "albumName", - "createdAt", - "albumThumbnailAssetId", - "shared", - "sharedUsers", - "assets" - ] - }, - "SharedLinkResponseDto": { - "type": "object", - "properties": { - "type": { - "$ref": "#/components/schemas/SharedLinkType" - }, - "id": { - "type": "string" - }, - "description": { - "type": "string" - }, - "userId": { - "type": "string" - }, - "key": { - "type": "string" - }, - "createdAt": { - "type": "string" - }, - "expiresAt": { - "type": "string", - "nullable": true - }, - "assets": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AssetResponseDto" - } - }, - "album": { - "$ref": "#/components/schemas/AlbumResponseDto" - }, - "allowUpload": { - "type": "boolean" - }, - "allowDownload": { - "type": "boolean" - }, - "showExif": { - "type": "boolean" - } - }, - "required": [ - "type", - "id", - "userId", - "key", - "createdAt", - "expiresAt", - "assets", - "allowUpload", - "allowDownload", - "showExif" - ] - }, "UpdateAssetsToSharedLinkDto": { "type": "object", "properties": { @@ -4301,7 +4315,7 @@ "albumId": { "type": "string" }, - "expiredAt": { + "expiresAt": { "type": "string" }, "allowUpload": { @@ -4321,29 +4335,6 @@ "albumId" ] }, - "EditSharedLinkDto": { - "type": "object", - "properties": { - "description": { - "type": "string" - }, - "expiredAt": { - "type": "string" - }, - "allowUpload": { - "type": "boolean" - }, - "allowDownload": { - "type": "boolean" - }, - "showExif": { - "type": "boolean" - }, - "isEditExpireTime": { - "type": "boolean" - } - } - }, "DeviceTypeEnum": { "type": "string", "enum": [ diff --git a/server/libs/common/src/utils/asset-utils.ts b/server/libs/common/src/utils/asset-utils.ts index 8bf7027639..e8cee3588f 100644 --- a/server/libs/common/src/utils/asset-utils.ts +++ b/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) => { diff --git a/server/libs/domain/src/album/index.ts b/server/libs/domain/src/album/index.ts new file mode 100644 index 0000000000..0b415ca923 --- /dev/null +++ b/server/libs/domain/src/album/index.ts @@ -0,0 +1 @@ +export * from './response-dto'; diff --git a/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts b/server/libs/domain/src/album/response-dto/album-response.dto.ts similarity index 89% rename from server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts rename to server/libs/domain/src/album/response-dto/album-response.dto.ts index 5cac1bd5d6..0be9173f46 100644 --- a/server/apps/immich/src/api-v1/album/response-dto/album-response.dto.ts +++ b/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; diff --git a/server/libs/domain/src/album/response-dto/index.ts b/server/libs/domain/src/album/response-dto/index.ts new file mode 100644 index 0000000000..d3b278b7dd --- /dev/null +++ b/server/libs/domain/src/album/response-dto/index.ts @@ -0,0 +1 @@ +export * from './album-response.dto'; diff --git a/server/libs/domain/src/asset/index.ts b/server/libs/domain/src/asset/index.ts new file mode 100644 index 0000000000..0b415ca923 --- /dev/null +++ b/server/libs/domain/src/asset/index.ts @@ -0,0 +1 @@ +export * from './response-dto'; diff --git a/server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts b/server/libs/domain/src/asset/response-dto/asset-response.dto.ts similarity index 94% rename from server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts rename to server/libs/domain/src/asset/response-dto/asset-response.dto.ts index 9a1f09e625..dc09f6d8c6 100644 --- a/server/apps/immich/src/api-v1/asset/response-dto/asset-response.dto.ts +++ b/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'; diff --git a/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts b/server/libs/domain/src/asset/response-dto/exif-response.dto.ts similarity index 95% rename from server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts rename to server/libs/domain/src/asset/response-dto/exif-response.dto.ts index 8e4b2e82de..0dd97a3111 100644 --- a/server/apps/immich/src/api-v1/asset/response-dto/exif-response.dto.ts +++ b/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, diff --git a/server/libs/domain/src/asset/response-dto/index.ts b/server/libs/domain/src/asset/response-dto/index.ts new file mode 100644 index 0000000000..1a274731de --- /dev/null +++ b/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'; diff --git a/server/apps/immich/src/api-v1/asset/response-dto/smart-info-response.dto.ts b/server/libs/domain/src/asset/response-dto/smart-info-response.dto.ts similarity index 82% rename from server/apps/immich/src/api-v1/asset/response-dto/smart-info-response.dto.ts rename to server/libs/domain/src/asset/response-dto/smart-info-response.dto.ts index 00e6790031..788bec8d91 100644 --- a/server/apps/immich/src/api-v1/asset/response-dto/smart-info-response.dto.ts +++ b/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; diff --git a/server/libs/domain/src/domain.module.ts b/server/libs/domain/src/domain.module.ts index 50fe668bc5..8079851276 100644 --- a/server/libs/domain/src/domain.module.ts +++ b/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, diff --git a/server/libs/domain/src/index.ts b/server/libs/domain/src/index.ts index 0f1706cf47..809f8b0618 100644 --- a/server/libs/domain/src/index.ts +++ b/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'; diff --git a/server/libs/domain/src/job/interfaces/metadata-extraction.interface.ts b/server/libs/domain/src/job/interfaces/metadata-extraction.interface.ts index 8140c8f393..bfacdb1e05 100644 --- a/server/libs/domain/src/job/interfaces/metadata-extraction.interface.ts +++ b/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; } diff --git a/server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts b/server/libs/domain/src/share/dto/create-shared-link.dto.ts similarity index 54% rename from server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts rename to server/libs/domain/src/share/dto/create-shared-link.dto.ts index b5ef2ca138..ffaba65608 100644 --- a/server/apps/immich/src/api-v1/share/dto/create-shared-link.dto.ts +++ b/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; diff --git a/server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts b/server/libs/domain/src/share/dto/edit-shared-link.dto.ts similarity index 63% rename from server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts rename to server/libs/domain/src/share/dto/edit-shared-link.dto.ts index e787e3c5bb..92ea2619ad 100644 --- a/server/apps/immich/src/api-v1/share/dto/edit-shared-link.dto.ts +++ b/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; } diff --git a/server/libs/domain/src/share/dto/index.ts b/server/libs/domain/src/share/dto/index.ts new file mode 100644 index 0000000000..8f29f0ca78 --- /dev/null +++ b/server/libs/domain/src/share/dto/index.ts @@ -0,0 +1,2 @@ +export * from './create-shared-link.dto'; +export * from './edit-shared-link.dto'; diff --git a/server/libs/domain/src/share/index.ts b/server/libs/domain/src/share/index.ts new file mode 100644 index 0000000000..8c4a085774 --- /dev/null +++ b/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'; diff --git a/server/libs/domain/src/share/response-dto/index.ts b/server/libs/domain/src/share/response-dto/index.ts new file mode 100644 index 0000000000..008ee5c1a0 --- /dev/null +++ b/server/libs/domain/src/share/response-dto/index.ts @@ -0,0 +1 @@ +export * from './shared-link-response.dto'; diff --git a/server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts b/server/libs/domain/src/share/response-dto/shared-link-response.dto.ts similarity index 93% rename from server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts rename to server/libs/domain/src/share/response-dto/shared-link-response.dto.ts index 78b13c1281..6e9b2fda92 100644 --- a/server/apps/immich/src/api-v1/share/response-dto/shared-link-response.dto.ts +++ b/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; diff --git a/server/libs/domain/src/share/share.core.ts b/server/libs/domain/src/share/share.core.ts new file mode 100644 index 0000000000..ffa8f1e20d --- /dev/null +++ b/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 { + return this.repository.getAll(userId); + } + + get(userId: string, id: string): Promise { + return this.repository.get(userId, id); + } + + getByKey(key: string): Promise { + return this.repository.getByKey(key); + } + + create(userId: string, dto: CreateSharedLinkDto): Promise { + 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): Promise { + 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 { + 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 { + return this.repository.hasAssetAccess(id, assetId); + } + + checkDownloadAccess(user: AuthUserDto) { + if (user.isPublicUser && !user.isAllowDownload) { + throw new ForbiddenException(); + } + } +} diff --git a/server/libs/domain/src/share/share.service.spec.ts b/server/libs/domain/src/share/share.service.spec.ts new file mode 100644 index 0000000000..9f997b4a56 --- /dev/null +++ b/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; + let shareMock: jest.Mocked; + let userMock: jest.Mocked; + + 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, + }); + }); + }); +}); diff --git a/server/apps/immich/src/api-v1/share/share.service.ts b/server/libs/domain/src/share/share.service.ts similarity index 59% rename from server/apps/immich/src/api-v1/share/share.service.ts rename to server/libs/domain/src/share/share.service.ts index 2b33f8ed33..eca46d97ab 100644 --- a/server/apps/immich/src/api-v1/share/share.service.ts +++ b/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 { - 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 { - 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 { - const link = await this.shareCore.getSharedLinkById(id); + async getById(authUser: AuthUserDto, id: string, allowExif: boolean): Promise { + 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 { - await this.shareCore.removeSharedLink(id, userId); - return id; - } - async getByKey(key: string): Promise { - 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 { + 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); } } diff --git a/server/libs/domain/src/share/shared-link.repository.ts b/server/libs/domain/src/share/shared-link.repository.ts new file mode 100644 index 0000000000..3fd1374664 --- /dev/null +++ b/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; + get(userId: string, id: string): Promise; + getByKey(key: string): Promise; + create(entity: Omit): Promise; + remove(entity: SharedLinkEntity): Promise; + save(entity: Partial): Promise; + hasAssetAccess(id: string, assetId: string): Promise; +} diff --git a/server/libs/domain/src/tag/index.ts b/server/libs/domain/src/tag/index.ts new file mode 100644 index 0000000000..0b415ca923 --- /dev/null +++ b/server/libs/domain/src/tag/index.ts @@ -0,0 +1 @@ +export * from './response-dto'; diff --git a/server/libs/domain/src/tag/response-dto/index.ts b/server/libs/domain/src/tag/response-dto/index.ts new file mode 100644 index 0000000000..b08b1f61c4 --- /dev/null +++ b/server/libs/domain/src/tag/response-dto/index.ts @@ -0,0 +1 @@ +export * from './tag-response.dto'; diff --git a/server/apps/immich/src/api-v1/tag/response-dto/tag-response.dto.ts b/server/libs/domain/src/tag/response-dto/tag-response.dto.ts similarity index 88% rename from server/apps/immich/src/api-v1/tag/response-dto/tag-response.dto.ts rename to server/libs/domain/src/tag/response-dto/tag-response.dto.ts index 294f944371..90a3273331 100644 --- a/server/apps/immich/src/api-v1/tag/response-dto/tag-response.dto.ts +++ b/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 { diff --git a/server/libs/domain/test/fixtures.ts b/server/libs/domain/test/fixtures.ts index f612663c09..f5d622fcc4 100644 --- a/server/libs/domain/test/fixtures.ts +++ b/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({ @@ -16,6 +82,26 @@ export const authStub = { isPublicUser: false, isAllowUpload: true, }), + adminSharedLink: Object.freeze({ + id: 'admin_id', + email: 'admin@test.com', + isAdmin: true, + isAllowUpload: true, + isAllowDownload: true, + isPublicUser: true, + isShowExif: true, + sharedLinkId: '123', + }), + readonlySharedLink: Object.freeze({ + 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({ + 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({ + 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({ + 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({ + 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({ + 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(); diff --git a/server/libs/domain/test/index.ts b/server/libs/domain/test/index.ts index db8e2ede60..85c949d5c4 100644 --- a/server/libs/domain/test/index.ts +++ b/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'; diff --git a/server/libs/domain/test/shared-link.repository.mock.ts b/server/libs/domain/test/shared-link.repository.mock.ts new file mode 100644 index 0000000000..b404fa73ba --- /dev/null +++ b/server/libs/domain/test/shared-link.repository.mock.ts @@ -0,0 +1,13 @@ +import { ISharedLinkRepository } from '../src'; + +export const newSharedLinkRepositoryMock = (): jest.Mocked => { + return { + getAll: jest.fn(), + get: jest.fn(), + getByKey: jest.fn(), + create: jest.fn(), + remove: jest.fn(), + save: jest.fn(), + hasAssetAccess: jest.fn(), + }; +}; diff --git a/server/libs/infra/src/db/entities/exif.entity.ts b/server/libs/infra/src/db/entities/exif.entity.ts index 6a013f81a9..a086260147 100644 --- a/server/libs/infra/src/db/entities/exif.entity.ts +++ b/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' }) diff --git a/server/libs/infra/src/db/repository/index.ts b/server/libs/infra/src/db/repository/index.ts index f7e2fb0b21..899bc21760 100644 --- a/server/libs/infra/src/db/repository/index.ts +++ b/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'; diff --git a/server/libs/infra/src/db/repository/shared-link.repository.ts b/server/libs/infra/src/db/repository/shared-link.repository.ts new file mode 100644 index 0000000000..9f372a3e96 --- /dev/null +++ b/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, + ) {} + + get(userId: string, id: string): Promise { + 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 { + return this.repository.find({ + where: { + userId, + }, + relations: { + assets: true, + album: true, + }, + order: { + createdAt: 'DESC', + }, + }); + } + + async getByKey(key: string): Promise { + return await this.repository.findOne({ + where: { + key: Buffer.from(key, 'hex'), + }, + relations: { + assets: true, + album: { + assets: { + assetInfo: true, + }, + }, + }, + order: { + createdAt: 'DESC', + }, + }); + } + + create(entity: Omit): Promise { + return this.repository.save(entity); + } + + remove(entity: SharedLinkEntity): Promise { + return this.repository.remove(entity); + } + + async save(entity: SharedLinkEntity): Promise { + await this.repository.save(entity); + return this.repository.findOneOrFail({ where: { id: entity.id } }); + } + + async hasAssetAccess(id: string, assetId: string): Promise { + 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); + } +} diff --git a/server/libs/infra/src/infra.module.ts b/server/libs/infra/src/infra.module.ts index b8371bd67d..67248c4eb4 100644 --- a/server/libs/infra/src/infra.module.ts +++ b/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', diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 65f631f1de..f3309115f0 100644 --- a/web/src/api/open-api/api.ts +++ b/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> { + async removeSharedLink(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { 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 { + removeSharedLink(id: string, options?: any): AxiosPromise { return localVarFp.removeSharedLink(id, options).then((request) => request(axios, basePath)); }, }; diff --git a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte b/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte index b0caaf99f5..dd08d76e36 100644 --- a/web/src/lib/components/shared-components/create-share-link-modal/create-shared-link-modal.svelte +++ b/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 });