mirror of
https://github.com/immich-app/immich.git
synced 2026-03-03 02:37:02 +00:00
Compare commits
1 Commits
d94d9600a7
...
refactor/a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ebc110603 |
@@ -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();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
UploadedFiles as Files,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
@@ -35,18 +35,17 @@ import {
|
||||
} from 'src/dtos/asset-media.dto';
|
||||
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum';
|
||||
import { ApiTag, ImmichHeader, Permission } from 'src/enum';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor';
|
||||
import { mapUploadedFile, UploadFiles, UploadRequest } from 'src/middleware/upload.interceptor';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { UploadFiles } from 'src/types';
|
||||
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Assets)
|
||||
@Controller(RouteKey.Asset)
|
||||
@Controller('assets')
|
||||
export class AssetMediaController {
|
||||
constructor(
|
||||
private logger: LoggingRepository,
|
||||
@@ -55,7 +54,7 @@ export class AssetMediaController {
|
||||
|
||||
@Post()
|
||||
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
|
||||
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
|
||||
@UseInterceptors(AssetUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiHeader({
|
||||
name: ImmichHeader.Checksum,
|
||||
@@ -80,12 +79,21 @@ export class AssetMediaController {
|
||||
})
|
||||
async uploadAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
|
||||
@Files(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||
files: UploadFiles,
|
||||
@Body() dto: AssetMediaCreateDto,
|
||||
@Req() req: UploadRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
const { file, sidecarFile } = getFiles(files);
|
||||
const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile);
|
||||
const file = files[UploadFieldName.ASSET_DATA][0];
|
||||
const sidecarFile = files[UploadFieldName.SIDECAR_DATA]?.[0];
|
||||
|
||||
const responseDto = await this.service.uploadAsset(
|
||||
auth,
|
||||
dto,
|
||||
mapUploadedFile(req, file),
|
||||
sidecarFile ? mapUploadedFile(req, sidecarFile) : undefined,
|
||||
);
|
||||
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
@@ -113,7 +121,7 @@ export class AssetMediaController {
|
||||
}
|
||||
|
||||
@Put(':id/original')
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@UseInterceptors(AssetUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
@@ -129,13 +137,19 @@ export class AssetMediaController {
|
||||
async replaceAsset(
|
||||
@Auth() auth: AuthDto,
|
||||
@Param() { id }: UUIDParamDto,
|
||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||
|
||||
@Files(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||
files: UploadFiles,
|
||||
@Body() dto: AssetMediaReplaceDto,
|
||||
@Req() req: UploadRequest,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
const { file } = getFiles(files);
|
||||
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
|
||||
const responseDto = await this.service.replaceAsset(
|
||||
auth,
|
||||
id,
|
||||
dto,
|
||||
mapUploadedFile(req, files[UploadFieldName.ASSET_DATA][0]),
|
||||
);
|
||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||
res.status(HttpStatus.OK);
|
||||
}
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
UploadedFile as File,
|
||||
Get,
|
||||
Next,
|
||||
Param,
|
||||
Post,
|
||||
Res,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { NextFunction, Response } from 'express';
|
||||
@@ -92,10 +103,7 @@ export class DatabaseBackupController {
|
||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||
})
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadDatabaseBackup(
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
|
||||
return this.service.uploadBackup(file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
UploadedFile as File,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
Post,
|
||||
Put,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
@@ -22,16 +22,17 @@ import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
||||
import { ApiTag, Permission } from 'src/enum';
|
||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
import { UploadedFile } from 'src/middleware/upload.interceptor';
|
||||
import { UserProfileUploadInterceptor } from 'src/middleware/user-profile-upload.interceptor';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
import { sendFile } from 'src/utils/file';
|
||||
import { UUIDParamDto } from 'src/validation';
|
||||
|
||||
@ApiTags(ApiTag.Users)
|
||||
@Controller(RouteKey.User)
|
||||
@Controller('users')
|
||||
export class UserController {
|
||||
constructor(
|
||||
private service: UserService,
|
||||
@@ -177,7 +178,7 @@ export class UserController {
|
||||
|
||||
@Post('profile-image')
|
||||
@Authenticated({ permission: Permission.UserProfileImageUpdate })
|
||||
@UseInterceptors(FileUploadInterceptor)
|
||||
@UseInterceptors(UserProfileUploadInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
|
||||
@Endpoint({
|
||||
@@ -185,11 +186,8 @@ export class UserController {
|
||||
description: 'Upload and set a new profile image for the current user.',
|
||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||
})
|
||||
createProfileImage(
|
||||
@Auth() auth: AuthDto,
|
||||
@UploadedFile() fileInfo: Express.Multer.File,
|
||||
): Promise<CreateProfileImageResponseDto> {
|
||||
return this.service.createProfileImage(auth, fileInfo);
|
||||
createProfileImage(@Auth() auth: AuthDto, @File() file: UploadedFile): Promise<CreateProfileImageResponseDto> {
|
||||
return this.service.createProfileImage(auth, file);
|
||||
}
|
||||
|
||||
@Delete('profile-image')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -2,13 +2,13 @@ import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
UploadedFile as File,
|
||||
Get,
|
||||
Next,
|
||||
Param,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
@@ -94,10 +94,7 @@ export class MaintenanceWorkerController {
|
||||
@Post('admin/database-backups/upload')
|
||||
@MaintenanceRoute()
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
uploadDatabaseBackup(
|
||||
@UploadedFile()
|
||||
file: Express.Multer.File,
|
||||
): Promise<void> {
|
||||
uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
|
||||
return this.databaseBackupService.uploadBackup(file);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,32 @@
|
||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import multer from 'multer';
|
||||
import { of } from 'rxjs';
|
||||
import { AssetMediaResponseDto, AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { ImmichHeader } from 'src/enum';
|
||||
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
|
||||
import { UploadInterceptor } from 'src/middleware/upload.interceptor';
|
||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||
import { fromMaybeArray } from 'src/utils/request';
|
||||
|
||||
@Injectable()
|
||||
export class AssetUploadInterceptor implements NestInterceptor {
|
||||
constructor(private service: AssetMediaService) {}
|
||||
|
||||
async intercept(context: ExecutionContext, next: CallHandler<any>) {
|
||||
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
|
||||
|
||||
const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]);
|
||||
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
|
||||
if (response) {
|
||||
res.status(200);
|
||||
return of({ status: AssetMediaStatus.DUPLICATE, id: response.id });
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
export class AssetUploadInterceptor extends UploadInterceptor {
|
||||
constructor(service: AssetMediaService) {
|
||||
super({
|
||||
onRequest: async (req, res) => {
|
||||
const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]);
|
||||
const response = await service.onBeforeUpload(req.user, checksum);
|
||||
if (response) {
|
||||
res.status(200);
|
||||
return of(response);
|
||||
}
|
||||
},
|
||||
configure: (instance: multer.Multer) =>
|
||||
instance.fields([
|
||||
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
||||
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
||||
]),
|
||||
canUpload: (req, file) => service.canUpload(req.user, file),
|
||||
upload: (req, file) => service.onUpload(req.user, file),
|
||||
remove: (req, file) => service.onUploadRemove(req.user, file),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
server/src/middleware/upload.interceptor.ts
Normal file
131
server/src/middleware/upload.interceptor.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
server/src/middleware/user-profile-upload.interceptor.ts
Normal file
17
server/src/middleware/user-profile-upload.interceptor.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import multer from 'multer';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { UploadInterceptor } from 'src/middleware/upload.interceptor';
|
||||
import { UserService } from 'src/services/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class UserProfileUploadInterceptor extends UploadInterceptor {
|
||||
constructor(service: UserService) {
|
||||
super({
|
||||
configure: (instance: multer.Multer) => instance.single(UploadFieldName.PROFILE_DATA),
|
||||
canUpload: (req, file) => service.canUpload(req.user, file),
|
||||
upload: (req, file) => service.onUpload(req.user, file),
|
||||
remove: (req, file) => service.onUploadRemove(req.user, file),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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')] },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||
import { extname } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { extname, join } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { Asset } from 'src/database';
|
||||
@@ -31,11 +33,10 @@ import {
|
||||
Permission,
|
||||
StorageFolder,
|
||||
} from 'src/enum';
|
||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
||||
import { UploadedFile, UploadFile, UploadingFile, UploadMetadata } from 'src/middleware/upload.interceptor';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { UploadFile, UploadRequest } from 'src/types';
|
||||
import { requireUploadAccess } from 'src/utils/access';
|
||||
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
|
||||
import { onBeforeLink } from 'src/utils/asset.util';
|
||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
@@ -47,8 +48,8 @@ export interface AssetMediaRedirectResponse {
|
||||
|
||||
@Injectable()
|
||||
export class AssetMediaService extends BaseService {
|
||||
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
|
||||
if (!checksum) {
|
||||
async onBeforeUpload(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
|
||||
if (!checksum || !auth) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -60,78 +61,56 @@ export class AssetMediaService extends BaseService {
|
||||
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
|
||||
}
|
||||
|
||||
canUploadFile({ auth, fieldName, file, body }: UploadRequest): true {
|
||||
canUpload(auth: AuthDto, file: UploadFile): true {
|
||||
requireUploadAccess(auth);
|
||||
|
||||
const filename = body.filename || file.originalName;
|
||||
|
||||
switch (fieldName) {
|
||||
case UploadFieldName.ASSET_DATA: {
|
||||
if (mimeTypes.isAsset(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.SIDECAR_DATA: {
|
||||
if (mimeTypes.isSidecar(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case UploadFieldName.PROFILE_DATA: {
|
||||
if (mimeTypes.isProfile(filename)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (
|
||||
(file.fieldName === UploadFieldName.ASSET_DATA && mimeTypes.isAsset(file.originalName)) ||
|
||||
(file.fieldName === UploadFieldName.SIDECAR_DATA && mimeTypes.isSidecar(file.originalName))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.error(`Unsupported file type ${filename}`);
|
||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
||||
this.logger.error(`Unsupported file type ${file.originalName}`);
|
||||
throw new BadRequestException(`Unsupported file type ${file.originalName}`);
|
||||
}
|
||||
|
||||
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
|
||||
requireUploadAccess(auth);
|
||||
async onUpload(auth: AuthDto, file: UploadingFile): Promise<UploadMetadata> {
|
||||
const stream = file.stream;
|
||||
let checksum: Buffer | undefined;
|
||||
let size = 0;
|
||||
|
||||
const extension = extname(body.filename || file.originalName);
|
||||
const hash = createHash('sha1');
|
||||
|
||||
const lookup = {
|
||||
[UploadFieldName.ASSET_DATA]: extension,
|
||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
||||
[UploadFieldName.PROFILE_DATA]: extension,
|
||||
};
|
||||
stream
|
||||
.on('data', (chunk: Buffer) => {
|
||||
hash.update(chunk);
|
||||
size += chunk.length;
|
||||
})
|
||||
.on('end', () => (checksum = hash.digest()))
|
||||
.on('error', () => hash.destroy());
|
||||
|
||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
||||
}
|
||||
|
||||
getUploadFolder({ auth, fieldName, file }: UploadRequest): string {
|
||||
auth = requireUploadAccess(auth);
|
||||
|
||||
let folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, file.uuid);
|
||||
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
||||
folder = StorageCore.getFolderLocation(StorageFolder.Profile, auth.user.id);
|
||||
}
|
||||
const extension = file.fieldName === UploadFieldName.ASSET_DATA ? extname(file.originalName) : '.xmp';
|
||||
const filename = sanitize(`${file.requestId}${extension}`);
|
||||
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, filename);
|
||||
const path = join(folder, filename);
|
||||
|
||||
this.storageRepository.mkdirSync(folder);
|
||||
|
||||
return folder;
|
||||
await pipeline(stream, this.storageRepository.createWriteStream(path));
|
||||
|
||||
return { filename, folder, path, checksum, size };
|
||||
}
|
||||
|
||||
async onUploadError(request: AuthRequest, file: Express.Multer.File) {
|
||||
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
|
||||
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
|
||||
const uploadPath = `${uploadFolder}/${uploadFilename}`;
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
|
||||
async onUploadRemove(auth: AuthDto, file: UploadedFile): Promise<void> {
|
||||
await this.storageRepository.unlink(file.metadata.path);
|
||||
}
|
||||
|
||||
async uploadAsset(
|
||||
auth: AuthDto,
|
||||
dto: AssetMediaCreateDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
file: UploadedFile,
|
||||
sidecarFile?: UploadedFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.requireAccess({
|
||||
@@ -141,7 +120,7 @@ export class AssetMediaService extends BaseService {
|
||||
ids: [auth.user.id],
|
||||
});
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
this.requireQuota(auth, file.metadata.size);
|
||||
|
||||
if (dto.livePhotoVideoId) {
|
||||
await onBeforeLink(
|
||||
@@ -151,7 +130,7 @@ export class AssetMediaService extends BaseService {
|
||||
}
|
||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
await this.userRepository.updateUsage(auth.user.id, file.metadata.size);
|
||||
|
||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||
} catch (error: any) {
|
||||
@@ -163,8 +142,8 @@ export class AssetMediaService extends BaseService {
|
||||
auth: AuthDto,
|
||||
id: string,
|
||||
dto: AssetMediaReplaceDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
file: UploadedFile,
|
||||
sidecarFile?: UploadedFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
try {
|
||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
|
||||
@@ -174,9 +153,9 @@ export class AssetMediaService extends BaseService {
|
||||
throw new Error('Asset not found');
|
||||
}
|
||||
|
||||
this.requireQuota(auth, file.size);
|
||||
this.requireQuota(auth, file.metadata.size);
|
||||
|
||||
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
|
||||
await this.replaceFileData(asset.id, dto, file, sidecarFile?.metadata.path);
|
||||
|
||||
// Next, create a backup copy of the existing record. The db record has already been updated above,
|
||||
// but the local variable holds the original file data paths.
|
||||
@@ -185,7 +164,7 @@ export class AssetMediaService extends BaseService {
|
||||
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
|
||||
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id });
|
||||
|
||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
||||
await this.userRepository.updateUsage(auth.user.id, file.metadata.size);
|
||||
|
||||
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
|
||||
} catch (error: any) {
|
||||
@@ -325,18 +304,18 @@ export class AssetMediaService extends BaseService {
|
||||
private async handleUploadError(
|
||||
error: any,
|
||||
auth: AuthDto,
|
||||
file: UploadFile,
|
||||
sidecarFile?: UploadFile,
|
||||
file: UploadedFile,
|
||||
sidecarFile?: UploadedFile,
|
||||
): Promise<AssetMediaResponseDto> {
|
||||
// clean up files
|
||||
await this.jobRepository.queue({
|
||||
name: JobName.FileDelete,
|
||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
||||
data: { files: [file.metadata.path, sidecarFile?.metadata.path] },
|
||||
});
|
||||
|
||||
// handle duplicates with a success response
|
||||
if (isAssetChecksumConstraint(error)) {
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.metadata.checksum!);
|
||||
if (!duplicateId) {
|
||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||
throw new InternalServerErrorException();
|
||||
@@ -358,15 +337,15 @@ export class AssetMediaService extends BaseService {
|
||||
private async replaceFileData(
|
||||
assetId: string,
|
||||
dto: AssetMediaReplaceDto,
|
||||
file: UploadFile,
|
||||
file: UploadedFile,
|
||||
sidecarPath?: string,
|
||||
): Promise<void> {
|
||||
await this.assetRepository.update({
|
||||
id: assetId,
|
||||
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
checksum: file.metadata.checksum,
|
||||
originalPath: file.metadata.path,
|
||||
type: mimeTypes.assetType(file.metadata.path),
|
||||
originalFileName: file.originalName,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
@@ -383,9 +362,9 @@ export class AssetMediaService extends BaseService {
|
||||
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
|
||||
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
|
||||
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.storageRepository.utimes(file.metadata.path, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId, fileSizeInByte: file.size },
|
||||
{ assetId, fileSizeInByte: file.metadata.size },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
await this.jobRepository.queue({
|
||||
@@ -424,13 +403,13 @@ export class AssetMediaService extends BaseService {
|
||||
return created;
|
||||
}
|
||||
|
||||
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
|
||||
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadedFile, sidecarFile?: UploadedFile) {
|
||||
const asset = await this.assetRepository.create({
|
||||
ownerId,
|
||||
libraryId: null,
|
||||
|
||||
checksum: file.checksum,
|
||||
originalPath: file.originalPath,
|
||||
checksum: file.metadata.checksum!,
|
||||
originalPath: file.metadata.path,
|
||||
|
||||
deviceAssetId: dto.deviceAssetId,
|
||||
deviceId: dto.deviceId,
|
||||
@@ -439,7 +418,7 @@ export class AssetMediaService extends BaseService {
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
type: mimeTypes.assetType(file.metadata.path),
|
||||
isFavorite: dto.isFavorite,
|
||||
duration: dto.duration || null,
|
||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||
@@ -454,14 +433,14 @@ export class AssetMediaService extends BaseService {
|
||||
if (sidecarFile) {
|
||||
await this.assetRepository.upsertFile({
|
||||
assetId: asset.id,
|
||||
path: sidecarFile.originalPath,
|
||||
path: sidecarFile.metadata.path,
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.storageRepository.utimes(sidecarFile.metadata.path, new Date(), new Date(dto.fileModifiedAt));
|
||||
}
|
||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.storageRepository.utimes(file.metadata.path, new Date(), new Date(dto.fileModifiedAt));
|
||||
await this.assetRepository.upsertExif(
|
||||
{ assetId: asset.id, fileSizeInByte: file.size },
|
||||
{ assetId: asset.id, fileSizeInByte: file.metadata.size },
|
||||
{ lockedPropertiesBehavior: 'override' },
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Updateable } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { extname, join } from 'node:path';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import sanitize from 'sanitize-filename';
|
||||
import { SALT_ROUNDS } from 'src/constants';
|
||||
import { StorageCore } from 'src/cores/storage.core';
|
||||
import { OnJob } from 'src/decorators';
|
||||
@@ -11,15 +14,41 @@ import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences }
|
||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||
import { UploadFile, UploadMetadata, UploadedFile, UploadingFile } from 'src/middleware/upload.interceptor';
|
||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { JobOf, UserMetadataItem } from 'src/types';
|
||||
import { ImmichFileResponse } from 'src/utils/file';
|
||||
import { mimeTypes } from 'src/utils/mime-types';
|
||||
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
||||
|
||||
@Injectable()
|
||||
export class UserService extends BaseService {
|
||||
canUpload(auth: AuthDto, file: UploadFile) {
|
||||
return mimeTypes.isProfile(file.originalName);
|
||||
}
|
||||
|
||||
async onUpload(auth: AuthDto, file: UploadingFile): Promise<UploadMetadata> {
|
||||
const extension = extname(file.originalName);
|
||||
const filename = sanitize(`${file.requestId}${extension}`);
|
||||
const folder = StorageCore.getNestedFolder(StorageFolder.Profile, auth.user.id, filename);
|
||||
const path = join(folder, filename);
|
||||
|
||||
this.storageRepository.mkdirSync(folder);
|
||||
|
||||
let size = 0;
|
||||
file.stream.on('data', (chunk: Buffer) => (size += chunk.length));
|
||||
|
||||
await pipeline(file.stream, this.storageRepository.createWriteStream(path));
|
||||
|
||||
return { filename, folder, path, size };
|
||||
}
|
||||
|
||||
async onUploadRemove(auth: AuthDto, file: UploadedFile) {
|
||||
await this.storageRepository.unlink(file.metadata.path);
|
||||
}
|
||||
|
||||
async search(auth: AuthDto): Promise<UserResponseDto[]> {
|
||||
const config = await this.getConfig({ withCache: false });
|
||||
|
||||
@@ -90,11 +119,11 @@ export class UserService extends BaseService {
|
||||
return mapUser(user);
|
||||
}
|
||||
|
||||
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
||||
async createProfileImage(auth: AuthDto, file: UploadedFile): Promise<CreateProfileImageResponseDto> {
|
||||
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
||||
|
||||
const user = await this.userRepository.update(auth.user.id, {
|
||||
profileImagePath: file.path,
|
||||
profileImagePath: file.metadata.path,
|
||||
profileChangedAt: new Date(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { SystemConfig } from 'src/config';
|
||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||
import { Asset, AssetFile } from 'src/database';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||
import {
|
||||
@@ -420,37 +418,6 @@ export interface VectorUpdateResult {
|
||||
restartRequired: boolean;
|
||||
}
|
||||
|
||||
export interface ImmichFile extends Express.Multer.File {
|
||||
uuid: string;
|
||||
/** sha1 hash of file */
|
||||
checksum: Buffer;
|
||||
}
|
||||
|
||||
export interface UploadFile {
|
||||
uuid: string;
|
||||
checksum: Buffer;
|
||||
originalPath: string;
|
||||
originalName: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface UploadBody {
|
||||
filename?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type UploadRequest = {
|
||||
auth: AuthDto | null;
|
||||
fieldName: UploadFieldName;
|
||||
file: UploadFile;
|
||||
body: UploadBody;
|
||||
};
|
||||
|
||||
export interface UploadFiles {
|
||||
assetData: ImmichFile[];
|
||||
sidecarData: ImmichFile[];
|
||||
}
|
||||
|
||||
export interface IBulkAsset {
|
||||
getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>;
|
||||
addAssetIds: (id: string, assetIds: string[]) => Promise<void>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -13,7 +13,6 @@ import postgres from 'postgres';
|
||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
@@ -120,7 +119,7 @@ export const controllerSetup = async (controller: ClassConstructor<unknown>, pro
|
||||
...providers,
|
||||
],
|
||||
})
|
||||
.overrideInterceptor(FileUploadInterceptor)
|
||||
.overrideInterceptor(AssetUploadInterceptor)
|
||||
.useValue(memoryFileInterceptor)
|
||||
.overrideInterceptor(AssetUploadInterceptor)
|
||||
.useValue(noopInterceptor)
|
||||
|
||||
Reference in New Issue
Block a user