diff --git a/server/src/app.module.ts b/server/src/app.module.ts index 49b779ca18..9f8f2b20fb 100644 --- a/server/src/app.module.ts +++ b/server/src/app.module.ts @@ -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(); diff --git a/server/src/controllers/asset-media.controller.ts b/server/src/controllers/asset-media.controller.ts index ec6083cfa8..042b09e8c7 100644 --- a/server/src/controllers/asset-media.controller.ts +++ b/server/src/controllers/asset-media.controller.ts @@ -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 { - 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 { - 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); } diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index 8eb3a5ce44..0ffae34790 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -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) {} diff --git a/server/src/controllers/database-backup.controller.ts b/server/src/controllers/database-backup.controller.ts index 737c8f3958..88572d105f 100644 --- a/server/src/controllers/database-backup.controller.ts +++ b/server/src/controllers/database-backup.controller.ts @@ -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 { + uploadDatabaseBackup(@File() file: Express.Multer.File): Promise { return this.service.uploadBackup(file); } } diff --git a/server/src/controllers/user.controller.ts b/server/src/controllers/user.controller.ts index 9c0dd3db7a..569e2c2910 100644 --- a/server/src/controllers/user.controller.ts +++ b/server/src/controllers/user.controller.ts @@ -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 { - return this.service.createProfileImage(auth, fileInfo); + createProfileImage(@Auth() auth: AuthDto, @File() file: UploadedFile): Promise { + return this.service.createProfileImage(auth, file); } @Delete('profile-image') diff --git a/server/src/enum.ts b/server/src/enum.ts index 8f509754da..7a493637a5 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -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', diff --git a/server/src/maintenance/maintenance-worker.controller.ts b/server/src/maintenance/maintenance-worker.controller.ts index 162fa27257..f67cd5593d 100644 --- a/server/src/maintenance/maintenance-worker.controller.ts +++ b/server/src/maintenance/maintenance-worker.controller.ts @@ -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 { + uploadDatabaseBackup(@File() file: Express.Multer.File): Promise { return this.databaseBackupService.uploadBackup(file); } diff --git a/server/src/middleware/asset-upload.interceptor.ts b/server/src/middleware/asset-upload.interceptor.ts index 0f1eaa4ce5..2853dfd171 100644 --- a/server/src/middleware/asset-upload.interceptor.ts +++ b/server/src/middleware/asset-upload.interceptor.ts @@ -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) { - const req = context.switchToHttp().getRequest(); - const res = context.switchToHttp().getResponse>(); - - 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), + }); } } diff --git a/server/src/middleware/file-upload.interceptor.ts b/server/src/middleware/file-upload.interceptor.ts deleted file mode 100644 index 6dfd11ee4b..0000000000 --- a/server/src/middleware/file-upload.interceptor.ts +++ /dev/null @@ -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 { - (error: Error): void; - (error: null, result: T): void; -} - -const callbackify = (target: (...arguments_: any[]) => T, callback: Callback) => { - 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): Promise> { - const context_ = context.switchToHttp(); - const route = this.reflect.get(PATH_METADATA, context.getClass()); - - const handler: RequestHandler | null = this.getHandler(route as RouteKey); - if (handler) { - await new Promise((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, - ); - } - - private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) { - return callbackify( - () => this.assetService.getUploadFolder(asUploadRequest(request, file)), - callback as Callback, - ); - } - - private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback>) { - (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; - } - } - } -} diff --git a/server/src/middleware/upload.interceptor.ts b/server/src/middleware/upload.interceptor.ts new file mode 100644 index 0000000000..2242bdfb83 --- /dev/null +++ b/server/src/middleware/upload.interceptor.ts @@ -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 = { + (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 | 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 = (target: () => T | Promise, callback: Callback) => { + 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; + remove(req: UploadRequest, file: UploadedFile): Promise; + }, + ) { + 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): Promise> { + const http = context.switchToHttp(); + const req = http.getRequest(); + const res = http.getResponse(); + + 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((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) { + return handle( + () => + this.options + .upload(req, { ...mapUploadFile(req, file), stream: file.stream }) + .then((metadata) => ({ metadata })), + callback, + ); + } + + private removeFile(req: UploadRequest, file: Express.Multer.File, callback: Callback) { + return handle(() => this.options.remove(req, mapUploadedFile(req, file)), callback); + } +} diff --git a/server/src/middleware/user-profile-upload.interceptor.ts b/server/src/middleware/user-profile-upload.interceptor.ts new file mode 100644 index 0000000000..8d77d6caf6 --- /dev/null +++ b/server/src/middleware/user-profile-upload.interceptor.ts @@ -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), + }); + } +} diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 84440fd4b6..a3ef659a22 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -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')] }, - }); - }); - }); }); diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index 020bda4df7..8afc33a82b 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -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 { - if (!checksum) { + async onBeforeUpload(auth: AuthDto, checksum?: string): Promise { + 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 { + 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 { + await this.storageRepository.unlink(file.metadata.path); } async uploadAsset( auth: AuthDto, dto: AssetMediaCreateDto, - file: UploadFile, - sidecarFile?: UploadFile, + file: UploadedFile, + sidecarFile?: UploadedFile, ): Promise { 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 { 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 { // 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 { 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' }, ); diff --git a/server/src/services/user.service.ts b/server/src/services/user.service.ts index 9fb1f45e54..2b1ed283ea 100644 --- a/server/src/services/user.service.ts +++ b/server/src/services/user.service.ts @@ -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 { + 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 { 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 { + async createProfileImage(auth: AuthDto, file: UploadedFile): Promise { 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(), }); diff --git a/server/src/types.ts b/server/src/types.ts index 3e9ea25957..0c0785ab5b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -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>; addAssetIds: (id: string, assetIds: string[]) => Promise; diff --git a/server/src/utils/asset.util.ts b/server/src/utils/asset.util.ts index f8fb3d215d..51ec9f72d4 100644 --- a/server/src/utils/asset.util.ts +++ b/server/src/utils/asset.util.ts @@ -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); diff --git a/server/test/utils.ts b/server/test/utils.ts index c2a83c52ae..7ced0e1dd4 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -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, pro ...providers, ], }) - .overrideInterceptor(FileUploadInterceptor) + .overrideInterceptor(AssetUploadInterceptor) .useValue(memoryFileInterceptor) .overrideInterceptor(AssetUploadInterceptor) .useValue(noopInterceptor)