1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

refactor(server): shared links (#1385)

* refactor(server): shared links

* chore: tests

* fix: bugs and tests

* fix: missed one expired at

* fix: standardize file upload checks

* test: lower flutter version

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2023-01-25 11:35:28 -05:00 committed by GitHub
parent f64db3a2f9
commit 8f304b8157
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
70 changed files with 1437 additions and 975 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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';

View File

@ -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],

View File

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

View File

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

View File

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

View File

@ -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' })

View File

@ -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';

View File

@ -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],

View File

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

View File

@ -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<SharedLinkResponseDto> {
if (!authUser.sharedLinkId) throw new ForbiddenException();
if (!authUser.sharedLinkId) {
throw new ForbiddenException();
}
const assets = [];
await this.checkAssetsAccess(authUser, dto.assetIds);
for (const assetId of dto.assetIds) {
const asset = await this._assetRepository.getById(assetId);
assets.push(asset);
}
const updatedLink = await this.shareCore.updateAssetsInSharedLink(authUser.sharedLinkId, assets);
const updatedLink = await this.shareCore.updateAssets(authUser.id, authUser.sharedLinkId, assets);
return mapSharedLink(updatedLink);
}

View File

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

View File

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

View File

@ -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 {}

View File

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

View File

@ -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')

View File

@ -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 {

View File

@ -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,
],

View File

@ -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());
}

View File

@ -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';

View File

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

View File

@ -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 {}

View File

@ -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';

View File

@ -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';

File diff suppressed because it is too large Load Diff

View File

@ -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) => {

View File

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

View File

@ -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;

View File

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

View File

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

View File

@ -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';

View File

@ -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,

View File

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

View File

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

View File

@ -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,

View File

@ -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';

View File

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

View File

@ -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;

View File

@ -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;
}

View File

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

View File

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

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 {

View File

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

View File

@ -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';

View File

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

View File

@ -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' })

View File

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

View File

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

View File

@ -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',

View File

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

View File

@ -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
});