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
+8 -2
View File
@@ -14,11 +14,12 @@ import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository'; import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller'; import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service'; import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { AuthGuard } from 'src/middleware/auth.guard'; import { AuthGuard } from 'src/middleware/auth.guard';
import { ErrorInterceptor } from 'src/middleware/error.interceptor'; import { ErrorInterceptor } from 'src/middleware/error.interceptor';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter'; import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
import { LoggingInterceptor } from 'src/middleware/logging.interceptor'; import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
import { UserProfileUploadInterceptor } from 'src/middleware/user-profile-upload.interceptor';
import { repositories } from 'src/repositories'; import { repositories } from 'src/repositories';
import { AppRepository } from 'src/repositories/app.repository'; import { AppRepository } from 'src/repositories/app.repository';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
@@ -46,7 +47,12 @@ const commonMiddleware = [
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor }, { 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 configRepository = new ConfigRepository();
const { bull, cls, database, otel } = configRepository.getEnv(); const { bull, cls, database, otel } = configRepository.getEnv();
@@ -1,6 +1,7 @@
import { import {
Body, Body,
Controller, Controller,
UploadedFiles as Files,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
@@ -12,7 +13,6 @@ import {
Query, Query,
Req, Req,
Res, Res,
UploadedFiles,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger';
@@ -35,18 +35,17 @@ import {
} from 'src/dtos/asset-media.dto'; } from 'src/dtos/asset-media.dto';
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto'; import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
import { AuthDto } from 'src/dtos/auth.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 { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard'; 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 { LoggingRepository } from 'src/repositories/logging.repository';
import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadFiles } from 'src/types';
import { ImmichFileResponse, sendFile } from 'src/utils/file'; import { ImmichFileResponse, sendFile } from 'src/utils/file';
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation'; import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets) @ApiTags(ApiTag.Assets)
@Controller(RouteKey.Asset) @Controller('assets')
export class AssetMediaController { export class AssetMediaController {
constructor( constructor(
private logger: LoggingRepository, private logger: LoggingRepository,
@@ -55,7 +54,7 @@ export class AssetMediaController {
@Post() @Post()
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true }) @Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor) @UseInterceptors(AssetUploadInterceptor)
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiHeader({ @ApiHeader({
name: ImmichHeader.Checksum, name: ImmichHeader.Checksum,
@@ -80,12 +79,21 @@ export class AssetMediaController {
}) })
async uploadAsset( async uploadAsset(
@Auth() auth: AuthDto, @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, @Body() dto: AssetMediaCreateDto,
@Req() req: UploadRequest,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
const { file, sidecarFile } = getFiles(files); const file = files[UploadFieldName.ASSET_DATA][0];
const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile); 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) { if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK); res.status(HttpStatus.OK);
@@ -113,7 +121,7 @@ export class AssetMediaController {
} }
@Put(':id/original') @Put(':id/original')
@UseInterceptors(FileUploadInterceptor) @UseInterceptors(AssetUploadInterceptor)
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiResponse({ @ApiResponse({
status: 200, status: 200,
@@ -129,13 +137,19 @@ export class AssetMediaController {
async replaceAsset( async replaceAsset(
@Auth() auth: AuthDto, @Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto, @Param() { id }: UUIDParamDto,
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
@Files(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
files: UploadFiles, files: UploadFiles,
@Body() dto: AssetMediaReplaceDto, @Body() dto: AssetMediaReplaceDto,
@Req() req: UploadRequest,
@Res({ passthrough: true }) res: Response, @Res({ passthrough: true }) res: Response,
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
const { file } = getFiles(files); const responseDto = await this.service.replaceAsset(
const responseDto = await this.service.replaceAsset(auth, id, dto, file); auth,
id,
dto,
mapUploadedFile(req, files[UploadFieldName.ASSET_DATA][0]),
);
if (responseDto.status === AssetMediaStatus.DUPLICATE) { if (responseDto.status === AssetMediaStatus.DUPLICATE) {
res.status(HttpStatus.OK); res.status(HttpStatus.OK);
} }
+2 -2
View File
@@ -22,13 +22,13 @@ import {
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto'; import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
import { AssetOcrResponseDto } from 'src/dtos/ocr.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 { Auth, Authenticated } from 'src/middleware/auth.guard';
import { AssetService } from 'src/services/asset.service'; import { AssetService } from 'src/services/asset.service';
import { UUIDParamDto } from 'src/validation'; import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Assets) @ApiTags(ApiTag.Assets)
@Controller(RouteKey.Asset) @Controller('assets')
export class AssetController { export class AssetController {
constructor(private service: AssetService) {} constructor(private service: AssetService) {}
@@ -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 { FileInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { NextFunction, Response } from 'express'; import { NextFunction, Response } from 'express';
@@ -92,10 +103,7 @@ export class DatabaseBackupController {
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'), history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
}) })
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
uploadDatabaseBackup( uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
@UploadedFile()
file: Express.Multer.File,
): Promise<void> {
return this.service.uploadBackup(file); return this.service.uploadBackup(file);
} }
} }
+8 -10
View File
@@ -2,6 +2,7 @@ import {
Body, Body,
Controller, Controller,
Delete, Delete,
UploadedFile as File,
Get, Get,
HttpCode, HttpCode,
HttpStatus, HttpStatus,
@@ -10,7 +11,6 @@ import {
Post, Post,
Put, Put,
Res, Res,
UploadedFile,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; 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 { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.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 { 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 { LoggingRepository } from 'src/repositories/logging.repository';
import { UserService } from 'src/services/user.service'; import { UserService } from 'src/services/user.service';
import { sendFile } from 'src/utils/file'; import { sendFile } from 'src/utils/file';
import { UUIDParamDto } from 'src/validation'; import { UUIDParamDto } from 'src/validation';
@ApiTags(ApiTag.Users) @ApiTags(ApiTag.Users)
@Controller(RouteKey.User) @Controller('users')
export class UserController { export class UserController {
constructor( constructor(
private service: UserService, private service: UserService,
@@ -177,7 +178,7 @@ export class UserController {
@Post('profile-image') @Post('profile-image')
@Authenticated({ permission: Permission.UserProfileImageUpdate }) @Authenticated({ permission: Permission.UserProfileImageUpdate })
@UseInterceptors(FileUploadInterceptor) @UseInterceptors(UserProfileUploadInterceptor)
@ApiConsumes('multipart/form-data') @ApiConsumes('multipart/form-data')
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto }) @ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
@Endpoint({ @Endpoint({
@@ -185,11 +186,8 @@ export class UserController {
description: 'Upload and set a new profile image for the current user.', description: 'Upload and set a new profile image for the current user.',
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'), history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
}) })
createProfileImage( createProfileImage(@Auth() auth: AuthDto, @File() file: UploadedFile): Promise<CreateProfileImageResponseDto> {
@Auth() auth: AuthDto, return this.service.createProfileImage(auth, file);
@UploadedFile() fileInfo: Express.Multer.File,
): Promise<CreateProfileImageResponseDto> {
return this.service.createProfileImage(auth, fileInfo);
} }
@Delete('profile-image') @Delete('profile-image')
-5
View File
@@ -487,11 +487,6 @@ export enum MetadataKey {
TelemetryEnabled = 'telemetry_enabled', TelemetryEnabled = 'telemetry_enabled',
} }
export enum RouteKey {
Asset = 'assets',
User = 'users',
}
export enum CacheControl { export enum CacheControl {
PrivateWithCache = 'private_with_cache', PrivateWithCache = 'private_with_cache',
PrivateWithoutCache = 'private_without_cache', PrivateWithoutCache = 'private_without_cache',
@@ -2,13 +2,13 @@ import {
Body, Body,
Controller, Controller,
Delete, Delete,
UploadedFile as File,
Get, Get,
Next, Next,
Param, Param,
Post, Post,
Req, Req,
Res, Res,
UploadedFile,
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
@@ -94,10 +94,7 @@ export class MaintenanceWorkerController {
@Post('admin/database-backups/upload') @Post('admin/database-backups/upload')
@MaintenanceRoute() @MaintenanceRoute()
@UseInterceptors(FileInterceptor('file')) @UseInterceptors(FileInterceptor('file'))
uploadDatabaseBackup( uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
@UploadedFile()
file: Express.Multer.File,
): Promise<void> {
return this.databaseBackupService.uploadBackup(file); return this.databaseBackupService.uploadBackup(file);
} }
@@ -1,27 +1,32 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Response } from 'express'; import multer from 'multer';
import { of } from 'rxjs'; 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 { 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 { AssetMediaService } from 'src/services/asset-media.service';
import { fromMaybeArray } from 'src/utils/request'; import { fromMaybeArray } from 'src/utils/request';
@Injectable() @Injectable()
export class AssetUploadInterceptor implements NestInterceptor { export class AssetUploadInterceptor extends UploadInterceptor {
constructor(private service: AssetMediaService) {} constructor(service: AssetMediaService) {
super({
async intercept(context: ExecutionContext, next: CallHandler<any>) { onRequest: async (req, res) => {
const req = context.switchToHttp().getRequest<AuthenticatedRequest>(); const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]);
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>(); const response = await service.onBeforeUpload(req.user, checksum);
if (response) {
const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]); res.status(200);
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum); return of(response);
if (response) { }
res.status(200); },
return of({ status: AssetMediaStatus.DUPLICATE, id: response.id }); configure: (instance: multer.Multer) =>
} instance.fields([
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
return next.handle(); { 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),
});
} }
} }
@@ -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;
}
}
}
}
+131
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);
}
}
@@ -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),
});
}
}
+27 -133
View File
@@ -1,9 +1,4 @@
import { import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
BadRequestException,
InternalServerErrorException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { AssetFile } from 'src/database'; import { AssetFile } from 'src/database';
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto'; 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 { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum'; 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 { AssetMediaService } from 'src/services/asset-media.service';
import { UploadBody } from 'src/types';
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database'; import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
import { AssetFileFactory } from 'test/factories/asset-file.factory'; 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 { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub'; import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex'); const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const uploadFile = { const create = (fieldName: UploadFieldName, originalName: string): UploadFile => {
nullAuth: { return {
auth: null, requestId: newUuid(),
body: {}, fieldName,
fieldName: UploadFieldName.ASSET_DATA, originalName,
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 validImages = [ const validImages = [
@@ -208,17 +181,17 @@ describe(AssetMediaService.name, () => {
describe('getUploadAssetIdByChecksum', () => { describe('getUploadAssetIdByChecksum', () => {
it('should return if checksum is undefined', async () => { 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 () => { 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); expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
}); });
it('should find an existing asset', async () => { it('should find an existing asset', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); 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', id: 'asset-id',
status: AssetMediaStatus.DUPLICATE, status: AssetMediaStatus.DUPLICATE,
}); });
@@ -227,7 +200,7 @@ describe(AssetMediaService.name, () => {
it('should find an existing asset by base64', async () => { it('should find an existing asset by base64', async () => {
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id'); 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', id: 'asset-id',
status: AssetMediaStatus.DUPLICATE, status: AssetMediaStatus.DUPLICATE,
}); });
@@ -236,21 +209,17 @@ describe(AssetMediaService.name, () => {
}); });
describe('canUpload', () => { describe('canUpload', () => {
it('should require an authenticated user', () => {
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
for (const { fieldName, valid, invalid } of uploadTests) { for (const { fieldName, valid, invalid } of uploadTests) {
describe(fieldName, () => { describe(fieldName, () => {
for (const filetype of valid) { for (const filetype of valid) {
it(`should accept ${filetype}`, () => { 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) { for (const filetype of invalid) {
it(`should reject ${filetype}`, () => { it(`should reject ${filetype}`, () => {
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError( expect(() => sut.canUpload(AuthFactory.create(), create(fieldName, `asset${filetype}`))).toThrowError(
BadRequestException, 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', () => { describe('uploadAsset', () => {
it('should throw an error if the quota is exceeded', async () => { it('should throw an error if the quota is exceeded', async () => {
const file = { const file = {
uuid: 'random-uuid', requestId: '1',
originalPath: 'fake_path/asset_1.jpeg', fieldName: 'assetData',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg', 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); mocks.asset.create.mockResolvedValue(assetEntity);
@@ -342,9 +263,9 @@ describe(AssetMediaService.name, () => {
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.create).not.toHaveBeenCalled(); 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( expect(mocks.storage.utimes).not.toHaveBeenCalledWith(
file.originalPath, file.metadata.path,
expect.any(Date), expect.any(Date),
new Date(createDto.fileModifiedAt), new Date(createDto.fileModifiedAt),
); );
@@ -1017,31 +938,4 @@ describe(AssetMediaService.name, () => {
expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]); 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')] },
});
});
});
}); });
+63 -84
View File
@@ -1,5 +1,7 @@
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; 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 sanitize from 'sanitize-filename';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { Asset } from 'src/database'; import { Asset } from 'src/database';
@@ -31,11 +33,10 @@ import {
Permission, Permission,
StorageFolder, StorageFolder,
} from 'src/enum'; } 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 { BaseService } from 'src/services/base.service';
import { UploadFile, UploadRequest } from 'src/types';
import { requireUploadAccess } from 'src/utils/access'; 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 { isAssetChecksumConstraint } from 'src/utils/database';
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file'; import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types'; import { mimeTypes } from 'src/utils/mime-types';
@@ -47,8 +48,8 @@ export interface AssetMediaRedirectResponse {
@Injectable() @Injectable()
export class AssetMediaService extends BaseService { export class AssetMediaService extends BaseService {
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> { async onBeforeUpload(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
if (!checksum) { if (!checksum || !auth) {
return; return;
} }
@@ -60,78 +61,56 @@ export class AssetMediaService extends BaseService {
return { id: assetId, status: AssetMediaStatus.DUPLICATE }; return { id: assetId, status: AssetMediaStatus.DUPLICATE };
} }
canUploadFile({ auth, fieldName, file, body }: UploadRequest): true { canUpload(auth: AuthDto, file: UploadFile): true {
requireUploadAccess(auth); requireUploadAccess(auth);
const filename = body.filename || file.originalName; if (
(file.fieldName === UploadFieldName.ASSET_DATA && mimeTypes.isAsset(file.originalName)) ||
switch (fieldName) { (file.fieldName === UploadFieldName.SIDECAR_DATA && mimeTypes.isSidecar(file.originalName))
case UploadFieldName.ASSET_DATA: { ) {
if (mimeTypes.isAsset(filename)) { return true;
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}`); this.logger.error(`Unsupported file type ${file.originalName}`);
throw new BadRequestException(`Unsupported file type ${filename}`); throw new BadRequestException(`Unsupported file type ${file.originalName}`);
} }
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string { async onUpload(auth: AuthDto, file: UploadingFile): Promise<UploadMetadata> {
requireUploadAccess(auth); const stream = file.stream;
let checksum: Buffer | undefined;
let size = 0;
const extension = extname(body.filename || file.originalName); const hash = createHash('sha1');
const lookup = { stream
[UploadFieldName.ASSET_DATA]: extension, .on('data', (chunk: Buffer) => {
[UploadFieldName.SIDECAR_DATA]: '.xmp', hash.update(chunk);
[UploadFieldName.PROFILE_DATA]: extension, size += chunk.length;
}; })
.on('end', () => (checksum = hash.digest()))
.on('error', () => hash.destroy());
return sanitize(`${file.uuid}${lookup[fieldName]}`); 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);
getUploadFolder({ auth, fieldName, file }: UploadRequest): string { const path = join(folder, filename);
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);
}
this.storageRepository.mkdirSync(folder); 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) { async onUploadRemove(auth: AuthDto, file: UploadedFile): Promise<void> {
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file)); await this.storageRepository.unlink(file.metadata.path);
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
const uploadPath = `${uploadFolder}/${uploadFilename}`;
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
} }
async uploadAsset( async uploadAsset(
auth: AuthDto, auth: AuthDto,
dto: AssetMediaCreateDto, dto: AssetMediaCreateDto,
file: UploadFile, file: UploadedFile,
sidecarFile?: UploadFile, sidecarFile?: UploadedFile,
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
try { try {
await this.requireAccess({ await this.requireAccess({
@@ -141,7 +120,7 @@ export class AssetMediaService extends BaseService {
ids: [auth.user.id], ids: [auth.user.id],
}); });
this.requireQuota(auth, file.size); this.requireQuota(auth, file.metadata.size);
if (dto.livePhotoVideoId) { if (dto.livePhotoVideoId) {
await onBeforeLink( await onBeforeLink(
@@ -151,7 +130,7 @@ export class AssetMediaService extends BaseService {
} }
const asset = await this.create(auth.user.id, dto, file, sidecarFile); 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 }; return { id: asset.id, status: AssetMediaStatus.CREATED };
} catch (error: any) { } catch (error: any) {
@@ -163,8 +142,8 @@ export class AssetMediaService extends BaseService {
auth: AuthDto, auth: AuthDto,
id: string, id: string,
dto: AssetMediaReplaceDto, dto: AssetMediaReplaceDto,
file: UploadFile, file: UploadedFile,
sidecarFile?: UploadFile, sidecarFile?: UploadedFile,
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
try { try {
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] }); await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
@@ -174,9 +153,9 @@ export class AssetMediaService extends BaseService {
throw new Error('Asset not found'); 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, // 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. // 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.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id }); 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 }; return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
} catch (error: any) { } catch (error: any) {
@@ -325,18 +304,18 @@ export class AssetMediaService extends BaseService {
private async handleUploadError( private async handleUploadError(
error: any, error: any,
auth: AuthDto, auth: AuthDto,
file: UploadFile, file: UploadedFile,
sidecarFile?: UploadFile, sidecarFile?: UploadedFile,
): Promise<AssetMediaResponseDto> { ): Promise<AssetMediaResponseDto> {
// clean up files // clean up files
await this.jobRepository.queue({ await this.jobRepository.queue({
name: JobName.FileDelete, name: JobName.FileDelete,
data: { files: [file.originalPath, sidecarFile?.originalPath] }, data: { files: [file.metadata.path, sidecarFile?.metadata.path] },
}); });
// handle duplicates with a success response // handle duplicates with a success response
if (isAssetChecksumConstraint(error)) { 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) { if (!duplicateId) {
this.logger.error(`Error locating duplicate for checksum constraint`); this.logger.error(`Error locating duplicate for checksum constraint`);
throw new InternalServerErrorException(); throw new InternalServerErrorException();
@@ -358,15 +337,15 @@ export class AssetMediaService extends BaseService {
private async replaceFileData( private async replaceFileData(
assetId: string, assetId: string,
dto: AssetMediaReplaceDto, dto: AssetMediaReplaceDto,
file: UploadFile, file: UploadedFile,
sidecarPath?: string, sidecarPath?: string,
): Promise<void> { ): Promise<void> {
await this.assetRepository.update({ await this.assetRepository.update({
id: assetId, id: assetId,
checksum: file.checksum, checksum: file.metadata.checksum,
originalPath: file.originalPath, originalPath: file.metadata.path,
type: mimeTypes.assetType(file.originalPath), type: mimeTypes.assetType(file.metadata.path),
originalFileName: file.originalName, originalFileName: file.originalName,
deviceAssetId: dto.deviceAssetId, deviceAssetId: dto.deviceAssetId,
@@ -383,9 +362,9 @@ export class AssetMediaService extends BaseService {
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath }) ? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar })); : 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( await this.assetRepository.upsertExif(
{ assetId, fileSizeInByte: file.size }, { assetId, fileSizeInByte: file.metadata.size },
{ lockedPropertiesBehavior: 'override' }, { lockedPropertiesBehavior: 'override' },
); );
await this.jobRepository.queue({ await this.jobRepository.queue({
@@ -424,13 +403,13 @@ export class AssetMediaService extends BaseService {
return created; 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({ const asset = await this.assetRepository.create({
ownerId, ownerId,
libraryId: null, libraryId: null,
checksum: file.checksum, checksum: file.metadata.checksum!,
originalPath: file.originalPath, originalPath: file.metadata.path,
deviceAssetId: dto.deviceAssetId, deviceAssetId: dto.deviceAssetId,
deviceId: dto.deviceId, deviceId: dto.deviceId,
@@ -439,7 +418,7 @@ export class AssetMediaService extends BaseService {
fileModifiedAt: dto.fileModifiedAt, fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt, localDateTime: dto.fileCreatedAt,
type: mimeTypes.assetType(file.originalPath), type: mimeTypes.assetType(file.metadata.path),
isFavorite: dto.isFavorite, isFavorite: dto.isFavorite,
duration: dto.duration || null, duration: dto.duration || null,
visibility: dto.visibility ?? AssetVisibility.Timeline, visibility: dto.visibility ?? AssetVisibility.Timeline,
@@ -454,14 +433,14 @@ export class AssetMediaService extends BaseService {
if (sidecarFile) { if (sidecarFile) {
await this.assetRepository.upsertFile({ await this.assetRepository.upsertFile({
assetId: asset.id, assetId: asset.id,
path: sidecarFile.originalPath, path: sidecarFile.metadata.path,
type: AssetFileType.Sidecar, 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( await this.assetRepository.upsertExif(
{ assetId: asset.id, fileSizeInByte: file.size }, { assetId: asset.id, fileSizeInByte: file.metadata.size },
{ lockedPropertiesBehavior: 'override' }, { lockedPropertiesBehavior: 'override' },
); );
+31 -2
View File
@@ -1,6 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Updateable } from 'kysely'; import { Updateable } from 'kysely';
import { DateTime } from 'luxon'; 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 { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { OnJob } from 'src/decorators'; import { OnJob } from 'src/decorators';
@@ -11,15 +14,41 @@ import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences }
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto'; import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto'; import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum'; 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 { UserFindOptions } from 'src/repositories/user.repository';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service'; import { BaseService } from 'src/services/base.service';
import { JobOf, UserMetadataItem } from 'src/types'; import { JobOf, UserMetadataItem } from 'src/types';
import { ImmichFileResponse } from 'src/utils/file'; import { ImmichFileResponse } from 'src/utils/file';
import { mimeTypes } from 'src/utils/mime-types';
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences'; import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
@Injectable() @Injectable()
export class UserService extends BaseService { 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[]> { async search(auth: AuthDto): Promise<UserResponseDto[]> {
const config = await this.getConfig({ withCache: false }); const config = await this.getConfig({ withCache: false });
@@ -90,11 +119,11 @@ export class UserService extends BaseService {
return mapUser(user); 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 { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
const user = await this.userRepository.update(auth.user.id, { const user = await this.userRepository.update(auth.user.id, {
profileImagePath: file.path, profileImagePath: file.metadata.path,
profileChangedAt: new Date(), profileChangedAt: new Date(),
}); });
-33
View File
@@ -1,8 +1,6 @@
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { VECTOR_EXTENSIONS } from 'src/constants'; import { VECTOR_EXTENSIONS } from 'src/constants';
import { Asset, AssetFile } from 'src/database'; 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 { AssetEditActionItem } from 'src/dtos/editing.dto';
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto'; import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
import { import {
@@ -420,37 +418,6 @@ export interface VectorUpdateResult {
restartRequired: boolean; 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 { export interface IBulkAsset {
getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>; getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>;
addAssetIds: (id: string, assetIds: string[]) => Promise<void>; addAssetIds: (id: string, assetIds: string[]) => Promise<void>;
+1 -22
View File
@@ -2,16 +2,14 @@ import { BadRequestException } from '@nestjs/common';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database'; import { AssetFile, Exif } from 'src/database';
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto'; 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 { AuthDto } from 'src/dtos/auth.dto';
import { ExifResponseDto } from 'src/dtos/exif.dto'; import { ExifResponseDto } from 'src/dtos/exif.dto';
import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum'; import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { AssetRepository } from 'src/repositories/asset.repository'; import { AssetRepository } from 'src/repositories/asset.repository';
import { EventRepository } from 'src/repositories/event.repository'; import { EventRepository } from 'src/repositories/event.repository';
import { PartnerRepository } from 'src/repositories/partner.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'; import { checkAccess } from 'src/utils/access';
export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => { 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 }); 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 isFlipped = (orientation?: string | null) => {
const value = Number(orientation); const value = Number(orientation);
return value && [5, 6, 7, 8, -90, 90].includes(value); return value && [5, 6, 7, 8, -90, 90].includes(value);
+1 -2
View File
@@ -13,7 +13,6 @@ import postgres from 'postgres';
import { UploadFieldName } from 'src/dtos/asset-media.dto'; import { UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor'; import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
import { AuthGuard } from 'src/middleware/auth.guard'; import { AuthGuard } from 'src/middleware/auth.guard';
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
import { AccessRepository } from 'src/repositories/access.repository'; import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@@ -120,7 +119,7 @@ export const controllerSetup = async (controller: ClassConstructor<unknown>, pro
...providers, ...providers,
], ],
}) })
.overrideInterceptor(FileUploadInterceptor) .overrideInterceptor(AssetUploadInterceptor)
.useValue(memoryFileInterceptor) .useValue(memoryFileInterceptor)
.overrideInterceptor(AssetUploadInterceptor) .overrideInterceptor(AssetUploadInterceptor)
.useValue(noopInterceptor) .useValue(noopInterceptor)