mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
refactor: asset media endpoints (#9831)
* refactor: asset media endpoints * refactor: mobile upload livePhoto as separate request * refactor: change mobile backup flow to use new asset upload endpoints * chore: format and analyze dart code * feat: mark motion as hidden when linked * feat: upload video portion of live photo before image portion * fix: incorrect assetApi calls in mobile code * fix: download asset --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
parent
66fced40e7
commit
69d2fcb43e
91 changed files with 1932 additions and 2456 deletions
|
|
@ -1,37 +1,44 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Next,
|
||||
Param,
|
||||
ParseFilePipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { Response } from 'express';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { EndpointLifecycle } from 'src/decorators';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetMediaStatusEnum,
|
||||
AssetMediaStatus,
|
||||
CheckExistingAssetsResponseDto,
|
||||
} from 'src/dtos/asset-media-response.dto';
|
||||
import {
|
||||
AssetBulkUploadCheckDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
CheckExistingAssetsDto,
|
||||
UploadFieldName,
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AuthDto, ImmichHeader } from 'src/dtos/auth.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor, Route, UploadFiles, getFiles } from 'src/middleware/file-upload.interceptor';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Assets')
|
||||
|
|
@ -42,10 +49,48 @@ export class AssetMediaController {
|
|||
private service: AssetMediaService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiHeader({
|
||||
name: ImmichHeader.CHECKSUM,
|
||||
description: 'sha1 checksum that can be used for duplicate detection before the file is uploaded',
|
||||
required: false,
|
||||
})
|
||||
@ApiBody({ description: 'Asset Upload Information', type: AssetMediaCreateDto })
|
||||
@Authenticated({ sharedLink: true })
|
||||
async uploadAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
|
||||
@Body() dto: AssetMediaCreateDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
const { file, sidecarFile } = getFiles(files);
|
||||
const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile);
|
||||
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
|
||||
return responseDto;
|
||||
}
|
||||
|
||||
@Get(':id/original')
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async downloadAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.downloadOriginal(auth, id), this.logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the asset with new file, without changing its id
|
||||
*/
|
||||
@Put(':id/file')
|
||||
@Put(':id/original')
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@Authenticated({ sharedLink: true })
|
||||
|
|
@ -60,12 +105,37 @@ export class AssetMediaController {
|
|||
): Promise<AssetMediaResponseDto> {
|
||||
const { file } = getFiles(files);
|
||||
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
|
||||
if (responseDto.status === AssetMediaStatusEnum.DUPLICATE) {
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
return responseDto;
|
||||
}
|
||||
|
||||
@Get(':id/thumbnail')
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async viewAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: AssetMediaOptionsDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.viewThumbnail(auth, id, dto), this.logger);
|
||||
}
|
||||
|
||||
@Get(':id/video/playback')
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async playAssetVideo(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.playbackVideo(auth, id), this.logger);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if multiple assets exist on the server and returns all existing - used by background backup
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Next,
|
||||
Param,
|
||||
ParseFilePipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
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';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor, Route, UploadFiles, mapToUploadFile } from 'src/middleware/file-upload.interceptor';
|
||||
import { AssetServiceV1 } from 'src/services/asset-v1.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags('Assets')
|
||||
@Controller(Route.ASSET)
|
||||
export class AssetControllerV1 {
|
||||
constructor(
|
||||
private service: AssetServiceV1,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiHeader({
|
||||
name: ImmichHeader.CHECKSUM,
|
||||
description: 'sha1 checksum that can be used for duplicate detection before the file is uploaded',
|
||||
required: false,
|
||||
})
|
||||
@ApiBody({ description: 'Asset Upload Information', type: CreateAssetDto })
|
||||
@Authenticated({ sharedLink: true })
|
||||
async uploadFile(
|
||||
@Auth() auth: AuthDto,
|
||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
|
||||
@Body() dto: CreateAssetDto,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetFileUploadResponseDto> {
|
||||
const file = mapToUploadFile(files.assetData[0]);
|
||||
const _livePhotoFile = files.livePhotoData?.[0];
|
||||
const _sidecarFile = files.sidecarData?.[0];
|
||||
let livePhotoFile;
|
||||
if (_livePhotoFile) {
|
||||
livePhotoFile = mapToUploadFile(_livePhotoFile);
|
||||
}
|
||||
|
||||
let sidecarFile;
|
||||
if (_sidecarFile) {
|
||||
sidecarFile = mapToUploadFile(_sidecarFile);
|
||||
}
|
||||
|
||||
const responseDto = await this.service.uploadFile(auth, dto, file, livePhotoFile, sidecarFile);
|
||||
if (responseDto.duplicate) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
|
||||
return responseDto;
|
||||
}
|
||||
|
||||
@Get('/file/:id')
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async serveFile(
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: ServeFileDto,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.serveFile(auth, id, dto), this.logger);
|
||||
}
|
||||
|
||||
@Get('/thumbnail/:id')
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async getAssetThumbnail(
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@Query() dto: GetAssetThumbnailDto,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.serveThumbnail(auth, id, dto), this.logger);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +1,16 @@
|
|||
import { Body, Controller, HttpCode, HttpStatus, Inject, Next, Param, Post, Res, StreamableFile } from '@nestjs/common';
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post, StreamableFile } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
import { AssetIdsDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { DownloadInfoDto, DownloadResponseDto } from 'src/dtos/download.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { asStreamableFile, sendFile } from 'src/utils/file';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
import { asStreamableFile } from 'src/utils/file';
|
||||
|
||||
@ApiTags('Download')
|
||||
@Controller('download')
|
||||
export class DownloadController {
|
||||
constructor(
|
||||
private service: DownloadService,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {}
|
||||
constructor(private service: DownloadService) {}
|
||||
|
||||
@Post('info')
|
||||
@Authenticated({ sharedLink: true })
|
||||
|
|
@ -31,17 +25,4 @@ export class DownloadController {
|
|||
downloadArchive(@Auth() auth: AuthDto, @Body() dto: AssetIdsDto): Promise<StreamableFile> {
|
||||
return this.service.downloadArchive(auth, dto).then(asStreamableFile);
|
||||
}
|
||||
|
||||
@Post('asset/:id')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@FileResponse()
|
||||
@Authenticated({ sharedLink: true })
|
||||
async downloadFile(
|
||||
@Res() res: Response,
|
||||
@Next() next: NextFunction,
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
) {
|
||||
await sendFile(res, next, () => this.service.downloadFile(auth, id), this.logger);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { AlbumController } from 'src/controllers/album.controller';
|
|||
import { APIKeyController } from 'src/controllers/api-key.controller';
|
||||
import { AppController } from 'src/controllers/app.controller';
|
||||
import { AssetMediaController } from 'src/controllers/asset-media.controller';
|
||||
import { AssetControllerV1 } from 'src/controllers/asset-v1.controller';
|
||||
import { AssetController } from 'src/controllers/asset.controller';
|
||||
import { AuditController } from 'src/controllers/audit.controller';
|
||||
import { AuthController } from 'src/controllers/auth.controller';
|
||||
|
|
@ -37,7 +36,6 @@ export const controllers = [
|
|||
AlbumController,
|
||||
AppController,
|
||||
AssetController,
|
||||
AssetControllerV1,
|
||||
AssetMediaController,
|
||||
AuditController,
|
||||
AuthController,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export enum AssetMediaStatusEnum {
|
||||
export enum AssetMediaStatus {
|
||||
CREATED = 'created',
|
||||
REPLACED = 'replaced',
|
||||
DUPLICATE = 'duplicate',
|
||||
}
|
||||
export class AssetMediaResponseDto {
|
||||
@ApiProperty({ enum: AssetMediaStatusEnum, enumName: 'AssetMediaStatus' })
|
||||
status!: AssetMediaStatusEnum;
|
||||
@ApiProperty({ enum: AssetMediaStatus, enumName: 'AssetMediaStatus' })
|
||||
status!: AssetMediaStatus;
|
||||
id!: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,27 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { Optional, ValidateDate } from 'src/validation';
|
||||
import { ArrayNotEmpty, IsArray, IsEnum, IsNotEmpty, IsString, ValidateNested } from 'class-validator';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
|
||||
|
||||
export enum AssetMediaSize {
|
||||
PREVIEW = 'preview',
|
||||
THUMBNAIL = 'thumbnail',
|
||||
}
|
||||
|
||||
export class AssetMediaOptionsDto {
|
||||
@Optional()
|
||||
@IsEnum(AssetMediaSize)
|
||||
@ApiProperty({ enumName: 'AssetMediaSize', enum: AssetMediaSize })
|
||||
size?: AssetMediaSize;
|
||||
}
|
||||
|
||||
export enum UploadFieldName {
|
||||
ASSET_DATA = 'assetData',
|
||||
LIVE_PHOTO_DATA = 'livePhotoData',
|
||||
SIDECAR_DATA = 'sidecarData',
|
||||
PROFILE_DATA = 'file',
|
||||
}
|
||||
|
||||
export class AssetMediaReplaceDto {
|
||||
class AssetMediaBase {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceAssetId!: string;
|
||||
|
|
@ -35,6 +46,28 @@ export class AssetMediaReplaceDto {
|
|||
[UploadFieldName.ASSET_DATA]!: any;
|
||||
}
|
||||
|
||||
export class AssetMediaCreateDto extends AssetMediaBase {
|
||||
@ValidateBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isArchived?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isVisible?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isOffline?: boolean;
|
||||
|
||||
@ValidateUUID({ optional: true })
|
||||
livePhotoVideoId?: string;
|
||||
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||
[UploadFieldName.SIDECAR_DATA]?: any;
|
||||
}
|
||||
|
||||
export class AssetMediaReplaceDto extends AssetMediaBase {}
|
||||
|
||||
export class AssetBulkUploadCheckItem {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
export class AssetFileUploadResponseDto {
|
||||
id!: string;
|
||||
duplicate!: boolean;
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsString } from 'class-validator';
|
||||
import { UploadFieldName } from 'src/dtos/asset.dto';
|
||||
import { Optional, ValidateBoolean, ValidateDate } from 'src/validation';
|
||||
|
||||
export class CreateAssetDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceAssetId!: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
deviceId!: string;
|
||||
|
||||
@ValidateDate()
|
||||
fileCreatedAt!: Date;
|
||||
|
||||
@ValidateDate()
|
||||
fileModifiedAt!: Date;
|
||||
|
||||
@Optional()
|
||||
@IsString()
|
||||
duration?: string;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isFavorite?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isArchived?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isVisible?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isOffline?: boolean;
|
||||
|
||||
// The properties below are added to correctly generate the API docs
|
||||
// and client SDKs. Validation should be handled in the controller.
|
||||
@ApiProperty({ type: 'string', format: 'binary' })
|
||||
[UploadFieldName.ASSET_DATA]!: any;
|
||||
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||
[UploadFieldName.LIVE_PHOTO_DATA]?: any;
|
||||
|
||||
@ApiProperty({ type: 'string', format: 'binary', required: false })
|
||||
[UploadFieldName.SIDECAR_DATA]?: any;
|
||||
}
|
||||
|
||||
export enum GetAssetThumbnailFormatEnum {
|
||||
JPEG = 'JPEG',
|
||||
WEBP = 'WEBP',
|
||||
}
|
||||
|
||||
export class GetAssetThumbnailDto {
|
||||
@Optional()
|
||||
@IsEnum(GetAssetThumbnailFormatEnum)
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
enum: GetAssetThumbnailFormatEnum,
|
||||
default: GetAssetThumbnailFormatEnum.WEBP,
|
||||
required: false,
|
||||
enumName: 'ThumbnailFormat',
|
||||
})
|
||||
format: GetAssetThumbnailFormatEnum = GetAssetThumbnailFormatEnum.WEBP;
|
||||
}
|
||||
|
||||
export class ServeFileDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ApiProperty({ title: 'Is serve thumbnail (resize) file' })
|
||||
isThumb?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
@ApiProperty({ title: 'Is request made from web' })
|
||||
isWeb?: boolean;
|
||||
}
|
||||
|
|
@ -127,9 +127,3 @@ export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
|
|||
total: Object.values(stats).reduce((total, value) => total + value, 0),
|
||||
};
|
||||
};
|
||||
export enum UploadFieldName {
|
||||
ASSET_DATA = 'assetData',
|
||||
LIVE_PHOTO_DATA = 'livePhotoData',
|
||||
SIDECAR_DATA = 'sidecarData',
|
||||
PROFILE_DATA = 'file',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { UploadFieldName } from 'src/dtos/asset.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
|
||||
export class CreateProfileImageDto {
|
||||
@ApiProperty({ type: 'string', format: 'binary' })
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
|
||||
export interface AssetCheck {
|
||||
id: string;
|
||||
checksum: Buffer;
|
||||
}
|
||||
|
||||
export interface AssetOwnerCheck extends AssetCheck {
|
||||
ownerId: string;
|
||||
}
|
||||
|
||||
export interface IAssetRepositoryV1 {
|
||||
get(id: string): Promise<AssetEntity | null>;
|
||||
getAssetsByChecksums(userId: string, checksums: Buffer[]): Promise<AssetCheck[]>;
|
||||
}
|
||||
|
||||
export const IAssetRepositoryV1 = 'IAssetRepositoryV1';
|
||||
|
|
@ -153,7 +153,7 @@ export interface IAssetRepository {
|
|||
): Promise<AssetEntity[]>;
|
||||
getByIdsWithAllRelations(ids: string[]): Promise<AssetEntity[]>;
|
||||
getByDayOfYear(ownerIds: string[], monthDay: MonthDay): Promise<AssetEntity[]>;
|
||||
getByChecksum(libraryId: string | null, checksum: Buffer): Promise<AssetEntity | null>;
|
||||
getByChecksum(options: { ownerId: string; checksum: Buffer; libraryId?: string }): 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>;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto';
|
||||
import { AssetMediaResponseDto } from 'src/dtos/asset-media-response.dto';
|
||||
import { ImmichHeader } from 'src/dtos/auth.dto';
|
||||
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { fromMaybeArray } from 'src/utils/request';
|
||||
|
||||
@Injectable()
|
||||
export class AssetUploadInterceptor implements NestInterceptor {
|
||||
constructor(private service: AssetService) {}
|
||||
constructor(private service: AssetMediaService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>) {
|
||||
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const res = context.switchToHttp().getResponse<Response<AssetFileUploadResponseDto>>();
|
||||
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
|
||||
|
||||
const checksum = fromMaybeArray(req.headers[ImmichHeader.CHECKSUM]);
|
||||
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
|
||||
|
|
|
|||
|
|
@ -9,16 +9,14 @@ import { Observable } from 'rxjs';
|
|||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { UploadFile } from 'src/services/asset-media.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AssetMediaService, UploadFile } from 'src/services/asset-media.service';
|
||||
|
||||
export interface UploadFiles {
|
||||
assetData: ImmichFile[];
|
||||
livePhotoData?: ImmichFile[];
|
||||
sidecarData: ImmichFile[];
|
||||
}
|
||||
|
||||
export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoData' | 'sidecarData') {
|
||||
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
|
||||
const file = files[property]?.[0];
|
||||
return file ? mapToUploadFile(file) : file;
|
||||
}
|
||||
|
|
@ -26,13 +24,12 @@ export function getFile(files: UploadFiles, property: 'assetData' | 'livePhotoDa
|
|||
export function getFiles(files: UploadFiles) {
|
||||
return {
|
||||
file: getFile(files, 'assetData') as UploadFile,
|
||||
livePhotoFile: getFile(files, 'livePhotoData'),
|
||||
sidecarFile: getFile(files, 'sidecarData'),
|
||||
};
|
||||
}
|
||||
|
||||
export enum Route {
|
||||
ASSET = 'asset',
|
||||
ASSET = 'assets',
|
||||
USER = 'users',
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +84,7 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||
|
||||
constructor(
|
||||
private reflect: Reflector,
|
||||
private assetService: AssetService,
|
||||
private assetService: AssetMediaService,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.logger.setContext(FileUploadInterceptor.name);
|
||||
|
|
@ -109,7 +106,6 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||
userProfile: instance.single(UploadFieldName.PROFILE_DATA),
|
||||
assetUpload: instance.fields([
|
||||
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
||||
{ name: UploadFieldName.LIVE_PHOTO_DATA, maxCount: 1 },
|
||||
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
||||
]),
|
||||
};
|
||||
|
|
@ -172,8 +168,7 @@ export class FileUploadInterceptor implements NestInterceptor {
|
|||
|
||||
private isAssetUploadFile(file: Express.Multer.File) {
|
||||
switch (file.fieldname as UploadFieldName) {
|
||||
case UploadFieldName.ASSET_DATA:
|
||||
case UploadFieldName.LIVE_PHOTO_DATA: {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -474,8 +474,9 @@ FROM
|
|||
WHERE
|
||||
(
|
||||
(
|
||||
("AssetEntity"."libraryId" = $1)
|
||||
AND ("AssetEntity"."checksum" = $2)
|
||||
("AssetEntity"."ownerId" = $1)
|
||||
AND ("AssetEntity"."libraryId" = $2)
|
||||
AND ("AssetEntity"."checksum" = $3)
|
||||
)
|
||||
)
|
||||
AND ("AssetEntity"."deletedAt" IS NULL)
|
||||
|
|
|
|||
|
|
@ -1,44 +0,0 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class AssetRepositoryV1 implements IAssetRepositoryV1 {
|
||||
constructor(@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>) {}
|
||||
|
||||
get(id: string): Promise<AssetEntity | null> {
|
||||
return this.assetRepository.findOne({
|
||||
where: { id },
|
||||
relations: {
|
||||
faces: {
|
||||
person: true,
|
||||
},
|
||||
library: true,
|
||||
},
|
||||
withDeleted: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get assets by checksums on the database
|
||||
* @param ownerId
|
||||
* @param checksums
|
||||
*
|
||||
*/
|
||||
getAssetsByChecksums(ownerId: string, checksums: Buffer[]): Promise<AssetCheck[]> {
|
||||
return this.assetRepository.find({
|
||||
select: {
|
||||
id: true,
|
||||
checksum: true,
|
||||
},
|
||||
where: {
|
||||
ownerId,
|
||||
checksum: In(checksums),
|
||||
},
|
||||
withDeleted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -300,10 +300,19 @@ export class AssetRepository implements IAssetRepository {
|
|||
await this.repository.remove(asset);
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID, DummyValue.BUFFER] })
|
||||
getByChecksum(libraryId: string | null, checksum: Buffer): Promise<AssetEntity | null> {
|
||||
@GenerateSql({ params: [{ ownerId: DummyValue.UUID, libraryId: DummyValue.UUID, checksum: DummyValue.BUFFER }] })
|
||||
getByChecksum({
|
||||
ownerId,
|
||||
libraryId,
|
||||
checksum,
|
||||
}: {
|
||||
ownerId: string;
|
||||
checksum: Buffer;
|
||||
libraryId?: string;
|
||||
}): Promise<AssetEntity | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
ownerId,
|
||||
libraryId: libraryId || IsNull(),
|
||||
checksum,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { IAlbumUserRepository } from 'src/interfaces/album-user.interface';
|
|||
import { IAlbumRepository } from 'src/interfaces/album.interface';
|
||||
import { IKeyRepository } from 'src/interfaces/api-key.interface';
|
||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IAuditRepository } from 'src/interfaces/audit.interface';
|
||||
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
|
||||
|
|
@ -37,7 +36,6 @@ import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
|||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
import { AssetStackRepository } from 'src/repositories/asset-stack.repository';
|
||||
import { AssetRepositoryV1 } from 'src/repositories/asset-v1.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { AuditRepository } from 'src/repositories/audit.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
|
|
@ -71,7 +69,6 @@ export const repositories = [
|
|||
{ provide: IAlbumRepository, useClass: AlbumRepository },
|
||||
{ provide: IAlbumUserRepository, useClass: AlbumUserRepository },
|
||||
{ provide: IAssetRepository, useClass: AssetRepository },
|
||||
{ provide: IAssetRepositoryV1, useClass: AssetRepositoryV1 },
|
||||
{ provide: IAssetStackRepository, useClass: AssetStackRepository },
|
||||
{ provide: IAuditRepository, useClass: AuditRepository },
|
||||
{ provide: ICryptoRepository, useClass: CryptoRepository },
|
||||
|
|
|
|||
|
|
@ -1,16 +1,17 @@
|
|||
import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common';
|
||||
import { Stats } from 'node:fs';
|
||||
import { AssetMediaStatusEnum, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaReplaceDto } from 'src/dtos/asset-media.dto';
|
||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||
import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { ExifEntity } from 'src/entities/exif.entity';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IEventRepository } from 'src/interfaces/event.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AssetMediaService, UploadFile } from 'src/services/asset-media.service';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
|
|
@ -23,65 +24,169 @@ import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
|||
import { QueryFailedError } from 'typeorm';
|
||||
import { Mocked } from 'vitest';
|
||||
|
||||
const _getUpdateAssetDto = (): AssetMediaReplaceDto => {
|
||||
return Object.assign(new AssetMediaReplaceDto(), {
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
updatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
});
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
||||
const uploadFile = {
|
||||
nullAuth: {
|
||||
auth: null,
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: 'upload/admin/image.jpeg',
|
||||
originalName: 'image.jpeg',
|
||||
size: 1000,
|
||||
},
|
||||
},
|
||||
filename: (fieldName: UploadFieldName, filename: string) => {
|
||||
return {
|
||||
auth: authStub.admin,
|
||||
fieldName,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: `upload/admin/${filename}`,
|
||||
originalName: filename,
|
||||
size: 1000,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const _getAsset_1 = () => {
|
||||
const asset_1 = new AssetEntity();
|
||||
const validImages = [
|
||||
'.3fr',
|
||||
'.ari',
|
||||
'.arw',
|
||||
'.avif',
|
||||
'.cap',
|
||||
'.cin',
|
||||
'.cr2',
|
||||
'.cr3',
|
||||
'.crw',
|
||||
'.dcr',
|
||||
'.dng',
|
||||
'.erf',
|
||||
'.fff',
|
||||
'.gif',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.iiq',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.jxl',
|
||||
'.k25',
|
||||
'.kdc',
|
||||
'.mrw',
|
||||
'.nef',
|
||||
'.orf',
|
||||
'.ori',
|
||||
'.pef',
|
||||
'.png',
|
||||
'.psd',
|
||||
'.raf',
|
||||
'.raw',
|
||||
'.rwl',
|
||||
'.sr2',
|
||||
'.srf',
|
||||
'.srw',
|
||||
'.svg',
|
||||
'.tiff',
|
||||
'.webp',
|
||||
'.x3f',
|
||||
];
|
||||
|
||||
asset_1.id = 'id_1';
|
||||
asset_1.ownerId = 'user_id_1';
|
||||
asset_1.deviceAssetId = 'device_asset_id_1';
|
||||
asset_1.deviceId = 'device_id_1';
|
||||
asset_1.type = AssetType.VIDEO;
|
||||
asset_1.originalPath = 'fake_path/asset_1.jpeg';
|
||||
asset_1.previewPath = '';
|
||||
asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.isArchived = false;
|
||||
asset_1.thumbnailPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
asset_1.exifInfo = new ExifEntity();
|
||||
asset_1.exifInfo.latitude = 49.533_547;
|
||||
asset_1.exifInfo.longitude = 10.703_075;
|
||||
asset_1.livePhotoVideoId = null;
|
||||
asset_1.sidecarPath = null;
|
||||
return asset_1;
|
||||
};
|
||||
const _getExistingAsset = () => {
|
||||
return {
|
||||
..._getAsset_1(),
|
||||
duration: null,
|
||||
type: AssetType.IMAGE,
|
||||
checksum: Buffer.from('_getExistingAsset', 'utf8'),
|
||||
libraryId: 'libraryId',
|
||||
} as AssetEntity;
|
||||
};
|
||||
const _getExistingAssetWithSideCar = () => {
|
||||
return {
|
||||
..._getExistingAsset(),
|
||||
sidecarPath: 'sidecar-path',
|
||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
||||
} as AssetEntity;
|
||||
};
|
||||
const _getCopiedAsset = () => {
|
||||
return {
|
||||
id: 'copied-asset',
|
||||
originalPath: 'copied-path',
|
||||
} as AssetEntity;
|
||||
};
|
||||
const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
|
||||
|
||||
describe('AssetMediaService', () => {
|
||||
const uploadTests = [
|
||||
{
|
||||
label: 'asset images',
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
valid: validImages,
|
||||
invalid: ['.html', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'asset videos',
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
valid: validVideos,
|
||||
invalid: ['.html', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'sidecar',
|
||||
fieldName: UploadFieldName.SIDECAR_DATA,
|
||||
valid: ['.xmp'],
|
||||
invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'profile',
|
||||
fieldName: UploadFieldName.PROFILE_DATA,
|
||||
valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'],
|
||||
invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'],
|
||||
},
|
||||
];
|
||||
|
||||
const createDto = Object.freeze({
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
duration: '0:00:00.000000',
|
||||
}) as AssetMediaCreateDto;
|
||||
|
||||
const replaceDto = Object.freeze({
|
||||
deviceAssetId: 'deviceAssetId',
|
||||
deviceId: 'deviceId',
|
||||
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'),
|
||||
}) as AssetMediaReplaceDto;
|
||||
|
||||
const assetEntity = Object.freeze({
|
||||
id: 'id_1',
|
||||
ownerId: 'user_id_1',
|
||||
deviceAssetId: 'device_asset_id_1',
|
||||
deviceId: 'device_id_1',
|
||||
type: AssetType.VIDEO,
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
previewPath: '',
|
||||
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
updatedAt: new Date('2022-06-19T23:41:36.910Z'),
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
thumbnailPath: '',
|
||||
encodedVideoPath: '',
|
||||
duration: '0:00:00.000000',
|
||||
exifInfo: {
|
||||
latitude: 49.533_547,
|
||||
longitude: 10.703_075,
|
||||
},
|
||||
livePhotoVideoId: null,
|
||||
sidecarPath: null,
|
||||
}) as AssetEntity;
|
||||
|
||||
const existingAsset = Object.freeze({
|
||||
...assetEntity,
|
||||
duration: null,
|
||||
type: AssetType.IMAGE,
|
||||
checksum: Buffer.from('_getExistingAsset', 'utf8'),
|
||||
libraryId: 'libraryId',
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
}) as AssetEntity;
|
||||
|
||||
const sidecarAsset = Object.freeze({
|
||||
...existingAsset,
|
||||
sidecarPath: 'sidecar-path',
|
||||
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
|
||||
}) as AssetEntity;
|
||||
|
||||
const copiedAsset = Object.freeze({
|
||||
id: 'copied-asset',
|
||||
originalPath: 'copied-path',
|
||||
}) as AssetEntity;
|
||||
|
||||
describe(AssetMediaService.name, () => {
|
||||
let sut: AssetMediaService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
|
|
@ -103,171 +208,359 @@ describe('AssetMediaService', () => {
|
|||
sut = new AssetMediaService(accessMock, assetMock, jobMock, storageMock, userMock, eventMock, loggerMock);
|
||||
});
|
||||
|
||||
describe('getUploadAssetIdByChecksum', () => {
|
||||
it('should handle a non-existent asset', async () => {
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset', async () => {
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset by base64', async () => {
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUpload', () => {
|
||||
it('should require an authenticated user', () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
for (const { fieldName, valid, invalid } of uploadTests) {
|
||||
describe(fieldName, () => {
|
||||
for (const filetype of valid) {
|
||||
it(`should accept ${filetype}`, () => {
|
||||
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const filetype of invalid) {
|
||||
it(`should reject ${filetype}`, () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('should be sorted (valid)', () => {
|
||||
// TODO: use toSorted in NodeJS 20.
|
||||
expect(valid).toEqual([...valid].sort());
|
||||
});
|
||||
|
||||
it('should be sorted (invalid)', () => {
|
||||
// TODO: use toSorted in NodeJS 20.
|
||||
expect(invalid).toEqual([...invalid].sort());
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getUploadFilename', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should be the original extension for asset upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the xmp extension for sidecar upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
|
||||
'random-uuid.xmp',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the original extension for profile upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadFolder', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return profile for profile uploads', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/profile/admin_id',
|
||||
);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
|
||||
});
|
||||
|
||||
it('should return upload for everything else', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/upload/admin_id/ra/nd',
|
||||
);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadAsset', () => {
|
||||
it('should handle a file upload', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 42,
|
||||
};
|
||||
|
||||
assetMock.create.mockResolvedValue(assetEntity);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
|
||||
id: 'id_1',
|
||||
status: AssetMediaStatus.CREATED,
|
||||
});
|
||||
|
||||
expect(assetMock.create).toHaveBeenCalled();
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
file.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(createDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a duplicate', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 0,
|
||||
};
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.create.mockRejectedValue(error);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(assetEntity.id);
|
||||
|
||||
await expect(sut.uploadAsset(authStub.user1, createDto, file)).resolves.toEqual({
|
||||
id: 'id_1',
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
});
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: ['fake_path/asset_1.jpeg', undefined] },
|
||||
});
|
||||
expect(userMock.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a live photo', async () => {
|
||||
assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
authStub.user1,
|
||||
{ ...createDto, livePhotoVideoId: 'live-photo-motion-asset' },
|
||||
fileStub.livePhotoStill,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: AssetMediaStatus.CREATED,
|
||||
id: 'live-photo-still-asset',
|
||||
});
|
||||
|
||||
expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset');
|
||||
expect(assetMock.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should hide the linked motion asset', async () => {
|
||||
assetMock.getById.mockResolvedValueOnce({ ...assetStub.livePhotoMotionAsset, isVisible: true });
|
||||
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
|
||||
await expect(
|
||||
sut.uploadAsset(
|
||||
authStub.user1,
|
||||
{ ...createDto, livePhotoVideoId: 'live-photo-motion-asset' },
|
||||
fileStub.livePhotoStill,
|
||||
),
|
||||
).resolves.toEqual({
|
||||
status: AssetMediaStatus.CREATED,
|
||||
id: 'live-photo-still-asset',
|
||||
});
|
||||
|
||||
expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset');
|
||||
expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadOriginal', () => {
|
||||
it('should require the asset.download permission', async () => {
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not found', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(null);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
|
||||
expect(assetMock.getById).toHaveBeenCalledWith('asset-1');
|
||||
});
|
||||
|
||||
it('should download a file', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getById.mockResolvedValue(assetStub.image);
|
||||
|
||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1')).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/original/path.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceAsset', () => {
|
||||
const expectAssetUpdate = (
|
||||
existingAsset: AssetEntity,
|
||||
uploadFile: UploadFile,
|
||||
dto: AssetMediaReplaceDto,
|
||||
livePhotoVideo?: AssetEntity,
|
||||
sidecarPath?: UploadFile,
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
) => {
|
||||
expect(assetMock.update).toHaveBeenCalledWith({
|
||||
id: existingAsset.id,
|
||||
checksum: uploadFile.checksum,
|
||||
originalFileName: uploadFile.originalName,
|
||||
originalPath: uploadFile.originalPath,
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
type: mimeTypes.assetType(uploadFile.originalPath),
|
||||
duration: dto.duration || null,
|
||||
livePhotoVideo: livePhotoVideo ? { id: livePhotoVideo?.id } : null,
|
||||
sidecarPath: sidecarPath?.originalPath || null,
|
||||
});
|
||||
};
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
const expectAssetCreateCopy = (existingAsset: AssetEntity) => {
|
||||
expect(assetMock.create).toHaveBeenCalledWith({
|
||||
ownerId: existingAsset.ownerId,
|
||||
originalPath: existingAsset.originalPath,
|
||||
originalFileName: existingAsset.originalFileName,
|
||||
libraryId: existingAsset.libraryId,
|
||||
deviceAssetId: existingAsset.deviceAssetId,
|
||||
deviceId: existingAsset.deviceId,
|
||||
type: existingAsset.type,
|
||||
checksum: existingAsset.checksum,
|
||||
fileCreatedAt: existingAsset.fileCreatedAt,
|
||||
localDateTime: existingAsset.localDateTime,
|
||||
fileModifiedAt: existingAsset.fileModifiedAt,
|
||||
livePhotoVideoId: existingAsset.livePhotoVideoId || null,
|
||||
sidecarPath: existingAsset.sidecarPath || null,
|
||||
});
|
||||
};
|
||||
|
||||
it('should error when update photo does not exist', async () => {
|
||||
const dto = _getUpdateAssetDto();
|
||||
assetMock.getById.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, 'id', dto, fileStub.photo)).rejects.toThrow(
|
||||
await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow(
|
||||
'Not found or no asset.update access',
|
||||
);
|
||||
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update a photo with no sidecar to photo with no sidecar', async () => {
|
||||
const existingAsset = _getExistingAsset();
|
||||
const updatedFile = fileStub.photo;
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
const dto = _getUpdateAssetDto();
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.REPLACED,
|
||||
id: _getCopiedAsset().id,
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto);
|
||||
expectAssetCreateCopy(existingAsset);
|
||||
expect(assetMock.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingAsset.id,
|
||||
sidecarPath: null,
|
||||
originalFileName: 'photo1.jpeg',
|
||||
originalPath: 'fake_path/photo1.jpeg',
|
||||
}),
|
||||
);
|
||||
expect(assetMock.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sidecarPath: null,
|
||||
originalFileName: 'existing-filename.jpeg',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([copiedAsset.id]);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
it('should update a photo with sidecar to photo with sidecar', async () => {
|
||||
const existingAsset = _getExistingAssetWithSideCar();
|
||||
|
||||
it('should update a photo with sidecar to photo with sidecar', async () => {
|
||||
const updatedFile = fileStub.photo;
|
||||
const sidecarFile = fileStub.photoSidecar;
|
||||
const dto = _getUpdateAssetDto();
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile, sidecarFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.REPLACED,
|
||||
id: _getCopiedAsset().id,
|
||||
await expect(
|
||||
sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
|
||||
).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto, undefined, sidecarFile);
|
||||
expectAssetCreateCopy(existingAsset);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update a photo with a sidecar to photo with no sidecar', async () => {
|
||||
const existingAsset = _getExistingAssetWithSideCar();
|
||||
const updatedFile = fileStub.photo;
|
||||
|
||||
const dto = _getUpdateAssetDto();
|
||||
const updatedAsset = { ...existingAsset, ...updatedFile };
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
const updatedAsset = { ...sidecarAsset, ...updatedFile };
|
||||
assetMock.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
assetMock.getById.mockResolvedValueOnce(updatedAsset);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the copy call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.REPLACED,
|
||||
id: _getCopiedAsset().id,
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.REPLACED,
|
||||
id: 'copied-asset',
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto);
|
||||
expectAssetCreateCopy(existingAsset);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith([_getCopiedAsset().id]);
|
||||
expect(assetMock.softDeleteAll).toHaveBeenCalledWith(['copied-asset']);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
updatedFile.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
new Date(replaceDto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a photo with sidecar to duplicate photo ', async () => {
|
||||
const existingAsset = _getExistingAssetWithSideCar();
|
||||
const updatedFile = fileStub.photo;
|
||||
const dto = _getUpdateAssetDto();
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.update.mockRejectedValue(error);
|
||||
assetMock.getById.mockResolvedValueOnce(existingAsset);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(existingAsset.id);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
|
||||
assetMock.getById.mockResolvedValueOnce(sidecarAsset);
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
|
||||
// this is the original file size
|
||||
storageMock.stat.mockResolvedValue({ size: 0 } as Stats);
|
||||
// this is for the clone call
|
||||
assetMock.create.mockResolvedValue(_getCopiedAsset());
|
||||
assetMock.create.mockResolvedValue(copiedAsset);
|
||||
|
||||
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, dto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatusEnum.DUPLICATE,
|
||||
id: existingAsset.id,
|
||||
await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
|
||||
status: AssetMediaStatus.DUPLICATE,
|
||||
id: sidecarAsset.id,
|
||||
});
|
||||
|
||||
expectAssetUpdate(existingAsset, updatedFile, dto);
|
||||
expect(assetMock.create).not.toHaveBeenCalled();
|
||||
expect(assetMock.softDeleteAll).not.toHaveBeenCalled();
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
|
|
@ -277,6 +570,7 @@ describe('AssetMediaService', () => {
|
|||
expect(userMock.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkUploadCheck', () => {
|
||||
it('should accept hex and base64 checksums', async () => {
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
|
|
|||
|
|
@ -1,21 +1,33 @@
|
|||
import { BadRequestException, Inject, Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import {
|
||||
AssetBulkUploadCheckResponseDto,
|
||||
AssetMediaResponseDto,
|
||||
AssetMediaStatusEnum,
|
||||
AssetMediaStatus,
|
||||
AssetRejectReason,
|
||||
AssetUploadAction,
|
||||
CheckExistingAssetsResponseDto,
|
||||
} from 'src/dtos/asset-media-response.dto';
|
||||
import {
|
||||
AssetBulkUploadCheckDto,
|
||||
AssetMediaCreateDto,
|
||||
AssetMediaOptionsDto,
|
||||
AssetMediaReplaceDto,
|
||||
AssetMediaSize,
|
||||
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 { ASSET_CHECKSUM_CONSTRAINT, AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { IAccessRepository } from 'src/interfaces/access.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
||||
|
|
@ -23,6 +35,7 @@ import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
|||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
|
|
@ -57,7 +70,121 @@ export class AssetMediaService {
|
|||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
public async replaceAsset(
|
||||
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
|
||||
if (!checksum) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
|
||||
}
|
||||
|
||||
canUploadFile({ auth, fieldName, file }: UploadRequest): true {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const filename = file.originalName;
|
||||
|
||||
switch (fieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
if (mimeTypes.isAsset(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.SIDECAR_DATA: {
|
||||
if (mimeTypes.isSidecar(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.PROFILE_DATA: {
|
||||
if (mimeTypes.isProfile(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`Unsupported file type ${filename}`);
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const originalExtension = extname(file.originalName);
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: originalExtension,
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: originalExtension,
|
||||
};
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
}
|
||||
|
||||
getUploadFolder({ auth, fieldName, file }: UploadRequest): string {
|
||||
auth = this.access.requireUploadAccess(auth);
|
||||
|
||||
let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid);
|
||||
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
||||
folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, auth.user.id);
|
||||
}
|
||||
|
||||
this.storageRepository.mkdirSync(folder);
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
async uploadAsset(
|
||||
auth: AuthDto,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.access.requirePermission(
|
||||
auth,
|
||||
Permission.ASSET_UPLOAD,
|
||||
// do not need an id here, but the interface requires it
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
if (dto.livePhotoVideoId) {
|
||||
const motionAsset = await this.assetRepository.getById(dto.livePhotoVideoId);
|
||||
if (!motionAsset) {
|
||||
throw new BadRequestException('Live photo video not found');
|
||||
}
|
||||
if (motionAsset.type !== AssetType.VIDEO) {
|
||||
throw new BadRequestException('Live photo vide must be a video');
|
||||
}
|
||||
if (motionAsset.ownerId !== auth.user.id) {
|
||||
throw new BadRequestException('Live photo video does not belong to the user');
|
||||
}
|
||||
if (motionAsset.isVisible) {
|
||||
await this.assetRepository.update({ id: motionAsset.id, isVisible: false });
|
||||
}
|
||||
}
|
||||
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
} catch (error: any) {
|
||||
return this.handleUploadError(error, auth, file, sidecarFile);
|
||||
}
|
||||
}
|
||||
|
||||
async replaceAsset(
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetMediaReplaceDto,
|
||||
|
|
@ -66,27 +193,131 @@ export class AssetMediaService {
|
|||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_UPDATE, id);
|
||||
const existingAssetEntity = (await this.assetRepository.getById(id)) as AssetEntity;
|
||||
const asset = (await this.assetRepository.getById(id)) as AssetEntity;
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
|
||||
await this.replaceFileData(existingAssetEntity.id, dto, file, sidecarFile?.originalPath);
|
||||
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
|
||||
|
||||
// Next, create a backup copy of the existing record. The db record has already been updated above,
|
||||
// but the local variable holds the original file data paths.
|
||||
const copiedPhoto = await this.createCopy(existingAssetEntity);
|
||||
const copiedPhoto = await this.createCopy(asset);
|
||||
// and immediate trash it
|
||||
await this.assetRepository.softDeleteAll([copiedPhoto.id]);
|
||||
|
||||
this.eventRepository.clientSend(ClientEvent.ASSET_TRASH, auth.user.id, [copiedPhoto.id]);
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
|
||||
return { status: AssetMediaStatusEnum.REPLACED, id: copiedPhoto.id };
|
||||
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
|
||||
} catch (error: any) {
|
||||
return await this.handleUploadError(error, auth, file, sidecarFile);
|
||||
return this.handleUploadError(error, auth, file, sidecarFile);
|
||||
}
|
||||
}
|
||||
|
||||
async downloadOriginal(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id);
|
||||
|
||||
const asset = await this.findOrFail(id);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset does not exist');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: asset.originalPath,
|
||||
contentType: mimeTypes.lookup(asset.originalPath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
async viewThumbnail(auth: AuthDto, id: string, dto: AssetMediaOptionsDto): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_VIEW, id);
|
||||
|
||||
const asset = await this.findOrFail(id);
|
||||
const size = dto.size ?? AssetMediaSize.THUMBNAIL;
|
||||
|
||||
let filepath = asset.previewPath;
|
||||
if (size === AssetMediaSize.THUMBNAIL && asset.thumbnailPath) {
|
||||
filepath = asset.thumbnailPath;
|
||||
}
|
||||
|
||||
if (!filepath) {
|
||||
throw new NotFoundException('Asset media not found');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
async playbackVideo(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_VIEW, id);
|
||||
|
||||
const asset = await this.findOrFail(id);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset does not exist');
|
||||
}
|
||||
|
||||
if (asset.type !== AssetType.VIDEO) {
|
||||
throw new BadRequestException('Asset is not a video');
|
||||
}
|
||||
|
||||
const filepath = asset.encodedVideoPath || asset.originalPath;
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
|
|
@ -106,7 +337,7 @@ export class AssetMediaService {
|
|||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
}
|
||||
return { status: AssetMediaStatusEnum.DUPLICATE, id: duplicateId };
|
||||
return { status: AssetMediaStatus.DUPLICATE, id: duplicateId };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
|
|
@ -181,54 +412,59 @@ export class AssetMediaService {
|
|||
return created;
|
||||
}
|
||||
|
||||
private async create(
|
||||
ownerId: string,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetEntity> {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
isArchived: dto.isArchived ?? false,
|
||||
duration: dto.duration || null,
|
||||
isVisible: dto.isVisible ?? true,
|
||||
livePhotoVideoId: dto.livePhotoVideoId,
|
||||
originalFileName: file.originalName,
|
||||
sidecarPath: sidecarFile?.originalPath,
|
||||
isOffline: dto.isOffline ?? false,
|
||||
});
|
||||
|
||||
if (sidecarFile) {
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||
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;
|
||||
private async findOrFail(id: string): Promise<AssetEntity> {
|
||||
const asset = await this.assetRepository.getById(id);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
return asset;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,193 +0,0 @@
|
|||
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';
|
||||
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AssetServiceV1 } from 'src/services/asset-v1.service';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { fileStub } from 'test/fixtures/file.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
|
||||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newLibraryRepositoryMock } from 'test/repositories/library.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
|
||||
const _getCreateAssetDto = (): CreateAssetDto => {
|
||||
const createAssetDto = new CreateAssetDto();
|
||||
createAssetDto.deviceAssetId = 'deviceAssetId';
|
||||
createAssetDto.deviceId = 'deviceId';
|
||||
createAssetDto.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
createAssetDto.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
createAssetDto.isFavorite = false;
|
||||
createAssetDto.isArchived = false;
|
||||
createAssetDto.duration = '0:00:00.000000';
|
||||
|
||||
return createAssetDto;
|
||||
};
|
||||
|
||||
const _getAsset_1 = () => {
|
||||
const asset_1 = new AssetEntity();
|
||||
|
||||
asset_1.id = 'id_1';
|
||||
asset_1.ownerId = 'user_id_1';
|
||||
asset_1.deviceAssetId = 'device_asset_id_1';
|
||||
asset_1.deviceId = 'device_id_1';
|
||||
asset_1.type = AssetType.VIDEO;
|
||||
asset_1.originalPath = 'fake_path/asset_1.jpeg';
|
||||
asset_1.previewPath = '';
|
||||
asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z');
|
||||
asset_1.isFavorite = false;
|
||||
asset_1.isArchived = false;
|
||||
asset_1.thumbnailPath = '';
|
||||
asset_1.encodedVideoPath = '';
|
||||
asset_1.duration = '0:00:00.000000';
|
||||
asset_1.exifInfo = new ExifEntity();
|
||||
asset_1.exifInfo.latitude = 49.533_547;
|
||||
asset_1.exifInfo.longitude = 10.703_075;
|
||||
return asset_1;
|
||||
};
|
||||
|
||||
describe('AssetService', () => {
|
||||
let sut: AssetServiceV1;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetRepositoryMockV1: Mocked<IAssetRepositoryV1>;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let libraryMock: Mocked<ILibraryRepository>;
|
||||
let loggerMock: Mocked<ILoggerRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
|
||||
beforeEach(() => {
|
||||
assetRepositoryMockV1 = {
|
||||
get: vitest.fn(),
|
||||
getAssetsByChecksums: vitest.fn(),
|
||||
};
|
||||
|
||||
accessMock = newAccessRepositoryMock();
|
||||
assetMock = newAssetRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
libraryMock = newLibraryRepositoryMock();
|
||||
loggerMock = newLoggerRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
|
||||
sut = new AssetServiceV1(
|
||||
accessMock,
|
||||
assetRepositoryMockV1,
|
||||
assetMock,
|
||||
jobMock,
|
||||
libraryMock,
|
||||
storageMock,
|
||||
userMock,
|
||||
loggerMock,
|
||||
);
|
||||
|
||||
assetRepositoryMockV1.get.mockImplementation((assetId) =>
|
||||
Promise.resolve(
|
||||
[assetStub.livePhotoMotionAsset, assetStub.livePhotoMotionAsset].find((asset) => asset.id === assetId) ?? null,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should handle a file upload', async () => {
|
||||
const assetEntity = _getAsset_1();
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 42,
|
||||
};
|
||||
const dto = _getCreateAssetDto();
|
||||
|
||||
assetMock.create.mockResolvedValue(assetEntity);
|
||||
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
|
||||
|
||||
expect(assetMock.create).toHaveBeenCalled();
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
file.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle a duplicate', async () => {
|
||||
const file = {
|
||||
uuid: 'random-uuid',
|
||||
originalPath: 'fake_path/asset_1.jpeg',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('file hash', 'utf8'),
|
||||
originalName: 'asset_1.jpeg',
|
||||
size: 0,
|
||||
};
|
||||
const dto = _getCreateAssetDto();
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.create.mockRejectedValue(error);
|
||||
assetRepositoryMockV1.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
|
||||
|
||||
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] },
|
||||
});
|
||||
expect(userMock.updateUsage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle a live photo', async () => {
|
||||
const dto = _getCreateAssetDto();
|
||||
const error = new QueryFailedError('', [], new Error('unique key violation'));
|
||||
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;
|
||||
|
||||
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
|
||||
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
|
||||
|
||||
await expect(
|
||||
sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion),
|
||||
).resolves.toEqual({
|
||||
duplicate: false,
|
||||
id: 'live-photo-still-asset',
|
||||
});
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([
|
||||
[
|
||||
{
|
||||
name: JobName.METADATA_EXTRACTION,
|
||||
data: { id: assetStub.livePhotoMotionAsset.id, source: 'upload' },
|
||||
},
|
||||
],
|
||||
[{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }],
|
||||
]);
|
||||
expect(userMock.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, 111);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
fileStub.livePhotoStill.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
);
|
||||
expect(storageMock.utimes).toHaveBeenCalledWith(
|
||||
fileStub.livePhotoMotion.originalPath,
|
||||
expect.any(Date),
|
||||
new Date(dto.fileModifiedAt),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||
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';
|
||||
import { IAssetRepositoryV1 } from 'src/interfaces/asset-v1.interface';
|
||||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILibraryRepository } from 'src/interfaces/library.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
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 { QueryFailedError } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
/** @deprecated */
|
||||
export class AssetServiceV1 {
|
||||
private access: AccessCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAccessRepository) accessRepository: IAccessRepository,
|
||||
@Inject(IAssetRepositoryV1) private assetRepositoryV1: IAssetRepositoryV1,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ILibraryRepository) private libraryRepository: ILibraryRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(ILoggerRepository) private logger: ILoggerRepository,
|
||||
) {
|
||||
this.access = AccessCore.create(accessRepository);
|
||||
this.logger.setContext(AssetServiceV1.name);
|
||||
}
|
||||
|
||||
public async uploadFile(
|
||||
auth: AuthDto,
|
||||
dto: CreateAssetDto,
|
||||
file: UploadFile,
|
||||
livePhotoFile?: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
): Promise<AssetFileUploadResponseDto> {
|
||||
if (livePhotoFile) {
|
||||
livePhotoFile = {
|
||||
...livePhotoFile,
|
||||
originalName: getLivePhotoMotionFilename(file.originalName, livePhotoFile.originalName),
|
||||
};
|
||||
}
|
||||
|
||||
let livePhotoAsset: AssetEntity | null = null;
|
||||
|
||||
try {
|
||||
await this.access.requirePermission(
|
||||
auth,
|
||||
Permission.ASSET_UPLOAD,
|
||||
// do not need an id here, but the interface requires it
|
||||
auth.user.id,
|
||||
);
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
if (livePhotoFile) {
|
||||
const livePhotoDto = { ...dto, assetType: AssetType.VIDEO, isVisible: false };
|
||||
livePhotoAsset = await this.create(auth, livePhotoDto, livePhotoFile);
|
||||
}
|
||||
|
||||
const asset = await this.create(auth, dto, file, livePhotoAsset?.id, sidecarFile?.originalPath);
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, (livePhotoFile?.size || 0) + file.size);
|
||||
|
||||
return { id: asset.id, duplicate: false };
|
||||
} catch (error: any) {
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.DELETE_FILES,
|
||||
data: { files: [file.originalPath, livePhotoFile?.originalPath, sidecarFile?.originalPath] },
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (error instanceof QueryFailedError && (error as any).constraint === ASSET_CHECKSUM_CONSTRAINT) {
|
||||
const checksums = [file.checksum, livePhotoFile?.checksum].filter((checksum): checksum is Buffer => !!checksum);
|
||||
const [duplicate] = await this.assetRepositoryV1.getAssetsByChecksums(auth.user.id, checksums);
|
||||
return { id: duplicate.id, duplicate: true };
|
||||
}
|
||||
|
||||
this.logger.error(`Error uploading file ${error}`, error?.stack);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async serveThumbnail(auth: AuthDto, assetId: string, dto: GetAssetThumbnailDto): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId);
|
||||
|
||||
const asset = await this.assetRepositoryV1.get(assetId);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset not found');
|
||||
}
|
||||
|
||||
const filepath = this.getThumbnailPath(asset, dto.format);
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
public async serveFile(auth: AuthDto, assetId: string, dto: ServeFileDto): Promise<ImmichFileResponse> {
|
||||
// this is not quite right as sometimes this returns the original still
|
||||
await this.access.requirePermission(auth, Permission.ASSET_VIEW, assetId);
|
||||
|
||||
const asset = await this.assetRepository.getById(assetId);
|
||||
if (!asset) {
|
||||
throw new NotFoundException('Asset does not exist');
|
||||
}
|
||||
|
||||
const allowOriginalFile = !!(!auth.sharedLink || auth.sharedLink?.allowDownload);
|
||||
|
||||
const filepath =
|
||||
asset.type === AssetType.IMAGE
|
||||
? this.getServePath(asset, dto, allowOriginalFile)
|
||||
: asset.encodedVideoPath || asset.originalPath;
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: filepath,
|
||||
contentType: mimeTypes.lookup(filepath),
|
||||
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
|
||||
});
|
||||
}
|
||||
|
||||
private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {
|
||||
switch (format) {
|
||||
case GetAssetThumbnailFormatEnum.WEBP: {
|
||||
if (asset.thumbnailPath) {
|
||||
return asset.thumbnailPath;
|
||||
}
|
||||
this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`);
|
||||
}
|
||||
case GetAssetThumbnailFormatEnum.JPEG: {
|
||||
if (!asset.previewPath) {
|
||||
throw new NotFoundException(`No thumbnail found for asset ${asset.id}`);
|
||||
}
|
||||
return asset.previewPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getServePath(asset: AssetEntity, dto: ServeFileDto, allowOriginalFile: boolean): string {
|
||||
const mimeType = mimeTypes.lookup(asset.originalPath);
|
||||
|
||||
/**
|
||||
* Serve file viewer on the web
|
||||
*/
|
||||
if (dto.isWeb && mimeType != 'image/gif') {
|
||||
if (!asset.previewPath) {
|
||||
this.logger.error('Error serving IMAGE asset for web');
|
||||
throw new InternalServerErrorException(`Failed to serve image asset for web`, 'ServeFile');
|
||||
}
|
||||
|
||||
return asset.previewPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve thumbnail image for both web and mobile app
|
||||
*/
|
||||
if ((!dto.isThumb && allowOriginalFile) || (dto.isWeb && mimeType === 'image/gif')) {
|
||||
return asset.originalPath;
|
||||
}
|
||||
|
||||
if (asset.thumbnailPath && asset.thumbnailPath.length > 0) {
|
||||
return asset.thumbnailPath;
|
||||
}
|
||||
|
||||
if (!asset.previewPath) {
|
||||
throw new Error('previewPath not set');
|
||||
}
|
||||
|
||||
return asset.previewPath;
|
||||
}
|
||||
|
||||
private async create(
|
||||
auth: AuthDto,
|
||||
dto: CreateAssetDto,
|
||||
file: UploadFile,
|
||||
livePhotoAssetId?: string,
|
||||
sidecarPath?: string,
|
||||
): Promise<AssetEntity> {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId: auth.user.id,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
isArchived: dto.isArchived ?? false,
|
||||
duration: dto.duration || null,
|
||||
isVisible: dto.isVisible ?? true,
|
||||
livePhotoVideo: livePhotoAssetId === null ? null : ({ id: livePhotoAssetId } as AssetEntity),
|
||||
originalFileName: file.originalName,
|
||||
sidecarPath: sidecarPath || null,
|
||||
isOffline: dto.isOffline ?? false,
|
||||
});
|
||||
|
||||
if (sidecarPath) {
|
||||
await this.storageRepository.utimes(sidecarPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif({ assetId: asset.id, fileSizeInByte: file.size });
|
||||
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
|
||||
|
||||
return asset;
|
||||
}
|
||||
|
||||
private requireQuota(auth: AuthDto, size: number) {
|
||||
if (auth.user.quotaSizeInBytes && auth.user.quotaSizeInBytes < auth.user.quotaUsageInBytes + size) {
|
||||
throw new BadRequestException('Quota has been exceeded!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { mapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto';
|
||||
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
|
||||
import { AssetEntity, AssetType } from 'src/entities/asset.entity';
|
||||
import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface';
|
||||
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
|
|
@ -8,7 +8,6 @@ import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface';
|
|||
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
|
|
@ -24,13 +23,10 @@ import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'
|
|||
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
|
||||
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
|
||||
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
|
||||
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
|
||||
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
|
||||
import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
|
||||
import { Mocked, vitest } from 'vitest';
|
||||
|
||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||
|
||||
const stats: AssetStats = {
|
||||
[AssetType.IMAGE]: 10,
|
||||
[AssetType.VIDEO]: 23,
|
||||
|
|
@ -44,117 +40,11 @@ const statResponse: AssetStatsResponseDto = {
|
|||
total: 33,
|
||||
};
|
||||
|
||||
const uploadFile = {
|
||||
nullAuth: {
|
||||
auth: null,
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: 'upload/admin/image.jpeg',
|
||||
originalName: 'image.jpeg',
|
||||
size: 1000,
|
||||
},
|
||||
},
|
||||
filename: (fieldName: UploadFieldName, filename: string) => {
|
||||
return {
|
||||
auth: authStub.admin,
|
||||
fieldName,
|
||||
file: {
|
||||
uuid: 'random-uuid',
|
||||
mimeType: 'image/jpeg',
|
||||
checksum: Buffer.from('checksum', 'utf8'),
|
||||
originalPath: `upload/admin/${filename}`,
|
||||
originalName: filename,
|
||||
size: 1000,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
const validImages = [
|
||||
'.3fr',
|
||||
'.ari',
|
||||
'.arw',
|
||||
'.avif',
|
||||
'.cap',
|
||||
'.cin',
|
||||
'.cr2',
|
||||
'.cr3',
|
||||
'.crw',
|
||||
'.dcr',
|
||||
'.dng',
|
||||
'.erf',
|
||||
'.fff',
|
||||
'.gif',
|
||||
'.heic',
|
||||
'.heif',
|
||||
'.iiq',
|
||||
'.jpeg',
|
||||
'.jpg',
|
||||
'.jxl',
|
||||
'.k25',
|
||||
'.kdc',
|
||||
'.mrw',
|
||||
'.nef',
|
||||
'.orf',
|
||||
'.ori',
|
||||
'.pef',
|
||||
'.png',
|
||||
'.psd',
|
||||
'.raf',
|
||||
'.raw',
|
||||
'.rwl',
|
||||
'.sr2',
|
||||
'.srf',
|
||||
'.srw',
|
||||
'.svg',
|
||||
'.tiff',
|
||||
'.webp',
|
||||
'.x3f',
|
||||
];
|
||||
|
||||
const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
|
||||
|
||||
const uploadTests = [
|
||||
{
|
||||
label: 'asset images',
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
valid: validImages,
|
||||
invalid: ['.html', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'asset videos',
|
||||
fieldName: UploadFieldName.ASSET_DATA,
|
||||
valid: validVideos,
|
||||
invalid: ['.html', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'live photo',
|
||||
fieldName: UploadFieldName.LIVE_PHOTO_DATA,
|
||||
valid: validVideos,
|
||||
invalid: ['.html', '.jpeg', '.jpg', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'sidecar',
|
||||
fieldName: UploadFieldName.SIDECAR_DATA,
|
||||
valid: ['.xmp'],
|
||||
invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'],
|
||||
},
|
||||
{
|
||||
label: 'profile',
|
||||
fieldName: UploadFieldName.PROFILE_DATA,
|
||||
valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'],
|
||||
invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'],
|
||||
},
|
||||
];
|
||||
|
||||
describe(AssetService.name, () => {
|
||||
let sut: AssetService;
|
||||
let accessMock: IAccessRepositoryMock;
|
||||
let assetMock: Mocked<IAssetRepository>;
|
||||
let jobMock: Mocked<IJobRepository>;
|
||||
let storageMock: Mocked<IStorageRepository>;
|
||||
let userMock: Mocked<IUserRepository>;
|
||||
let eventMock: Mocked<IEventRepository>;
|
||||
let systemMock: Mocked<ISystemMetadataRepository>;
|
||||
|
|
@ -177,7 +67,6 @@ describe(AssetService.name, () => {
|
|||
assetMock = newAssetRepositoryMock();
|
||||
eventMock = newEventRepositoryMock();
|
||||
jobMock = newJobRepositoryMock();
|
||||
storageMock = newStorageRepositoryMock();
|
||||
userMock = newUserRepositoryMock();
|
||||
systemMock = newSystemMetadataRepositoryMock();
|
||||
partnerMock = newPartnerRepositoryMock();
|
||||
|
|
@ -189,7 +78,6 @@ describe(AssetService.name, () => {
|
|||
assetMock,
|
||||
jobMock,
|
||||
systemMock,
|
||||
storageMock,
|
||||
userMock,
|
||||
eventMock,
|
||||
partnerMock,
|
||||
|
|
@ -200,115 +88,6 @@ describe(AssetService.name, () => {
|
|||
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
|
||||
});
|
||||
|
||||
describe('getUploadAssetIdByChecksum', () => {
|
||||
it('should handle a non-existent asset', async () => {
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset', async () => {
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
duplicate: true,
|
||||
});
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
|
||||
it('should find an existing asset by base64', async () => {
|
||||
assetMock.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
||||
id: 'asset-id',
|
||||
duplicate: true,
|
||||
});
|
||||
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUpload', () => {
|
||||
it('should require an authenticated user', () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
for (const { fieldName, valid, invalid } of uploadTests) {
|
||||
describe(fieldName, () => {
|
||||
for (const filetype of valid) {
|
||||
it(`should accept ${filetype}`, () => {
|
||||
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
|
||||
});
|
||||
}
|
||||
|
||||
for (const filetype of invalid) {
|
||||
it(`should reject ${filetype}`, () => {
|
||||
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it('should be sorted (valid)', () => {
|
||||
// TODO: use toSorted in NodeJS 20.
|
||||
expect(valid).toEqual([...valid].sort());
|
||||
});
|
||||
|
||||
it('should be sorted (invalid)', () => {
|
||||
// TODO: use toSorted in NodeJS 20.
|
||||
expect(invalid).toEqual([...invalid].sort());
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getUploadFilename', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should be the original extension for asset upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the mov extension for live photo upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual(
|
||||
'random-uuid.mov',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the xmp extension for sidecar upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
|
||||
'random-uuid.xmp',
|
||||
);
|
||||
});
|
||||
|
||||
it('should be the original extension for profile upload', () => {
|
||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'random-uuid.jpg',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUploadFolder', () => {
|
||||
it('should require authentication', () => {
|
||||
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
||||
});
|
||||
|
||||
it('should return profile for profile uploads', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/profile/admin_id',
|
||||
);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
|
||||
});
|
||||
|
||||
it('should return upload for everything else', () => {
|
||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
||||
'upload/upload/admin_id/ra/nd',
|
||||
);
|
||||
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id/ra/nd');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMemoryLane', () => {
|
||||
beforeAll(() => {
|
||||
vitest.useFakeTimers();
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { BadRequestException, Inject } from '@nestjs/common';
|
||||
import _ from 'lodash';
|
||||
import { DateTime, Duration } from 'luxon';
|
||||
import { extname } from 'node:path';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { AccessCore, Permission } from 'src/cores/access.core';
|
||||
import { StorageCore, StorageFolder } from 'src/cores/storage.core';
|
||||
import { SystemConfigCore } from 'src/cores/system-config.core';
|
||||
import {
|
||||
AssetResponseDto,
|
||||
|
|
@ -12,7 +9,6 @@ import {
|
|||
SanitizedAssetResponseDto,
|
||||
mapAsset,
|
||||
} from 'src/dtos/asset-response.dto';
|
||||
import { AssetFileUploadResponseDto } from 'src/dtos/asset-v1-response.dto';
|
||||
import {
|
||||
AssetBulkDeleteDto,
|
||||
AssetBulkUpdateDto,
|
||||
|
|
@ -20,7 +16,6 @@ import {
|
|||
AssetJobsDto,
|
||||
AssetStatsDto,
|
||||
UpdateAssetDto,
|
||||
UploadFieldName,
|
||||
mapStats,
|
||||
} from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
|
|
@ -42,13 +37,9 @@ import {
|
|||
} from 'src/interfaces/job.interface';
|
||||
import { ILoggerRepository } from 'src/interfaces/logger.interface';
|
||||
import { IPartnerRepository } from 'src/interfaces/partner.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
|
||||
import { IUserRepository } from 'src/interfaces/user.interface';
|
||||
import { UploadRequest } from 'src/services/asset-media.service';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
import { fromChecksum } from 'src/utils/request';
|
||||
|
||||
export class AssetService {
|
||||
private access: AccessCore;
|
||||
|
|
@ -59,7 +50,6 @@ export class AssetService {
|
|||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
|
||||
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
|
||||
@Inject(IUserRepository) private userRepository: IUserRepository,
|
||||
@Inject(IEventRepository) private eventRepository: IEventRepository,
|
||||
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
|
||||
|
|
@ -71,86 +61,6 @@ export class AssetService {
|
|||
this.configCore = SystemConfigCore.create(systemMetadataRepository, this.logger);
|
||||
}
|
||||
|
||||
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetFileUploadResponseDto | undefined> {
|
||||
if (!checksum) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, fromChecksum(checksum));
|
||||
if (!assetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return { id: assetId, duplicate: true };
|
||||
}
|
||||
|
||||
canUploadFile({ auth, fieldName, file }: UploadRequest): true {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const filename = file.originalName;
|
||||
|
||||
switch (fieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
if (mimeTypes.isAsset(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.LIVE_PHOTO_DATA: {
|
||||
if (mimeTypes.isVideo(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.SIDECAR_DATA: {
|
||||
if (mimeTypes.isSidecar(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.PROFILE_DATA: {
|
||||
if (mimeTypes.isProfile(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`Unsupported file type ${filename}`);
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file }: UploadRequest): string {
|
||||
this.access.requireUploadAccess(auth);
|
||||
|
||||
const originalExtension = extname(file.originalName);
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: originalExtension,
|
||||
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: originalExtension,
|
||||
};
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
}
|
||||
|
||||
getUploadFolder({ auth, fieldName, file }: UploadRequest): string {
|
||||
auth = this.access.requireUploadAccess(auth);
|
||||
|
||||
let folder = StorageCore.getNestedFolder(StorageFolder.UPLOAD, auth.user.id, file.uuid);
|
||||
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
||||
folder = StorageCore.getFolderLocation(StorageFolder.PROFILE, auth.user.id);
|
||||
}
|
||||
|
||||
this.storageRepository.mkdirSync(folder);
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import { AssetEntity } from 'src/entities/asset.entity';
|
|||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IStorageRepository } from 'src/interfaces/storage.interface';
|
||||
import { DownloadService } from 'src/services/download.service';
|
||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { assetStub } from 'test/fixtures/asset.stub';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
|
||||
|
|
@ -41,46 +40,7 @@ describe(DownloadService.name, () => {
|
|||
sut = new DownloadService(accessMock, assetMock, storageMock);
|
||||
});
|
||||
|
||||
describe('downloadFile', () => {
|
||||
it('should require the asset.download permission', async () => {
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['asset-1']));
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is not found', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getByIds.mockResolvedValue([]);
|
||||
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
|
||||
});
|
||||
|
||||
it('should throw an error if the asset is offline', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.offline]);
|
||||
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(assetMock.getByIds).toHaveBeenCalledWith(['asset-1']);
|
||||
});
|
||||
|
||||
it('should download a file', async () => {
|
||||
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
||||
assetMock.getByIds.mockResolvedValue([assetStub.image]);
|
||||
|
||||
await expect(sut.downloadFile(authStub.admin, 'asset-1')).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/original/path.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.NONE,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('downloadArchive', () => {
|
||||
it('should download an archive', async () => {
|
||||
const archiveMock = {
|
||||
addFile: vitest.fn(),
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ import { IAccessRepository } from 'src/interfaces/access.interface';
|
|||
import { IAssetRepository } from 'src/interfaces/asset.interface';
|
||||
import { IStorageRepository, ImmichReadStream } from 'src/interfaces/storage.interface';
|
||||
import { HumanReadableSize } from 'src/utils/bytes';
|
||||
import { CacheControl, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { usePagination } from 'src/utils/pagination';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -25,25 +23,6 @@ export class DownloadService {
|
|||
this.access = AccessCore.create(accessRepository);
|
||||
}
|
||||
|
||||
async downloadFile(auth: AuthDto, id: string): Promise<ImmichFileResponse> {
|
||||
await this.access.requirePermission(auth, Permission.ASSET_DOWNLOAD, id);
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset) {
|
||||
throw new BadRequestException('Asset not found');
|
||||
}
|
||||
|
||||
if (asset.isOffline) {
|
||||
throw new BadRequestException('Asset is offline');
|
||||
}
|
||||
|
||||
return new ImmichFileResponse({
|
||||
path: asset.originalPath,
|
||||
contentType: mimeTypes.lookup(asset.originalPath),
|
||||
cacheControl: CacheControl.NONE,
|
||||
});
|
||||
}
|
||||
|
||||
async getDownloadInfo(auth: AuthDto, dto: DownloadInfoDto): Promise<DownloadResponseDto> {
|
||||
const targetSize = dto.archiveSize || HumanReadableSize.GiB * 4;
|
||||
const archives: DownloadArchiveInfo[] = [];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { AlbumService } from 'src/services/album.service';
|
|||
import { APIKeyService } from 'src/services/api-key.service';
|
||||
import { ApiService } from 'src/services/api.service';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { AssetServiceV1 } from 'src/services/asset-v1.service';
|
||||
import { AssetService } from 'src/services/asset.service';
|
||||
import { AuditService } from 'src/services/audit.service';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
|
|
@ -45,7 +44,6 @@ export const services = [
|
|||
ApiService,
|
||||
AssetMediaService,
|
||||
AssetService,
|
||||
AssetServiceV1,
|
||||
AuditService,
|
||||
AuthService,
|
||||
CliService,
|
||||
|
|
|
|||
|
|
@ -411,7 +411,11 @@ export class MetadataService {
|
|||
}
|
||||
const checksum = this.cryptoRepository.hashSha1(video);
|
||||
|
||||
let motionAsset = await this.assetRepository.getByChecksum(asset.libraryId ?? null, checksum);
|
||||
let motionAsset = await this.assetRepository.getByChecksum({
|
||||
ownerId: asset.ownerId,
|
||||
libraryId: asset.libraryId ?? undefined,
|
||||
checksum,
|
||||
});
|
||||
if (motionAsset) {
|
||||
this.logger.debug(
|
||||
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
|
||||
|
|
|
|||
|
|
@ -271,7 +271,7 @@ describe(SharedLinkService.name, () => {
|
|||
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
|
||||
description: '1 shared photos & videos',
|
||||
imageUrl:
|
||||
'/api/asset/thumbnail/asset-id?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0',
|
||||
'/api/assets/asset-id/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0',
|
||||
title: 'Public Share',
|
||||
});
|
||||
expect(shareMock.get).toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -191,7 +191,7 @@ export class SharedLinkService {
|
|||
title: sharedLink.album ? sharedLink.album.albumName : 'Public Share',
|
||||
description: sharedLink.description || `${assetCount} shared photos & videos`,
|
||||
imageUrl: assetId
|
||||
? `/api/asset/thumbnail/${assetId}?key=${sharedLink.key.toString('base64url')}`
|
||||
? `/api/assets/${assetId}/thumbnail?key=${sharedLink.key.toString('base64url')}`
|
||||
: '/feature-panel.png',
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue