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
70 changed files with 1481 additions and 1057 deletions

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

@@ -1,62 +0,0 @@
import { AlbumEntity } from '@app/infra';
import { UserResponseDto, mapUser } from '@app/domain';
import { AssetResponseDto, mapAsset } from '../../asset/response-dto/asset-response.dto';
import { ApiProperty } from '@nestjs/swagger';
export class AlbumResponseDto {
id!: string;
ownerId!: string;
albumName!: string;
createdAt!: string;
albumThumbnailAssetId!: string | null;
shared!: boolean;
sharedUsers!: UserResponseDto[];
assets!: AssetResponseDto[];
@ApiProperty({ type: 'integer' })
assetCount!: number;
}
export function mapAlbum(entity: AlbumEntity): AlbumResponseDto {
const sharedUsers: UserResponseDto[] = [];
entity.sharedUsers?.forEach((userAlbum) => {
if (userAlbum.userInfo) {
const user = mapUser(userAlbum.userInfo);
sharedUsers.push(user);
}
});
return {
albumName: entity.albumName,
albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: entity.createdAt,
id: entity.id,
ownerId: entity.ownerId,
sharedUsers,
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: entity.assets?.map((assetAlbum) => mapAsset(assetAlbum.assetInfo)) || [],
assetCount: entity.assets?.length || 0,
};
}
export function mapAlbumExcludeAssetInfo(entity: AlbumEntity): AlbumResponseDto {
const sharedUsers: UserResponseDto[] = [];
entity.sharedUsers?.forEach((userAlbum) => {
if (userAlbum.userInfo) {
const user = mapUser(userAlbum.userInfo);
sharedUsers.push(user);
}
});
return {
albumName: entity.albumName,
albumThumbnailAssetId: entity.albumThumbnailAssetId,
createdAt: entity.createdAt,
id: entity.id,
ownerId: entity.ownerId,
sharedUsers,
shared: sharedUsers.length > 0 || entity.sharedLinks?.length > 0,
assets: [],
assetCount: entity.assets?.length || 0,
};
}

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,74 +0,0 @@
import { AssetEntity, AssetType } from '@app/infra';
import { ApiProperty } from '@nestjs/swagger';
import { mapTag, TagResponseDto } from '../../tag/response-dto/tag-response.dto';
import { ExifResponseDto, mapExif } from './exif-response.dto';
import { SmartInfoResponseDto, mapSmartInfo } from './smart-info-response.dto';
export class AssetResponseDto {
id!: string;
deviceAssetId!: string;
ownerId!: string;
deviceId!: string;
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
type!: AssetType;
originalPath!: string;
resizePath!: string | null;
createdAt!: string;
modifiedAt!: string;
isFavorite!: boolean;
mimeType!: string | null;
duration!: string;
webpPath!: string | null;
encodedVideoPath?: string | null;
exifInfo?: ExifResponseDto;
smartInfo?: SmartInfoResponseDto;
livePhotoVideoId?: string | null;
tags!: TagResponseDto[];
}
export function mapAsset(entity: AssetEntity): AssetResponseDto {
return {
id: entity.id,
deviceAssetId: entity.deviceAssetId,
ownerId: entity.userId,
deviceId: entity.deviceId,
type: entity.type,
originalPath: entity.originalPath,
resizePath: entity.resizePath,
createdAt: entity.createdAt,
modifiedAt: entity.modifiedAt,
isFavorite: entity.isFavorite,
mimeType: entity.mimeType,
webpPath: entity.webpPath,
encodedVideoPath: entity.encodedVideoPath,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
};
}
export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
return {
id: entity.id,
deviceAssetId: entity.deviceAssetId,
ownerId: entity.userId,
deviceId: entity.deviceId,
type: entity.type,
originalPath: entity.originalPath,
resizePath: entity.resizePath,
createdAt: entity.createdAt,
modifiedAt: entity.modifiedAt,
isFavorite: entity.isFavorite,
mimeType: entity.mimeType,
webpPath: entity.webpPath,
encodedVideoPath: entity.encodedVideoPath,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: undefined,
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
};
}

View File

@@ -1,53 +0,0 @@
import { ExifEntity } from '@app/infra';
import { ApiProperty } from '@nestjs/swagger';
export class ExifResponseDto {
@ApiProperty({ type: 'integer', format: 'int64' })
id?: number | null = null;
make?: string | null = null;
model?: string | null = null;
imageName?: string | null = null;
exifImageWidth?: number | null = null;
exifImageHeight?: number | null = null;
@ApiProperty({ type: 'integer', format: 'int64' })
fileSizeInByte?: number | null = null;
orientation?: string | null = null;
dateTimeOriginal?: Date | null = null;
modifyDate?: Date | null = null;
lensModel?: string | null = null;
fNumber?: number | null = null;
focalLength?: number | null = null;
iso?: number | null = null;
exposureTime?: number | null = null;
latitude?: number | null = null;
longitude?: number | null = null;
city?: string | null = null;
state?: string | null = null;
country?: string | null = null;
}
export function mapExif(entity: ExifEntity): ExifResponseDto {
return {
id: parseInt(entity.id),
make: entity.make,
model: entity.model,
imageName: entity.imageName,
exifImageWidth: entity.exifImageWidth,
exifImageHeight: entity.exifImageHeight,
fileSizeInByte: entity.fileSizeInByte ? parseInt(entity.fileSizeInByte.toString()) : null,
orientation: entity.orientation,
dateTimeOriginal: entity.dateTimeOriginal,
modifyDate: entity.modifyDate,
lensModel: entity.lensModel,
fNumber: entity.fNumber,
focalLength: entity.focalLength,
iso: entity.iso,
exposureTime: entity.exposureTime,
latitude: entity.latitude,
longitude: entity.longitude,
city: entity.city,
state: entity.state,
country: entity.country,
};
}

View File

@@ -1,15 +0,0 @@
import { SmartInfoEntity } from '@app/infra';
export class SmartInfoResponseDto {
id?: string;
tags?: string[] | null;
objects?: string[] | null;
}
export function mapSmartInfo(entity: SmartInfoEntity): SmartInfoResponseDto {
return {
id: entity.id,
tags: entity.tags,
objects: entity.objects,
};
}

View File

@@ -1,13 +0,0 @@
import { AlbumEntity, AssetEntity } from '@app/infra';
import { SharedLinkType } from '@app/infra';
export class CreateSharedLinkDto {
description?: string;
expiredAt?: string;
sharedType!: SharedLinkType;
assets!: AssetEntity[];
album?: AlbumEntity;
allowUpload?: boolean;
allowDownload?: boolean;
showExif?: boolean;
}

View File

@@ -1,21 +0,0 @@
import { IsNotEmpty, IsOptional } from 'class-validator';
export class EditSharedLinkDto {
@IsOptional()
description?: string;
@IsOptional()
expiredAt?: string;
@IsOptional()
allowUpload?: boolean;
@IsOptional()
allowDownload?: boolean;
@IsOptional()
showExif?: boolean;
@IsNotEmpty()
isEditExpireTime?: boolean;
}

View File

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

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,99 +0,0 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
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 { ShareCore } from './share.core';
import { ISharedLinkRepository } from './shared-link.repository';
@Injectable()
export class ShareService {
readonly logger = new Logger(ShareService.name);
private shareCore: ShareCore;
constructor(
@Inject(ISharedLinkRepository)
sharedLinkRepository: ISharedLinkRepository,
private userService: UserService,
) {
this.shareCore = new ShareCore(sharedLinkRepository);
}
async validate(key: string): Promise<AuthUserDto> {
const link = await this.shareCore.getSharedLinkByKey(key);
if (link) {
if (!link.expiresAt || new Date(link.expiresAt) > new Date()) {
const user = await this.userService.getUserById(link.userId).catch(() => null);
if (user) {
return {
id: user.id,
email: user.email,
isAdmin: user.isAdmin,
isPublicUser: true,
sharedLinkId: link.id,
isAllowUpload: link.allowUpload,
isAllowDownload: link.allowDownload,
isShowExif: link.showExif,
};
}
}
}
throw new UnauthorizedException();
}
async getAll(authUser: AuthUserDto): Promise<SharedLinkResponseDto[]> {
const links = await this.shareCore.getSharedLinks(authUser.id);
return links.map(mapSharedLink);
}
async getMine(authUser: AuthUserDto): Promise<SharedLinkResponseDto> {
if (!authUser.isPublicUser || !authUser.sharedLinkId) {
throw new ForbiddenException();
}
let allowExif = true;
if (authUser.isShowExif != undefined) {
allowExif = authUser.isShowExif;
}
return this.getById(authUser.sharedLinkId, allowExif);
}
async getById(id: string, allowExif: boolean): Promise<SharedLinkResponseDto> {
const link = await this.shareCore.getSharedLinkById(id);
if (!link) {
throw new BadRequestException('Shared link not found');
}
if (allowExif) {
return mapSharedLink(link);
} else {
return mapSharedLinkWithNoExif(link);
}
}
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);
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);
return mapSharedLink(link);
}
}

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

@@ -1,26 +0,0 @@
import { TagEntity, TagType } from '@app/infra';
import { ApiProperty } from '@nestjs/swagger';
export class TagResponseDto {
@ApiProperty()
id!: string;
@ApiProperty({ enumName: 'TagTypeEnum', enum: TagType })
type!: string;
name!: string;
userId!: string;
renameTagId?: string | null;
}
export function mapTag(entity: TagEntity): TagResponseDto {
return {
id: entity.id,
type: entity.type,
name: entity.name,
userId: entity.userId,
renameTagId: entity.renameTagId,
};
}

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