refactor(server): move checkExistingAssets(), checkBulkUpdate() remove getAllAssets() (#9715)

* Refactor controller methods, non-breaking change

* Remove getAllAssets

* used imports

* sync:sql

* missing mock

* Removing remaining references

* chore: remove unused code

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
This commit is contained in:
Min Idzelis 2024-05-24 21:02:22 -04:00 committed by GitHub
parent 95012dc19b
commit d5cf8e4bfe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 286 additions and 684 deletions

View file

@ -1,10 +1,12 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Inject,
Param,
ParseFilePipe,
Post,
Put,
Res,
UploadedFiles,
@ -13,8 +15,18 @@ import {
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { Response } from 'express';
import { EndpointLifecycle } from 'src/decorators';
import { AssetMediaResponseDto, AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto';
import { AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
AssetMediaStatusEnum,
CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-media-response.dto';
import {
AssetBulkUploadCheckDto,
AssetMediaReplaceDto,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
@ -53,4 +65,30 @@ export class AssetMediaController {
}
return responseDto;
}
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('exist')
@HttpCode(HttpStatus.OK)
@Authenticated()
checkExistingAssets(
@Auth() auth: AuthDto,
@Body() dto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this.service.checkExistingAssets(auth, dto);
}
/**
* Checks if assets exist by checksums
*/
@Post('bulk-upload-check')
@HttpCode(HttpStatus.OK)
@Authenticated()
checkBulkUpload(
@Auth() auth: AuthDto,
@Body() dto: AssetBulkUploadCheckDto,
): Promise<AssetBulkUploadCheckResponseDto> {
return this.service.bulkUploadCheck(auth, dto);
}
}

View file

@ -2,7 +2,6 @@ import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Inject,
Next,
@ -16,20 +15,8 @@ import {
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import {
AssetBulkUploadCheckResponseDto,
AssetFileUploadResponseDto,
CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-v1-response.dto';
import {
AssetBulkUploadCheckDto,
AssetSearchDto,
CheckExistingAssetsDto,
CreateAssetDto,
GetAssetThumbnailDto,
ServeFileDto,
} from 'src/dtos/asset-v1.dto';
import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto';
import { CreateAssetDto, GetAssetThumbnailDto, ServeFileDto } from 'src/dtos/asset-v1.dto';
import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
@ -109,45 +96,4 @@ export class AssetControllerV1 {
) {
await sendFile(res, next, () => this.service.serveThumbnail(auth, id, dto), this.logger);
}
/**
* Get all AssetEntity belong to the user
*/
@Get('/')
@ApiHeader({
name: 'if-none-match',
description: 'ETag of data already cached on the client',
required: false,
schema: { type: 'string' },
})
@Authenticated()
getAllAssets(@Auth() auth: AuthDto, @Query() dto: AssetSearchDto): Promise<AssetResponseDto[]> {
return this.service.getAllAssets(auth, dto);
}
/**
* Checks if multiple assets exist on the server and returns all existing - used by background backup
*/
@Post('/exist')
@HttpCode(HttpStatus.OK)
@Authenticated()
checkExistingAssets(
@Auth() auth: AuthDto,
@Body() dto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return this.service.checkExistingAssets(auth, dto);
}
/**
* Checks if assets exist by checksums
*/
@Post('/bulk-upload-check')
@HttpCode(HttpStatus.OK)
@Authenticated()
checkBulkUpload(
@Auth() auth: AuthDto,
@Body() dto: AssetBulkUploadCheckDto,
): Promise<AssetBulkUploadCheckResponseDto> {
return this.service.bulkUploadCheck(auth, dto);
}
}

View file

@ -9,3 +9,28 @@ export class AssetMediaResponseDto {
status!: AssetMediaStatusEnum;
id!: string;
}
export enum AssetUploadAction {
ACCEPT = 'accept',
REJECT = 'reject',
}
export enum AssetRejectReason {
DUPLICATE = 'duplicate',
UNSUPPORTED_FORMAT = 'unsupported-format',
}
export class AssetBulkUploadCheckResult {
id!: string;
action!: AssetUploadAction;
reason?: AssetRejectReason;
assetId?: string;
}
export class AssetBulkUploadCheckResponseDto {
results!: AssetBulkUploadCheckResult[];
}
export class CheckExistingAssetsResponseDto {
existingIds!: string[];
}

View file

@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString } from 'class-validator';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
import { Optional, ValidateDate } from 'src/validation';
export enum UploadFieldName {
@ -33,3 +34,31 @@ export class AssetMediaReplaceDto {
@ApiProperty({ type: 'string', format: 'binary' })
[UploadFieldName.ASSET_DATA]!: any;
}
export class AssetBulkUploadCheckItem {
@IsString()
@IsNotEmpty()
id!: string;
/** base64 or hex encoded sha1 hash */
@IsString()
@IsNotEmpty()
checksum!: string;
}
export class AssetBulkUploadCheckDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetBulkUploadCheckItem)
assets!: AssetBulkUploadCheckItem[];
}
export class CheckExistingAssetsDto {
@ArrayNotEmpty()
@IsString({ each: true })
@IsNotEmpty({ each: true })
deviceAssetIds!: string[];
@IsNotEmpty()
deviceId!: string;
}

View file

@ -1,29 +1,4 @@
export class AssetBulkUploadCheckResult {
id!: string;
action!: AssetUploadAction;
reason?: AssetRejectReason;
assetId?: string;
}
export class AssetBulkUploadCheckResponseDto {
results!: AssetBulkUploadCheckResult[];
}
export enum AssetUploadAction {
ACCEPT = 'accept',
REJECT = 'reject',
}
export enum AssetRejectReason {
DUPLICATE = 'duplicate',
UNSUPPORTED_FORMAT = 'unsupported-format',
}
export class AssetFileUploadResponseDto {
id!: string;
duplicate!: boolean;
}
export class CheckExistingAssetsResponseDto {
existingIds!: string[];
}

View file

@ -1,68 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsEnum, IsInt, IsNotEmpty, IsString, IsUUID, ValidateNested } from 'class-validator';
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
import { UploadFieldName } from 'src/dtos/asset.dto';
import { Optional, ValidateBoolean, ValidateDate } from 'src/validation';
export class AssetBulkUploadCheckItem {
@IsString()
@IsNotEmpty()
id!: string;
/** base64 or hex encoded sha1 hash */
@IsString()
@IsNotEmpty()
checksum!: string;
}
export class AssetBulkUploadCheckDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => AssetBulkUploadCheckItem)
assets!: AssetBulkUploadCheckItem[];
}
export class AssetSearchDto {
@ValidateBoolean({ optional: true })
isFavorite?: boolean;
@ValidateBoolean({ optional: true })
isArchived?: boolean;
@Optional()
@IsInt()
@Type(() => Number)
@ApiProperty({ type: 'integer' })
skip?: number;
@Optional()
@IsInt()
@Type(() => Number)
@ApiProperty({ type: 'integer' })
take?: number;
@Optional()
@IsUUID('4')
@ApiProperty({ format: 'uuid' })
userId?: string;
@ValidateDate({ optional: true })
updatedAfter?: Date;
@ValidateDate({ optional: true })
updatedBefore?: Date;
}
export class CheckExistingAssetsDto {
@ArrayNotEmpty()
@IsString({ each: true })
@IsNotEmpty({ each: true })
deviceAssetIds!: string[];
@IsNotEmpty()
deviceId!: string;
}
export class CreateAssetDto {
@IsNotEmpty()
@IsString()

View file

@ -1,4 +1,3 @@
import { AssetSearchDto, CheckExistingAssetsDto } from 'src/dtos/asset-v1.dto';
import { AssetEntity } from 'src/entities/asset.entity';
export interface AssetCheck {
@ -12,10 +11,7 @@ export interface AssetOwnerCheck extends AssetCheck {
export interface IAssetRepositoryV1 {
get(id: string): Promise<AssetEntity | null>;
getAllByUserId(userId: string, dto: AssetSearchDto): Promise<AssetEntity[]>;
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
getExistingAssets(userId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]>;
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null>;
}
export const IAssetRepositoryV1 = 'IAssetRepositoryV1';

View file

@ -168,8 +168,10 @@ export interface IAssetRepository {
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
getByChecksum(libraryId: string | null, checksum: Buffer): Promise<AssetEntity | null>;
getByChecksums(userId: string, checksums: Buffer[]): Promise<AssetEntity[]>;
getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<AssetEntity[]>;
getByUserId(pagination: PaginationOptions, userId: string, options?: AssetSearchOptions): Paginated<AssetEntity>;
getById(
id: string,

View file

@ -482,6 +482,20 @@ WHERE
LIMIT
1
-- AssetRepository.getByChecksums
SELECT
"AssetEntity"."id" AS "AssetEntity_id",
"AssetEntity"."checksum" AS "AssetEntity_checksum"
FROM
"assets" "AssetEntity"
WHERE
(
("AssetEntity"."ownerId" = $1)
AND (
"AssetEntity"."checksum" IN ($2, $3, $4, $5, $6, $7, $8, $9, $10)
)
)
-- AssetRepository.getUploadAssetIdByChecksum
SELECT
"AssetEntity"."id" AS "AssetEntity_id"

View file

@ -74,43 +74,6 @@ WHERE
((("LibraryEntity"."ownerId" = $1)))
AND ("LibraryEntity"."deletedAt" IS NULL)
-- LibraryRepository.getAllByUserId
SELECT
"LibraryEntity"."id" AS "LibraryEntity_id",
"LibraryEntity"."name" AS "LibraryEntity_name",
"LibraryEntity"."ownerId" AS "LibraryEntity_ownerId",
"LibraryEntity"."importPaths" AS "LibraryEntity_importPaths",
"LibraryEntity"."exclusionPatterns" AS "LibraryEntity_exclusionPatterns",
"LibraryEntity"."createdAt" AS "LibraryEntity_createdAt",
"LibraryEntity"."updatedAt" AS "LibraryEntity_updatedAt",
"LibraryEntity"."deletedAt" AS "LibraryEntity_deletedAt",
"LibraryEntity"."refreshedAt" AS "LibraryEntity_refreshedAt",
"LibraryEntity__LibraryEntity_owner"."id" AS "LibraryEntity__LibraryEntity_owner_id",
"LibraryEntity__LibraryEntity_owner"."name" AS "LibraryEntity__LibraryEntity_owner_name",
"LibraryEntity__LibraryEntity_owner"."isAdmin" AS "LibraryEntity__LibraryEntity_owner_isAdmin",
"LibraryEntity__LibraryEntity_owner"."email" AS "LibraryEntity__LibraryEntity_owner_email",
"LibraryEntity__LibraryEntity_owner"."storageLabel" AS "LibraryEntity__LibraryEntity_owner_storageLabel",
"LibraryEntity__LibraryEntity_owner"."oauthId" AS "LibraryEntity__LibraryEntity_owner_oauthId",
"LibraryEntity__LibraryEntity_owner"."profileImagePath" AS "LibraryEntity__LibraryEntity_owner_profileImagePath",
"LibraryEntity__LibraryEntity_owner"."shouldChangePassword" AS "LibraryEntity__LibraryEntity_owner_shouldChangePassword",
"LibraryEntity__LibraryEntity_owner"."createdAt" AS "LibraryEntity__LibraryEntity_owner_createdAt",
"LibraryEntity__LibraryEntity_owner"."deletedAt" AS "LibraryEntity__LibraryEntity_owner_deletedAt",
"LibraryEntity__LibraryEntity_owner"."status" AS "LibraryEntity__LibraryEntity_owner_status",
"LibraryEntity__LibraryEntity_owner"."updatedAt" AS "LibraryEntity__LibraryEntity_owner_updatedAt",
"LibraryEntity__LibraryEntity_owner"."quotaSizeInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaSizeInBytes",
"LibraryEntity__LibraryEntity_owner"."quotaUsageInBytes" AS "LibraryEntity__LibraryEntity_owner_quotaUsageInBytes"
FROM
"libraries" "LibraryEntity"
LEFT JOIN "users" "LibraryEntity__LibraryEntity_owner" ON "LibraryEntity__LibraryEntity_owner"."id" = "LibraryEntity"."ownerId"
AND (
"LibraryEntity__LibraryEntity_owner"."deletedAt" IS NULL
)
WHERE
((("LibraryEntity"."ownerId" = $1)))
AND ("LibraryEntity"."deletedAt" IS NULL)
ORDER BY
"LibraryEntity"."createdAt" ASC
-- LibraryRepository.getAll
SELECT
"LibraryEntity"."id" AS "LibraryEntity_id",

View file

@ -1,9 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { AssetSearchDto, CheckExistingAssetsDto } from 'src/dtos/asset-v1.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetCheck, AssetOwnerCheck, IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
import { OptionalBetween } from 'src/utils/database';
import { AssetCheck, IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
import { In } from 'typeorm/find-options/operator/In.js';
import { Repository } from 'typeorm/repository/Repository.js';
@ -11,36 +9,6 @@ import { Repository } from 'typeorm/repository/Repository.js';
export class AssetRepositoryV1 implements IAssetRepositoryV1 {
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
/**
* Retrieves all assets by user ID.
*
* @param ownerId - The ID of the owner.
* @param dto - The AssetSearchDto object containing search criteria.
* @returns A Promise that resolves to an array of AssetEntity objects.
*/
getAllByUserId(ownerId: string, dto: AssetSearchDto): Promise<AssetEntity[]> {
return this.assetRepository.find({
where: {
ownerId,
isVisible: true,
isFavorite: dto.isFavorite,
isArchived: dto.isArchived,
updatedAt: OptionalBetween(dto.updatedAfter, dto.updatedBefore),
},
relations: {
exifInfo: true,
tags: true,
stack: { assets: true },
},
skip: dto.skip || 0,
take: dto.take,
order: {
fileCreatedAt: 'DESC',
},
withDeleted: true,
});
}
get(id: string): Promise<AssetEntity | null> {
return this.assetRepository.findOne({
where: { id },
@ -73,30 +41,4 @@ export class AssetRepositoryV1 implements IAssetRepositoryV1 {
withDeleted: true,
});
}
async getExistingAssets(ownerId: string, checkDuplicateAssetDto: CheckExistingAssetsDto): Promise<string[]> {
const assets = await this.assetRepository.find({
select: { deviceAssetId: true },
where: {
deviceAssetId: In(checkDuplicateAssetDto.deviceAssetIds),
deviceId: checkDuplicateAssetDto.deviceId,
ownerId,
},
withDeleted: true,
});
return assets.map((asset) => asset.deviceAssetId);
}
getByOriginalPath(originalPath: string): Promise<AssetOwnerCheck | null> {
return this.assetRepository.findOne({
select: {
id: true,
ownerId: true,
checksum: true,
},
where: {
originalPath,
},
});
}
}

View file

@ -157,6 +157,18 @@ export class AssetRepository implements IAssetRepository {
});
}
getByDeviceIds(ownerId: string, deviceId: string, deviceAssetIds: string[]): Promise<AssetEntity[]> {
return this.repository.find({
select: { deviceAssetId: true },
where: {
deviceAssetId: In(deviceAssetIds),
deviceId,
ownerId,
},
withDeleted: true,
});
}
getByUserId(
pagination: PaginationOptions,
userId: string,
@ -300,6 +312,21 @@ export class AssetRepository implements IAssetRepository {
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
getByChecksums(ownerId: string, checksums: Buffer[]): Promise<AssetEntity[]> {
return this.repository.find({
select: {
id: true,
checksum: true,
},
where: {
ownerId,
checksum: In(checksums),
},
withDeleted: true,
});
}
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
async getUploadAssetIdByChecksum(ownerId: string, checksum: Buffer): Promise<string | undefined> {
const asset = await this.repository.findOne({

View file

@ -39,21 +39,6 @@ export class LibraryRepository implements ILibraryRepository {
return this.repository.countBy({ ownerId });
}
@GenerateSql({ params: [DummyValue.UUID] })
getAllByUserId(ownerId: string): Promise<LibraryEntity[]> {
return this.repository.find({
where: {
ownerId,
},
relations: {
owner: true,
},
order: {
createdAt: 'ASC',
},
});
}
@GenerateSql({ params: [] })
getAll(withDeleted = false): Promise<LibraryEntity[]> {
return this.repository.find({

View file

@ -1,5 +1,5 @@
import { Stats } from 'node:fs';
import { AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto';
import { AssetMediaStatusEnum, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
import { AssetMediaReplaceDto } from 'src/dtos/asset-media.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
@ -277,4 +277,31 @@ describe('AssetMediaService', () => {
expect(userMock.updateUsage).not.toHaveBeenCalled();
});
});
describe('bulkUploadCheck', () => {
it('should accept hex and base64 checksums', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
assetMock.getByChecksums.mockResolvedValue([
{ id: 'asset-1', checksum: file1 } as AssetEntity,
{ id: 'asset-2', checksum: file2 } as AssetEntity,
]);
await expect(
sut.bulkUploadCheck(authStub.admin, {
assets: [
{ id: '1', checksum: file1.toString('hex') },
{ id: '2', checksum: file2.toString('base64') },
],
}),
).resolves.toEqual({
results: [
{ id: '1', assetId: 'asset-1', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE },
{ id: '2', assetId: 'asset-2', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE },
],
});
expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
});
});
});

View file

@ -1,7 +1,19 @@
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AssetMediaResponseDto, AssetMediaStatusEnum } from 'src/dtos/asset-media-response.dto';
import { AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
import {
AssetBulkUploadCheckResponseDto,
AssetMediaResponseDto,
AssetMediaStatusEnum,
AssetRejectReason,
AssetUploadAction,
CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-media-response.dto';
import {
AssetBulkUploadCheckDto,
AssetMediaReplaceDto,
CheckExistingAssetsDto,
UploadFieldName,
} from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
@ -12,8 +24,8 @@ import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
import { QueryFailedError } from 'typeorm';
export interface UploadRequest {
auth: AuthDto | null;
fieldName: UploadFieldName;
@ -174,4 +186,49 @@ export class AssetMediaService {
throw new BadRequestException('Quota has been exceeded!');
}
}
async checkExistingAssets(
auth: AuthDto,
checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
const assets = await this.assetRepository.getByDeviceIds(
auth.user.id,
checkExistingAssetsDto.deviceId,
checkExistingAssetsDto.deviceAssetIds,
);
return {
existingIds: assets.map((asset) => asset.id),
};
}
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
const results = await this.assetRepository.getByChecksums(auth.user.id, checksums);
const checksumMap: Record<string, string> = {};
for (const { id, checksum } of results) {
checksumMap[checksum.toString('hex')] = id;
}
return {
results: dto.assets.map(({ id, checksum }) => {
const duplicate = checksumMap[fromChecksum(checksum).toString('hex')];
if (duplicate) {
return {
id,
assetId: duplicate,
action: AssetUploadAction.REJECT,
reason: AssetRejectReason.DUPLICATE,
};
}
// TODO mime-check
return {
id,
action: AssetUploadAction.ACCEPT,
};
}),
};
}
}

View file

@ -1,4 +1,3 @@
import { AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-v1-response.dto';
import { CreateAssetDto } from 'src/dtos/asset-v1.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
import { ExifEntity } from 'src/entities/exif.entity';
@ -74,10 +73,7 @@ describe('AssetService', () => {
beforeEach(() => {
assetRepositoryMockV1 = {
get: vitest.fn(),
getAllByUserId: vitest.fn(),
getAssetsByChecksums: vitest.fn(),
getExistingAssets: vitest.fn(),
getByOriginalPath: vitest.fn(),
};
accessMock = newAccessRepositoryMock();
@ -194,32 +190,4 @@ describe('AssetService', () => {
);
});
});
describe('bulkUploadCheck', () => {
it('should accept hex and base64 checksums', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
assetRepositoryMockV1.getAssetsByChecksums.mockResolvedValue([
{ id: 'asset-1', checksum: file1 },
{ id: 'asset-2', checksum: file2 },
]);
await expect(
sut.bulkUploadCheck(authStub.admin, {
assets: [
{ id: '1', checksum: file1.toString('hex') },
{ id: '2', checksum: file2.toString('base64') },
],
}),
).resolves.toEqual({
results: [
{ id: '1', assetId: 'asset-1', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE },
{ id: '2', assetId: 'asset-2', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE },
],
});
expect(assetRepositoryMockV1.getAssetsByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
});
});
});

View file

@ -6,23 +6,8 @@ import {
NotFoundException,
} from '@nestjs/common';
import { AccessCore, Permission } from 'src/cores/access.core';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import {
AssetBulkUploadCheckResponseDto,
AssetFileUploadResponseDto,
AssetRejectReason,
AssetUploadAction,
CheckExistingAssetsResponseDto,
} from 'src/dtos/asset-v1-response.dto';
import {
AssetBulkUploadCheckDto,
AssetSearchDto,
CheckExistingAssetsDto,
CreateAssetDto,
GetAssetThumbnailDto,
GetAssetThumbnailFormatEnum,
ServeFileDto,
} from 'src/dtos/asset-v1.dto';
import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto';
import { CreateAssetDto, GetAssetThumbnailDto, GetAssetThumbnailFormatEnum, ServeFileDto } from 'src/dtos/asset-v1.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
import { IAccessRepository } from 'src/interfaces/access.interface';
@ -36,7 +21,6 @@ import { IUserRepository } from 'src/interfaces/user.interface';
import { UploadFile } from 'src/services/asset-media.service';
import { CacheControl, ImmichFileResponse, getLivePhotoMotionFilename } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { fromChecksum } from 'src/utils/request';
import { QueryFailedError } from 'typeorm';
@Injectable()
@ -112,13 +96,6 @@ export class AssetServiceV1 {
}
}
public async getAllAssets(auth: AuthDto, dto: AssetSearchDto): Promise<AssetResponseDto[]> {
const userId = dto.userId || auth.user.id;
await this.access.requirePermission(auth, Permission.TIMELINE_READ, userId);
const assets = await this.assetRepositoryV1.getAllByUserId(userId, dto);
return assets.map((asset) => mapAsset(asset, { withStack: true, auth }));
}
async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise<ImmichFileResponse> {
await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId);
@ -159,46 +136,6 @@ export class AssetServiceV1 {
});
}
async checkExistingAssets(
auth: AuthDto,
checkExistingAssetsDto: CheckExistingAssetsDto,
): Promise<CheckExistingAssetsResponseDto> {
return {
existingIds: await this.assetRepositoryV1.getExistingAssets(auth.user.id, checkExistingAssetsDto),
};
}
async bulkUploadCheck(auth: AuthDto, dto: AssetBulkUploadCheckDto): Promise<AssetBulkUploadCheckResponseDto> {
const checksums: Buffer[] = dto.assets.map((asset) => fromChecksum(asset.checksum));
const results = await this.assetRepositoryV1.getAssetsByChecksums(auth.user.id, checksums);
const checksumMap: Record<string, string> = {};
for (const { id, checksum } of results) {
checksumMap[checksum.toString('hex')] = id;
}
return {
results: dto.assets.map(({ id, checksum }) => {
const duplicate = checksumMap[fromChecksum(checksum).toString('hex')];
if (duplicate) {
return {
id,
assetId: duplicate,
action: AssetUploadAction.REJECT,
reason: AssetRejectReason.DUPLICATE,
};
}
// TODO mime-check
return {
id,
action: AssetUploadAction.ACCEPT,
};
}),
};
}
private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {
switch (format) {
case GetAssetThumbnailFormatEnum.WEBP: {