Compare commits

...

1 Commits

Author SHA1 Message Date
Jason Rasmussen
7ebc110603 refactor: asset upload 2026-02-13 17:02:39 -05:00
17 changed files with 355 additions and 510 deletions

View File

@@ -14,11 +14,12 @@ import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { UserProfileUploadInterceptor } from 'src/middleware/user-profile-upload.interceptor';
import { repositories } from 'src/repositories';
import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
@@ -46,7 +47,12 @@ const commonMiddleware = [
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
];
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
const apiMiddleware = [
AssetUploadInterceptor,
UserProfileUploadInterceptor,
...commonMiddleware,
{ provide: APP_GUARD, useClass: AuthGuard },
];
const configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv();

View File

@@ -1,6 +1,7 @@
import {
Body,
Controller,
UploadedFiles as Files,
Get,
HttpCode,
HttpStatus,
@@ -12,7 +13,6 @@ import {
Query,
Req,
Res,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger';
@@ -35,18 +35,17 @@ import {
} from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum';
import { ApiTag, ImmichHeader, Permission } from 'src/enum';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor';
import { mapUploadedFile, UploadFiles, UploadRequest } from 'src/middleware/upload.interceptor';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadFiles } from 'src/types';
import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets)
@Controller(RouteKey.Asset)
@Controller('assets')
export class AssetMediaController {
constructor(
private logger: LoggingRepository,
@@ -55,7 +54,7 @@ export class AssetMediaController {
@Post()
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
@UseInterceptors(AssetUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiHeader({
name: ImmichHeader.Checksum,
@@ -80,12 +79,21 @@ export class AssetMediaController {
})
async uploadAsset(
@Auth() auth: AuthDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
@Files(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
files: UploadFiles,
@Body() dto: AssetMediaCreateDto,
@Req() req: UploadRequest,
@Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> {
const { file, sidecarFile } = getFiles(files);
const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile);
const file = files[UploadFieldName.ASSET_DATA][0];
const sidecarFile = files[UploadFieldName.SIDECAR_DATA]?.[0];
const responseDto = await this.service.uploadAsset(
auth,
dto,
mapUploadedFile(req, file),
sidecarFile ? mapUploadedFile(req, sidecarFile) : undefined,
);
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK);
@@ -113,7 +121,7 @@ export class AssetMediaController {
}
@Put(':id/original')
@UseInterceptors(FileUploadInterceptor)
@UseInterceptors(AssetUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiResponse({
status: 200,
@@ -129,13 +137,19 @@ export class AssetMediaController {
async replaceAsset(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
@Files(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
files: UploadFiles,
@Body() dto: AssetMediaReplaceDto,
@Req() req: UploadRequest,
@Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> {
const { file } = getFiles(files);
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
const responseDto = await this.service.replaceAsset(
auth,
id,
dto,
mapUploadedFile(req, files[UploadFieldName.ASSET_DATA][0]),
);
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK);
}

View File

@@ -22,13 +22,13 @@ import {
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
import { ApiTag, Permission, RouteKey } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AssetService } from 'src/services/asset.service';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets)
@Controller(RouteKey.Asset)
@Controller('assets')
export class AssetController {
constructor(private service: AssetService) {}

View File

@@ -1,4 +1,15 @@
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import {
Body,
Controller,
Delete,
UploadedFile as File,
Get,
Next,
Param,
Post,
Res,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express';
@@ -92,10 +103,7 @@ export class DatabaseBackupController {
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
})
@UseInterceptors(FileInterceptor('file'))
uploadDatabaseBackup(
@UploadedFile()
file: Express.Multer.File,
): Promise<void> {
uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
return this.service.uploadBackup(file);
}
}

View File

@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
UploadedFile as File,
Get,
HttpCode,
HttpStatus,
@@ -10,7 +11,6 @@ import {
Post,
Put,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
@@ -22,16 +22,17 @@ import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
import { ApiTag, Permission, RouteKey } from 'src/enum';
import { ApiTag, Permission } from 'src/enum';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { UploadedFile } from 'src/middleware/upload.interceptor';
import { UserProfileUploadInterceptor } from 'src/middleware/user-profile-upload.interceptor';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { UserService } from 'src/services/user.service';
import { sendFile } from 'src/utils/file';
import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Users)
@Controller(RouteKey.User)
@Controller('users')
export class UserController {
constructor(
private service: UserService,
@@ -177,7 +178,7 @@ export class UserController {
@Post('profile-image')
@Authenticated({ permission: Permission.UserProfileImageUpdate })
@UseInterceptors(FileUploadInterceptor)
@UseInterceptors(UserProfileUploadInterceptor)
@ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@Endpoint({
@@ -185,11 +186,8 @@ export class UserController {
description: 'Upload and set a new profile image for the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
})
createProfileImage(
@Auth() auth: AuthDto,
@UploadedFile() fileInfo: Express.Multer.File,
): Promise<CreateProfileImageResponseDto> {
return this.service.createProfileImage(auth, fileInfo);
createProfileImage(@Auth() auth: AuthDto, @File() file: UploadedFile): Promise<CreateProfileImageResponseDto> {
return this.service.createProfileImage(auth, file);
}
@Delete('profile-image')

View File

@@ -487,11 +487,6 @@ export enum MetadataKey {
TelemetryEnabled = 'telemetry_enabled',
}
export enum RouteKey {
Asset = 'assets',
User = 'users',
}
export enum CacheControl {
PrivateWithCache = 'private_with_cache',
PrivateWithoutCache = 'private_without_cache',

View File

@@ -2,13 +2,13 @@ import {
Body,
Controller,
Delete,
UploadedFile as File,
Get,
Next,
Param,
Post,
Req,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
@@ -94,10 +94,7 @@ export class MaintenanceWorkerController {
@Post('admin/database-backups/upload')
@MaintenanceRoute()
@UseInterceptors(FileInterceptor('file'))
uploadDatabaseBackup(
@UploadedFile()
file: Express.Multer.File,
): Promise<void> {
uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
return this.databaseBackupService.uploadBackup(file);
}

View File

@@ -1,27 +1,32 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Response } from 'express';
import { Injectable } from '@nestjs/common';
import multer from 'multer';
import { of } from 'rxjs';
import { AssetMediaResponseDto, AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { ImmichHeader } from 'src/enum';
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
import { UploadInterceptor } from 'src/middleware/upload.interceptor';
import { AssetMediaService } from 'src/services/asset-media.service';
import { fromMaybeArray } from 'src/utils/request';
@Injectable()
export class AssetUploadInterceptor implements NestInterceptor {
constructor(private service: AssetMediaService) {}
async intercept(context: ExecutionContext, next: CallHandler<any>) {
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]);
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
if (response) {
res.status(200);
return of({ status: AssetMediaStatus.DUPLICATE, id: response.id });
}
return next.handle();
export class AssetUploadInterceptor extends UploadInterceptor {
constructor(service: AssetMediaService) {
super({
onRequest: async (req, res) => {
const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]);
const response = await service.onBeforeUpload(req.user, checksum);
if (response) {
res.status(200);
return of(response);
}
},
configure: (instance: multer.Multer) =>
instance.fields([
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
]),
canUpload: (req, file) => service.canUpload(req.user, file),
upload: (req, file) => service.onUpload(req.user, file),
remove: (req, file) => service.onUploadRemove(req.user, file),
});
}
}

View File

@@ -1,173 +0,0 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { PATH_METADATA } from '@nestjs/common/constants';
import { Reflector } from '@nestjs/core';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { NextFunction, RequestHandler } from 'express';
import multer, { StorageEngine, diskStorage } from 'multer';
import { createHash, randomUUID } from 'node:crypto';
import { Observable } from 'rxjs';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { RouteKey } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service';
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
const file = files[property]?.[0];
return file ? mapToUploadFile(file) : file;
}
export function getFiles(files: UploadFiles) {
return {
file: getFile(files, 'assetData') as UploadFile,
sidecarFile: getFile(files, 'sidecarData'),
};
}
type DiskStorageCallback = (error: Error | null, result: string) => void;
type ImmichMulterFile = Express.Multer.File & { uuid: string };
interface Callback<T> {
(error: Error): void;
(error: null, result: T): void;
}
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
try {
return callback(null, target());
} catch (error: Error | any) {
return callback(error);
}
};
@Injectable()
export class FileUploadInterceptor implements NestInterceptor {
private handlers: {
userProfile: RequestHandler;
assetUpload: RequestHandler;
};
private defaultStorage: StorageEngine;
constructor(
private reflect: Reflector,
private assetService: AssetMediaService,
private logger: LoggingRepository,
) {
this.logger.setContext(FileUploadInterceptor.name);
this.defaultStorage = diskStorage({
filename: this.filename.bind(this),
destination: this.destination.bind(this),
});
const instance = multer({
fileFilter: this.fileFilter.bind(this),
storage: {
_handleFile: this.handleFile.bind(this),
_removeFile: this.removeFile.bind(this),
},
});
this.handlers = {
userProfile: instance.single(UploadFieldName.PROFILE_DATA),
assetUpload: instance.fields([
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
]),
};
}
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
const context_ = context.switchToHttp();
const route = this.reflect.get<string>(PATH_METADATA, context.getClass());
const handler: RequestHandler | null = this.getHandler(route as RouteKey);
if (handler) {
await new Promise<void>((resolve, reject) => {
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
const maybePromise = handler(context_.getRequest(), context_.getResponse(), next);
Promise.resolve(maybePromise).catch((error) => reject(error));
});
} else {
this.logger.warn(`Skipping invalid file upload route: ${route}`);
}
return next.handle();
}
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
}
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
callback as Callback<string>,
);
}
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
return callbackify(
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
callback as Callback<string>,
);
}
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
(file as ImmichMulterFile).uuid = randomUUID();
request.on('error', (error) => {
this.logger.warn('Request error while uploading file, cleaning up', error);
this.assetService.onUploadError(request, file).catch(this.logger.error);
});
if (!this.isAssetUploadFile(file)) {
this.defaultStorage._handleFile(request, file, callback);
return;
}
const hash = createHash('sha1');
file.stream.on('data', (chunk) => hash.update(chunk));
this.defaultStorage._handleFile(request, file, (error, info) => {
if (error) {
hash.destroy();
callback(error);
} else {
callback(null, { ...info, checksum: hash.digest() });
}
});
}
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
this.defaultStorage._removeFile(request, file, callback);
}
private isAssetUploadFile(file: Express.Multer.File) {
switch (file.fieldname as UploadFieldName) {
case UploadFieldName.ASSET_DATA: {
return true;
}
}
return false;
}
private getHandler(route: RouteKey) {
switch (route) {
case RouteKey.Asset: {
return this.handlers.assetUpload;
}
case RouteKey.User: {
return this.handlers.userProfile;
}
default: {
return null;
}
}
}
}

View File

@@ -0,0 +1,131 @@
import { CallHandler, ExecutionContext, NestInterceptor, UnauthorizedException } from '@nestjs/common';
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
import { NextFunction, RequestHandler, Response } from 'express';
import multer from 'multer';
import { Readable } from 'node:stream';
import { Observable } from 'rxjs';
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
import { v4 } from 'uuid';
type Callback<T> = {
(error: Error): void;
(error: null, result: T): void;
};
export type UploadFile = {
requestId: string;
fieldName: string;
originalName: string;
};
export type UploadingFile = UploadFile & {
stream: Readable;
};
export type UploadedFile = UploadFile & { metadata: UploadMetadata };
export type UploadMetadata = {
/** folder */
folder: string;
/** k filename */
filename: string;
/** full path */
path: string;
size: number;
checksum?: Buffer;
};
export type UploadFiles = {
assetData: Express.Multer.File[];
sidecarData: Express.Multer.File[];
};
export type UploadRequest = AuthenticatedRequest & {
requestId: string;
};
type OnRequest = (req: UploadRequest, res: Response) => Promise<Observable<any> | void>;
const mapUploadFile = (req: UploadRequest, file: Express.Multer.File): UploadFile => {
const originalName = req.body?.filename || Buffer.from(file.originalname, 'latin1').toString('utf8');
return {
requestId: req.requestId,
fieldName: file.fieldname,
originalName,
};
};
export const mapUploadedFile = (req: UploadRequest, file: Express.Multer.File): UploadedFile => {
return { ...mapUploadFile(req, file), metadata: (file as unknown as UploadedFile).metadata };
};
const handle = <T>(target: () => T | Promise<T>, callback: Callback<T>) => {
void Promise.resolve(true)
.then(() => target())
.then((result) => callback(null, result))
.catch((error) => callback(error));
};
export class UploadInterceptor implements NestInterceptor {
private handler: RequestHandler;
private onRequest: OnRequest;
constructor(
private options: {
/** pre-request hook */
onRequest?: OnRequest;
configure(instance: multer.Multer): RequestHandler;
canUpload(req: UploadRequest, file: UploadFile): boolean;
upload(req: UploadRequest, file: UploadingFile): Promise<UploadMetadata>;
remove(req: UploadRequest, file: UploadedFile): Promise<void>;
},
) {
const storage = { _handleFile: this.handleFile.bind(this), _removeFile: this.removeFile.bind(this) };
this.handler = options.configure(multer({ fileFilter: this.canUpload.bind(this), storage }));
this.onRequest = options.onRequest ?? (() => Promise.resolve());
}
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
const http = context.switchToHttp();
const req = http.getRequest<UploadRequest>();
const res = http.getResponse<Response>();
if (!req.user) {
throw new UnauthorizedException();
}
req.requestId = v4();
// hook to preempt the request before file upload
const response = await this.onRequest(req, res);
if (response) {
return response;
}
await new Promise<void>((resolve, reject) => {
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
const maybePromise = this.handler(req, res, next);
Promise.resolve(maybePromise).catch((error) => reject(error));
});
return next.handle();
}
private canUpload(req: UploadRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
return handle(() => this.options.canUpload(req, mapUploadFile(req, file)), callback);
}
private handleFile(req: UploadRequest, file: Express.Multer.File, callback: Callback<UploadMetadata>) {
return handle<any>(
() =>
this.options
.upload(req, { ...mapUploadFile(req, file), stream: file.stream })
.then((metadata) => ({ metadata })),
callback,
);
}
private removeFile(req: UploadRequest, file: Express.Multer.File, callback: Callback<void>) {
return handle(() => this.options.remove(req, mapUploadedFile(req, file)), callback);
}
}

View File

@@ -0,0 +1,17 @@
import { Injectable } from '@nestjs/common';
import multer from 'multer';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { UploadInterceptor } from 'src/middleware/upload.interceptor';
import { UserService } from 'src/services/user.service';
@Injectable()
export class UserProfileUploadInterceptor extends UploadInterceptor {
constructor(service: UserService) {
super({
configure: (instance: multer.Multer) => instance.single(UploadFieldName.PROFILE_DATA),
canUpload: (req, file) => service.canUpload(req.user, file),
upload: (req, file) => service.onUpload(req.user, file),
remove: (req, file) => service.onUploadRemove(req.user, file),
});
}
}

View File

@@ -1,9 +1,4 @@
import {
BadRequestException,
InternalServerErrorException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { Stats } from 'node:fs';
import { AssetFile } from 'src/database';
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
@@ -11,9 +6,8 @@ import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldN
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { UploadFile } from 'src/middleware/upload.interceptor';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadBody } from 'src/types';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { ImmichFileResponse } from 'src/utils/file';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
@@ -22,38 +16,17 @@ import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const uploadFile = {
nullAuth: {
auth: null,
body: {},
fieldName: UploadFieldName.ASSET_DATA,
file: {
uuid: 'random-uuid',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: '/data/library/admin/image.jpeg',
originalName: 'image.jpeg',
size: 1000,
},
},
filename: (fieldName: UploadFieldName, filename: string, body?: UploadBody) => {
return {
auth: authStub.admin,
body: body || {},
fieldName,
file: {
uuid: 'random-uuid',
mimeType: 'image/jpeg',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: `/data/admin/${filename}`,
originalName: filename,
size: 1000,
},
};
},
const create = (fieldName: UploadFieldName, originalName: string): UploadFile => {
return {
requestId: newUuid(),
fieldName,
originalName,
};
};
const validImages = [
@@ -208,17 +181,17 @@ describe(AssetMediaService.name, () => {
describe('getUploadAssetIdByChecksum', () => {
it('should return if checksum is undefined', async () => {
await expect(sut.getUploadAssetIdByChecksum(authStub.admin)).resolves.toBe(undefined);
await expect(sut.onBeforeUpload(authStub.admin)).resolves.toBe(undefined);
});
it('should handle a non-existent asset', async () => {
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
await expect(sut.onBeforeUpload(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
});
it('should find an existing asset', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
await expect(sut.onBeforeUpload(authStub.admin, file1.toString('hex'))).resolves.toEqual({
id: 'asset-id',
status: AssetMediaStatus.DUPLICATE,
});
@@ -227,7 +200,7 @@ describe(AssetMediaService.name, () => {
it('should find an existing asset by base64', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
await expect(sut.onBeforeUpload(authStub.admin, file1.toString('base64'))).resolves.toEqual({
id: 'asset-id',
status: AssetMediaStatus.DUPLICATE,
});
@@ -236,21 +209,17 @@ describe(AssetMediaService.name, () => {
});
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);
expect(sut.canUpload(AuthFactory.create(), create(fieldName, `asset${filetype}`))).toEqual(true);
});
}
for (const filetype of invalid) {
it(`should reject ${filetype}`, () => {
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
expect(() => sut.canUpload(AuthFactory.create(), create(fieldName, `asset${filetype}`))).toThrowError(
BadRequestException,
);
});
@@ -265,70 +234,22 @@ describe(AssetMediaService.name, () => {
});
});
}
it('should prefer filename from body over name from path', () => {
const pathFilename = 'invalid-file-name';
const body = { filename: 'video.mov' };
expect(() => sut.canUploadFile(uploadFile.filename(UploadFieldName.ASSET_DATA, pathFilename))).toThrowError(
BadRequestException,
);
expect(sut.canUploadFile(uploadFile.filename(UploadFieldName.ASSET_DATA, pathFilename, body))).toEqual(true);
});
});
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(
expect.stringContaining('/data/profile/admin_id'),
);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/profile/admin_id'));
});
it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
expect.stringContaining('/data/upload/admin_id/ra/nd'),
);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/upload/admin_id/ra/nd'));
});
});
describe('uploadAsset', () => {
it('should throw an error if the quota is exceeded', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
requestId: '1',
fieldName: 'assetData',
originalName: 'asset_1.jpeg',
size: 42,
metadata: {
uuid: 'random-uuid',
path: 'fake_path/asset_1.jpeg',
folder: 'fake_path',
filename: 'asset_1.jpeg',
checksum: Buffer.from('file hash', 'utf8'),
size: 42,
},
};
mocks.asset.create.mockResolvedValue(assetEntity);
@@ -342,9 +263,9 @@ describe(AssetMediaService.name, () => {
).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size);
expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.metadata.size);
expect(mocks.storage.utimes).not.toHaveBeenCalledWith(
file.originalPath,
file.metadata.path,
expect.any(Date),
new Date(createDto.fileModifiedAt),
);
@@ -1017,31 +938,4 @@ describe(AssetMediaService.name, () => {
expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
});
});
describe('onUploadError', () => {
it('should queue a job to delete the uploaded file', async () => {
const request = {
body: {},
user: authStub.user1,
} as AuthRequest;
const file = {
fieldname: UploadFieldName.ASSET_DATA,
originalname: 'image.jpg',
mimetype: 'image/jpeg',
buffer: Buffer.from(''),
size: 1000,
uuid: 'random-uuid',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: '/data/upload/user-id/ra/nd/random-uuid.jpg',
} as unknown as Express.Multer.File;
await sut.onUploadError(request, file);
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [expect.stringContaining('/data/upload/user-id/ra/nd/random-uuid.jpg')] },
});
});
});
});

View File

@@ -1,5 +1,7 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
import { extname } from 'node:path';
import { createHash } from 'node:crypto';
import { extname, join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database';
@@ -31,11 +33,10 @@ import {
Permission,
StorageFolder,
} from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { UploadedFile, UploadFile, UploadingFile, UploadMetadata } from 'src/middleware/upload.interceptor';
import { BaseService } from 'src/services/base.service';
import { UploadFile, UploadRequest } from 'src/types';
import { requireUploadAccess } from 'src/utils/access';
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
import { onBeforeLink } from 'src/utils/asset.util';
import { isAssetChecksumConstraint } from 'src/utils/database';
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
@@ -47,8 +48,8 @@ export interface AssetMediaRedirectResponse {
@Injectable()
export class AssetMediaService extends BaseService {
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
if (!checksum) {
async onBeforeUpload(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
if (!checksum || !auth) {
return;
}
@@ -60,78 +61,56 @@ export class AssetMediaService extends BaseService {
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
}
canUploadFile({ auth, fieldName, file, body }: UploadRequest): true {
canUpload(auth: AuthDto, file: UploadFile): true {
requireUploadAccess(auth);
const filename = body.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;
}
if (
(file.fieldName === UploadFieldName.ASSET_DATA && mimeTypes.isAsset(file.originalName)) ||
(file.fieldName === UploadFieldName.SIDECAR_DATA && mimeTypes.isSidecar(file.originalName))
) {
return true;
}
this.logger.error(`Unsupported file type ${filename}`);
throw new BadRequestException(`Unsupported file type ${filename}`);
this.logger.error(`Unsupported file type ${file.originalName}`);
throw new BadRequestException(`Unsupported file type ${file.originalName}`);
}
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
requireUploadAccess(auth);
async onUpload(auth: AuthDto, file: UploadingFile): Promise<UploadMetadata> {
const stream = file.stream;
let checksum: Buffer | undefined;
let size = 0;
const extension = extname(body.filename || file.originalName);
const hash = createHash('sha1');
const lookup = {
[UploadFieldName.ASSET_DATA]: extension,
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: extension,
};
stream
.on('data', (chunk: Buffer) => {
hash.update(chunk);
size += chunk.length;
})
.on('end', () => (checksum = hash.digest()))
.on('error', () => hash.destroy());
return sanitize(`${file.uuid}${lookup[fieldName]}`);
}
getUploadFolder({ auth, fieldName, file }: UploadRequest): string {
auth = 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);
}
const extension = file.fieldName === UploadFieldName.ASSET_DATA ? extname(file.originalName) : '.xmp';
const filename = sanitize(`${file.requestId}${extension}`);
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, filename);
const path = join(folder, filename);
this.storageRepository.mkdirSync(folder);
return folder;
await pipeline(stream, this.storageRepository.createWriteStream(path));
return { filename, folder, path, checksum, size };
}
async onUploadError(request: AuthRequest, file: Express.Multer.File) {
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
const uploadPath = `${uploadFolder}/${uploadFilename}`;
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
async onUploadRemove(auth: AuthDto, file: UploadedFile): Promise<void> {
await this.storageRepository.unlink(file.metadata.path);
}
async uploadAsset(
auth: AuthDto,
dto: AssetMediaCreateDto,
file: UploadFile,
sidecarFile?: UploadFile,
file: UploadedFile,
sidecarFile?: UploadedFile,
): Promise<AssetMediaResponseDto> {
try {
await this.requireAccess({
@@ -141,7 +120,7 @@ export class AssetMediaService extends BaseService {
ids: [auth.user.id],
});
this.requireQuota(auth, file.size);
this.requireQuota(auth, file.metadata.size);
if (dto.livePhotoVideoId) {
await onBeforeLink(
@@ -151,7 +130,7 @@ export class AssetMediaService extends BaseService {
}
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
await this.userRepository.updateUsage(auth.user.id, file.size);
await this.userRepository.updateUsage(auth.user.id, file.metadata.size);
return { id: asset.id, status: AssetMediaStatus.CREATED };
} catch (error: any) {
@@ -163,8 +142,8 @@ export class AssetMediaService extends BaseService {
auth: AuthDto,
id: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
sidecarFile?: UploadFile,
file: UploadedFile,
sidecarFile?: UploadedFile,
): Promise<AssetMediaResponseDto> {
try {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
@@ -174,9 +153,9 @@ export class AssetMediaService extends BaseService {
throw new Error('Asset not found');
}
this.requireQuota(auth, file.size);
this.requireQuota(auth, file.metadata.size);
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
await this.replaceFileData(asset.id, dto, file, sidecarFile?.metadata.path);
// 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.
@@ -185,7 +164,7 @@ export class AssetMediaService extends BaseService {
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id });
await this.userRepository.updateUsage(auth.user.id, file.size);
await this.userRepository.updateUsage(auth.user.id, file.metadata.size);
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
} catch (error: any) {
@@ -325,18 +304,18 @@ export class AssetMediaService extends BaseService {
private async handleUploadError(
error: any,
auth: AuthDto,
file: UploadFile,
sidecarFile?: UploadFile,
file: UploadedFile,
sidecarFile?: UploadedFile,
): Promise<AssetMediaResponseDto> {
// clean up files
await this.jobRepository.queue({
name: JobName.FileDelete,
data: { files: [file.originalPath, sidecarFile?.originalPath] },
data: { files: [file.metadata.path, sidecarFile?.metadata.path] },
});
// handle duplicates with a success response
if (isAssetChecksumConstraint(error)) {
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.metadata.checksum!);
if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException();
@@ -358,15 +337,15 @@ export class AssetMediaService extends BaseService {
private async replaceFileData(
assetId: string,
dto: AssetMediaReplaceDto,
file: UploadFile,
file: UploadedFile,
sidecarPath?: string,
): Promise<void> {
await this.assetRepository.update({
id: assetId,
checksum: file.checksum,
originalPath: file.originalPath,
type: mimeTypes.assetType(file.originalPath),
checksum: file.metadata.checksum,
originalPath: file.metadata.path,
type: mimeTypes.assetType(file.metadata.path),
originalFileName: file.originalName,
deviceAssetId: dto.deviceAssetId,
@@ -383,9 +362,9 @@ export class AssetMediaService extends BaseService {
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.storageRepository.utimes(file.metadata.path, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif(
{ assetId, fileSizeInByte: file.size },
{ assetId, fileSizeInByte: file.metadata.size },
{ lockedPropertiesBehavior: 'override' },
);
await this.jobRepository.queue({
@@ -424,13 +403,13 @@ export class AssetMediaService extends BaseService {
return created;
}
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadedFile, sidecarFile?: UploadedFile) {
const asset = await this.assetRepository.create({
ownerId,
libraryId: null,
checksum: file.checksum,
originalPath: file.originalPath,
checksum: file.metadata.checksum!,
originalPath: file.metadata.path,
deviceAssetId: dto.deviceAssetId,
deviceId: dto.deviceId,
@@ -439,7 +418,7 @@ export class AssetMediaService extends BaseService {
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
type: mimeTypes.assetType(file.originalPath),
type: mimeTypes.assetType(file.metadata.path),
isFavorite: dto.isFavorite,
duration: dto.duration || null,
visibility: dto.visibility ?? AssetVisibility.Timeline,
@@ -454,14 +433,14 @@ export class AssetMediaService extends BaseService {
if (sidecarFile) {
await this.assetRepository.upsertFile({
assetId: asset.id,
path: sidecarFile.originalPath,
path: sidecarFile.metadata.path,
type: AssetFileType.Sidecar,
});
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.storageRepository.utimes(sidecarFile.metadata.path, new Date(), new Date(dto.fileModifiedAt));
}
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
await this.storageRepository.utimes(file.metadata.path, new Date(), new Date(dto.fileModifiedAt));
await this.assetRepository.upsertExif(
{ assetId: asset.id, fileSizeInByte: file.size },
{ assetId: asset.id, fileSizeInByte: file.metadata.size },
{ lockedPropertiesBehavior: 'override' },
);

View File

@@ -1,6 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Updateable } from 'kysely';
import { DateTime } from 'luxon';
import { extname, join } from 'node:path';
import { pipeline } from 'node:stream/promises';
import sanitize from 'sanitize-filename';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators';
@@ -11,15 +14,41 @@ import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences }
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
import { UploadFile, UploadMetadata, UploadedFile, UploadingFile } from 'src/middleware/upload.interceptor';
import { UserFindOptions } from 'src/repositories/user.repository';
import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service';
import { JobOf, UserMetadataItem } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
@Injectable()
export class UserService extends BaseService {
canUpload(auth: AuthDto, file: UploadFile) {
return mimeTypes.isProfile(file.originalName);
}
async onUpload(auth: AuthDto, file: UploadingFile): Promise<UploadMetadata> {
const extension = extname(file.originalName);
const filename = sanitize(`${file.requestId}${extension}`);
const folder = StorageCore.getNestedFolder(StorageFolder.Profile, auth.user.id, filename);
const path = join(folder, filename);
this.storageRepository.mkdirSync(folder);
let size = 0;
file.stream.on('data', (chunk: Buffer) => (size += chunk.length));
await pipeline(file.stream, this.storageRepository.createWriteStream(path));
return { filename, folder, path, size };
}
async onUploadRemove(auth: AuthDto, file: UploadedFile) {
await this.storageRepository.unlink(file.metadata.path);
}
async search(auth: AuthDto): Promise<UserResponseDto[]> {
const config = await this.getConfig({ withCache: false });
@@ -90,11 +119,11 @@ export class UserService extends BaseService {
return mapUser(user);
}
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
async createProfileImage(auth: AuthDto, file: UploadedFile): Promise<CreateProfileImageResponseDto> {
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
const user = await this.userRepository.update(auth.user.id, {
profileImagePath: file.path,
profileImagePath: file.metadata.path,
profileChangedAt: new Date(),
});

View File

@@ -1,8 +1,6 @@
import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants';
import { Asset, AssetFile } from 'src/database';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import {
@@ -420,37 +418,6 @@ export interface VectorUpdateResult {
restartRequired: boolean;
}
export interface ImmichFile extends Express.Multer.File {
uuid: string;
/** sha1 hash of file */
checksum: Buffer;
}
export interface UploadFile {
uuid: string;
checksum: Buffer;
originalPath: string;
originalName: string;
size: number;
}
export interface UploadBody {
filename?: string;
[key: string]: unknown;
}
export type UploadRequest = {
auth: AuthDto | null;
fieldName: UploadFieldName;
file: UploadFile;
body: UploadBody;
};
export interface UploadFiles {
assetData: ImmichFile[];
sidecarData: ImmichFile[];
}
export interface IBulkAsset {
getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>;
addAssetIds: (id: string, assetIds: string[]) => Promise<void>;

View File

@@ -2,16 +2,14 @@ import { BadRequestException } from '@nestjs/common';
import { StorageCore } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ExifResponseDto } from 'src/dtos/exif.dto';
import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
import { IBulkAsset } from 'src/types';
import { checkAccess } from 'src/utils/access';
export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => {
@@ -186,25 +184,6 @@ export const onAfterUnlink = async (
await eventRepository.emit('AssetShow', { assetId: livePhotoVideoId, userId });
};
export function mapToUploadFile(file: ImmichFile): UploadFile {
return {
uuid: file.uuid,
checksum: file.checksum,
originalPath: file.path,
originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
size: file.size,
};
}
export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File): UploadRequest => {
return {
auth: request.user || null,
body: request.body,
fieldName: file.fieldname as UploadFieldName,
file: mapToUploadFile(file as ImmichFile),
};
};
const isFlipped = (orientation?: string | null) => {
const value = Number(orientation);
return value && [5, 6, 7, 8, -90, 90].includes(value);

View File

@@ -13,7 +13,6 @@ import postgres from 'postgres';
import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { AuthGuard } from 'src/middleware/auth.guard';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@@ -120,7 +119,7 @@ export const controllerSetup = async (controller: ClassConstructor<unknown>, pro
...providers,
],
})
.overrideInterceptor(FileUploadInterceptor)
.overrideInterceptor(AssetUploadInterceptor)
.useValue(memoryFileInterceptor)
.overrideInterceptor(AssetUploadInterceptor)
.useValue(noopInterceptor)