mirror of
https://github.com/immich-app/immich.git
synced 2026-05-23 08:02:29 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ebc110603 |
@@ -14,11 +14,12 @@ import { MaintenanceHealthRepository } from 'src/maintenance/maintenance-health.
|
|||||||
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
import { MaintenanceWebsocketRepository } from 'src/maintenance/maintenance-websocket.repository';
|
||||||
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
|
import { MaintenanceWorkerController } from 'src/maintenance/maintenance-worker.controller';
|
||||||
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
import { MaintenanceWorkerService } from 'src/maintenance/maintenance-worker.service';
|
||||||
|
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||||
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
|
import { ErrorInterceptor } from 'src/middleware/error.interceptor';
|
||||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
|
||||||
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
|
import { GlobalExceptionFilter } from 'src/middleware/global-exception.filter';
|
||||||
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
|
import { LoggingInterceptor } from 'src/middleware/logging.interceptor';
|
||||||
|
import { UserProfileUploadInterceptor } from 'src/middleware/user-profile-upload.interceptor';
|
||||||
import { repositories } from 'src/repositories';
|
import { repositories } from 'src/repositories';
|
||||||
import { AppRepository } from 'src/repositories/app.repository';
|
import { AppRepository } from 'src/repositories/app.repository';
|
||||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||||
@@ -46,7 +47,12 @@ const commonMiddleware = [
|
|||||||
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
|
{ provide: APP_INTERCEPTOR, useClass: ErrorInterceptor },
|
||||||
];
|
];
|
||||||
|
|
||||||
const apiMiddleware = [FileUploadInterceptor, ...commonMiddleware, { provide: APP_GUARD, useClass: AuthGuard }];
|
const apiMiddleware = [
|
||||||
|
AssetUploadInterceptor,
|
||||||
|
UserProfileUploadInterceptor,
|
||||||
|
...commonMiddleware,
|
||||||
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
|
];
|
||||||
|
|
||||||
const configRepository = new ConfigRepository();
|
const configRepository = new ConfigRepository();
|
||||||
const { bull, cls, database, otel } = configRepository.getEnv();
|
const { bull, cls, database, otel } = configRepository.getEnv();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
UploadedFiles as Files,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UploadedFiles,
|
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiConsumes, ApiHeader, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -35,18 +35,17 @@ import {
|
|||||||
} from 'src/dtos/asset-media.dto';
|
} from 'src/dtos/asset-media.dto';
|
||||||
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
|
import { AssetDownloadOriginalDto } from 'src/dtos/asset.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { ApiTag, ImmichHeader, Permission, RouteKey } from 'src/enum';
|
import { ApiTag, ImmichHeader, Permission } from 'src/enum';
|
||||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||||
import { FileUploadInterceptor, getFiles } from 'src/middleware/file-upload.interceptor';
|
import { mapUploadedFile, UploadFiles, UploadRequest } from 'src/middleware/upload.interceptor';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||||
import { UploadFiles } from 'src/types';
|
|
||||||
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
import { ImmichFileResponse, sendFile } from 'src/utils/file';
|
||||||
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
import { FileNotEmptyValidator, UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags(ApiTag.Assets)
|
@ApiTags(ApiTag.Assets)
|
||||||
@Controller(RouteKey.Asset)
|
@Controller('assets')
|
||||||
export class AssetMediaController {
|
export class AssetMediaController {
|
||||||
constructor(
|
constructor(
|
||||||
private logger: LoggingRepository,
|
private logger: LoggingRepository,
|
||||||
@@ -55,7 +54,7 @@ export class AssetMediaController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
|
@Authenticated({ permission: Permission.AssetUpload, sharedLink: true })
|
||||||
@UseInterceptors(AssetUploadInterceptor, FileUploadInterceptor)
|
@UseInterceptors(AssetUploadInterceptor)
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiHeader({
|
@ApiHeader({
|
||||||
name: ImmichHeader.Checksum,
|
name: ImmichHeader.Checksum,
|
||||||
@@ -80,12 +79,21 @@ export class AssetMediaController {
|
|||||||
})
|
})
|
||||||
async uploadAsset(
|
async uploadAsset(
|
||||||
@Auth() auth: AuthDto,
|
@Auth() auth: AuthDto,
|
||||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator(['assetData'])] })) files: UploadFiles,
|
@Files(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||||
|
files: UploadFiles,
|
||||||
@Body() dto: AssetMediaCreateDto,
|
@Body() dto: AssetMediaCreateDto,
|
||||||
|
@Req() req: UploadRequest,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<AssetMediaResponseDto> {
|
): Promise<AssetMediaResponseDto> {
|
||||||
const { file, sidecarFile } = getFiles(files);
|
const file = files[UploadFieldName.ASSET_DATA][0];
|
||||||
const responseDto = await this.service.uploadAsset(auth, dto, file, sidecarFile);
|
const sidecarFile = files[UploadFieldName.SIDECAR_DATA]?.[0];
|
||||||
|
|
||||||
|
const responseDto = await this.service.uploadAsset(
|
||||||
|
auth,
|
||||||
|
dto,
|
||||||
|
mapUploadedFile(req, file),
|
||||||
|
sidecarFile ? mapUploadedFile(req, sidecarFile) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||||
res.status(HttpStatus.OK);
|
res.status(HttpStatus.OK);
|
||||||
@@ -113,7 +121,7 @@ export class AssetMediaController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Put(':id/original')
|
@Put(':id/original')
|
||||||
@UseInterceptors(FileUploadInterceptor)
|
@UseInterceptors(AssetUploadInterceptor)
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiResponse({
|
@ApiResponse({
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -129,13 +137,19 @@ export class AssetMediaController {
|
|||||||
async replaceAsset(
|
async replaceAsset(
|
||||||
@Auth() auth: AuthDto,
|
@Auth() auth: AuthDto,
|
||||||
@Param() { id }: UUIDParamDto,
|
@Param() { id }: UUIDParamDto,
|
||||||
@UploadedFiles(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
|
||||||
|
@Files(new ParseFilePipe({ validators: [new FileNotEmptyValidator([UploadFieldName.ASSET_DATA])] }))
|
||||||
files: UploadFiles,
|
files: UploadFiles,
|
||||||
@Body() dto: AssetMediaReplaceDto,
|
@Body() dto: AssetMediaReplaceDto,
|
||||||
|
@Req() req: UploadRequest,
|
||||||
@Res({ passthrough: true }) res: Response,
|
@Res({ passthrough: true }) res: Response,
|
||||||
): Promise<AssetMediaResponseDto> {
|
): Promise<AssetMediaResponseDto> {
|
||||||
const { file } = getFiles(files);
|
const responseDto = await this.service.replaceAsset(
|
||||||
const responseDto = await this.service.replaceAsset(auth, id, dto, file);
|
auth,
|
||||||
|
id,
|
||||||
|
dto,
|
||||||
|
mapUploadedFile(req, files[UploadFieldName.ASSET_DATA][0]),
|
||||||
|
);
|
||||||
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
if (responseDto.status === AssetMediaStatus.DUPLICATE) {
|
||||||
res.status(HttpStatus.OK);
|
res.status(HttpStatus.OK);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ import {
|
|||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
import { AssetEditActionListDto, AssetEditsDto } from 'src/dtos/editing.dto';
|
||||||
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
import { AssetOcrResponseDto } from 'src/dtos/ocr.dto';
|
||||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
import { ApiTag, Permission } from 'src/enum';
|
||||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||||
import { AssetService } from 'src/services/asset.service';
|
import { AssetService } from 'src/services/asset.service';
|
||||||
import { UUIDParamDto } from 'src/validation';
|
import { UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags(ApiTag.Assets)
|
@ApiTags(ApiTag.Assets)
|
||||||
@Controller(RouteKey.Asset)
|
@Controller('assets')
|
||||||
export class AssetController {
|
export class AssetController {
|
||||||
constructor(private service: AssetService) {}
|
constructor(private service: AssetService) {}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import { Body, Controller, Delete, Get, Next, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
UploadedFile as File,
|
||||||
|
Get,
|
||||||
|
Next,
|
||||||
|
Param,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
UseInterceptors,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
import { NextFunction, Response } from 'express';
|
import { NextFunction, Response } from 'express';
|
||||||
@@ -92,10 +103,7 @@ export class DatabaseBackupController {
|
|||||||
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
history: new HistoryBuilder().added('v2.5.0').alpha('v2.5.0'),
|
||||||
})
|
})
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
uploadDatabaseBackup(
|
uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
|
||||||
@UploadedFile()
|
|
||||||
file: Express.Multer.File,
|
|
||||||
): Promise<void> {
|
|
||||||
return this.service.uploadBackup(file);
|
return this.service.uploadBackup(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
UploadedFile as File,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
@@ -10,7 +11,6 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
Res,
|
Res,
|
||||||
UploadedFile,
|
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||||
@@ -22,16 +22,17 @@ import { OnboardingDto, OnboardingResponseDto } from 'src/dtos/onboarding.dto';
|
|||||||
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
import { UserPreferencesResponseDto, UserPreferencesUpdateDto } from 'src/dtos/user-preferences.dto';
|
||||||
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
import { CreateProfileImageDto, CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto } from 'src/dtos/user.dto';
|
||||||
import { ApiTag, Permission, RouteKey } from 'src/enum';
|
import { ApiTag, Permission } from 'src/enum';
|
||||||
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard';
|
||||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
import { UploadedFile } from 'src/middleware/upload.interceptor';
|
||||||
|
import { UserProfileUploadInterceptor } from 'src/middleware/user-profile-upload.interceptor';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
import { UserService } from 'src/services/user.service';
|
import { UserService } from 'src/services/user.service';
|
||||||
import { sendFile } from 'src/utils/file';
|
import { sendFile } from 'src/utils/file';
|
||||||
import { UUIDParamDto } from 'src/validation';
|
import { UUIDParamDto } from 'src/validation';
|
||||||
|
|
||||||
@ApiTags(ApiTag.Users)
|
@ApiTags(ApiTag.Users)
|
||||||
@Controller(RouteKey.User)
|
@Controller('users')
|
||||||
export class UserController {
|
export class UserController {
|
||||||
constructor(
|
constructor(
|
||||||
private service: UserService,
|
private service: UserService,
|
||||||
@@ -177,7 +178,7 @@ export class UserController {
|
|||||||
|
|
||||||
@Post('profile-image')
|
@Post('profile-image')
|
||||||
@Authenticated({ permission: Permission.UserProfileImageUpdate })
|
@Authenticated({ permission: Permission.UserProfileImageUpdate })
|
||||||
@UseInterceptors(FileUploadInterceptor)
|
@UseInterceptors(UserProfileUploadInterceptor)
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiConsumes('multipart/form-data')
|
||||||
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
|
@ApiBody({ description: 'A new avatar for the user', type: CreateProfileImageDto })
|
||||||
@Endpoint({
|
@Endpoint({
|
||||||
@@ -185,11 +186,8 @@ export class UserController {
|
|||||||
description: 'Upload and set a new profile image for the current user.',
|
description: 'Upload and set a new profile image for the current user.',
|
||||||
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
history: new HistoryBuilder().added('v1').beta('v1').stable('v2'),
|
||||||
})
|
})
|
||||||
createProfileImage(
|
createProfileImage(@Auth() auth: AuthDto, @File() file: UploadedFile): Promise<CreateProfileImageResponseDto> {
|
||||||
@Auth() auth: AuthDto,
|
return this.service.createProfileImage(auth, file);
|
||||||
@UploadedFile() fileInfo: Express.Multer.File,
|
|
||||||
): Promise<CreateProfileImageResponseDto> {
|
|
||||||
return this.service.createProfileImage(auth, fileInfo);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('profile-image')
|
@Delete('profile-image')
|
||||||
|
|||||||
@@ -487,11 +487,6 @@ export enum MetadataKey {
|
|||||||
TelemetryEnabled = 'telemetry_enabled',
|
TelemetryEnabled = 'telemetry_enabled',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum RouteKey {
|
|
||||||
Asset = 'assets',
|
|
||||||
User = 'users',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CacheControl {
|
export enum CacheControl {
|
||||||
PrivateWithCache = 'private_with_cache',
|
PrivateWithCache = 'private_with_cache',
|
||||||
PrivateWithoutCache = 'private_without_cache',
|
PrivateWithoutCache = 'private_without_cache',
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
UploadedFile as File,
|
||||||
Get,
|
Get,
|
||||||
Next,
|
Next,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
UploadedFile,
|
|
||||||
UseInterceptors,
|
UseInterceptors,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { FileInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor } from '@nestjs/platform-express';
|
||||||
@@ -94,10 +94,7 @@ export class MaintenanceWorkerController {
|
|||||||
@Post('admin/database-backups/upload')
|
@Post('admin/database-backups/upload')
|
||||||
@MaintenanceRoute()
|
@MaintenanceRoute()
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
uploadDatabaseBackup(
|
uploadDatabaseBackup(@File() file: Express.Multer.File): Promise<void> {
|
||||||
@UploadedFile()
|
|
||||||
file: Express.Multer.File,
|
|
||||||
): Promise<void> {
|
|
||||||
return this.databaseBackupService.uploadBackup(file);
|
return this.databaseBackupService.uploadBackup(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,32 @@
|
|||||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Response } from 'express';
|
import multer from 'multer';
|
||||||
import { of } from 'rxjs';
|
import { of } from 'rxjs';
|
||||||
import { AssetMediaResponseDto, AssetMediaStatus } from 'src/dtos/asset-media-response.dto';
|
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
import { ImmichHeader } from 'src/enum';
|
import { ImmichHeader } from 'src/enum';
|
||||||
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
|
import { UploadInterceptor } from 'src/middleware/upload.interceptor';
|
||||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||||
import { fromMaybeArray } from 'src/utils/request';
|
import { fromMaybeArray } from 'src/utils/request';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetUploadInterceptor implements NestInterceptor {
|
export class AssetUploadInterceptor extends UploadInterceptor {
|
||||||
constructor(private service: AssetMediaService) {}
|
constructor(service: AssetMediaService) {
|
||||||
|
super({
|
||||||
async intercept(context: ExecutionContext, next: CallHandler<any>) {
|
onRequest: async (req, res) => {
|
||||||
const req = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]);
|
||||||
const res = context.switchToHttp().getResponse<Response<AssetMediaResponseDto>>();
|
const response = await service.onBeforeUpload(req.user, checksum);
|
||||||
|
if (response) {
|
||||||
const checksum = fromMaybeArray(req.headers[ImmichHeader.Checksum]);
|
res.status(200);
|
||||||
const response = await this.service.getUploadAssetIdByChecksum(req.user, checksum);
|
return of(response);
|
||||||
if (response) {
|
}
|
||||||
res.status(200);
|
},
|
||||||
return of({ status: AssetMediaStatus.DUPLICATE, id: response.id });
|
configure: (instance: multer.Multer) =>
|
||||||
}
|
instance.fields([
|
||||||
|
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
||||||
return next.handle();
|
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
||||||
|
]),
|
||||||
|
canUpload: (req, file) => service.canUpload(req.user, file),
|
||||||
|
upload: (req, file) => service.onUpload(req.user, file),
|
||||||
|
remove: (req, file) => service.onUploadRemove(req.user, file),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
|
||||||
import { PATH_METADATA } from '@nestjs/common/constants';
|
|
||||||
import { Reflector } from '@nestjs/core';
|
|
||||||
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
|
|
||||||
import { NextFunction, RequestHandler } from 'express';
|
|
||||||
import multer, { StorageEngine, diskStorage } from 'multer';
|
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
|
||||||
import { Observable } from 'rxjs';
|
|
||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
|
||||||
import { RouteKey } from 'src/enum';
|
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
|
||||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
|
||||||
import { ImmichFile, UploadFile, UploadFiles } from 'src/types';
|
|
||||||
import { asUploadRequest, mapToUploadFile } from 'src/utils/asset.util';
|
|
||||||
|
|
||||||
export function getFile(files: UploadFiles, property: 'assetData' | 'sidecarData') {
|
|
||||||
const file = files[property]?.[0];
|
|
||||||
return file ? mapToUploadFile(file) : file;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFiles(files: UploadFiles) {
|
|
||||||
return {
|
|
||||||
file: getFile(files, 'assetData') as UploadFile,
|
|
||||||
sidecarFile: getFile(files, 'sidecarData'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
type DiskStorageCallback = (error: Error | null, result: string) => void;
|
|
||||||
|
|
||||||
type ImmichMulterFile = Express.Multer.File & { uuid: string };
|
|
||||||
|
|
||||||
interface Callback<T> {
|
|
||||||
(error: Error): void;
|
|
||||||
(error: null, result: T): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callbackify = <T>(target: (...arguments_: any[]) => T, callback: Callback<T>) => {
|
|
||||||
try {
|
|
||||||
return callback(null, target());
|
|
||||||
} catch (error: Error | any) {
|
|
||||||
return callback(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class FileUploadInterceptor implements NestInterceptor {
|
|
||||||
private handlers: {
|
|
||||||
userProfile: RequestHandler;
|
|
||||||
assetUpload: RequestHandler;
|
|
||||||
};
|
|
||||||
private defaultStorage: StorageEngine;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private reflect: Reflector,
|
|
||||||
private assetService: AssetMediaService,
|
|
||||||
private logger: LoggingRepository,
|
|
||||||
) {
|
|
||||||
this.logger.setContext(FileUploadInterceptor.name);
|
|
||||||
|
|
||||||
this.defaultStorage = diskStorage({
|
|
||||||
filename: this.filename.bind(this),
|
|
||||||
destination: this.destination.bind(this),
|
|
||||||
});
|
|
||||||
|
|
||||||
const instance = multer({
|
|
||||||
fileFilter: this.fileFilter.bind(this),
|
|
||||||
storage: {
|
|
||||||
_handleFile: this.handleFile.bind(this),
|
|
||||||
_removeFile: this.removeFile.bind(this),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.handlers = {
|
|
||||||
userProfile: instance.single(UploadFieldName.PROFILE_DATA),
|
|
||||||
assetUpload: instance.fields([
|
|
||||||
{ name: UploadFieldName.ASSET_DATA, maxCount: 1 },
|
|
||||||
{ name: UploadFieldName.SIDECAR_DATA, maxCount: 1 },
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
|
|
||||||
const context_ = context.switchToHttp();
|
|
||||||
const route = this.reflect.get<string>(PATH_METADATA, context.getClass());
|
|
||||||
|
|
||||||
const handler: RequestHandler | null = this.getHandler(route as RouteKey);
|
|
||||||
if (handler) {
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
|
|
||||||
const maybePromise = handler(context_.getRequest(), context_.getResponse(), next);
|
|
||||||
Promise.resolve(maybePromise).catch((error) => reject(error));
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`Skipping invalid file upload route: ${route}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next.handle();
|
|
||||||
}
|
|
||||||
|
|
||||||
private fileFilter(request: AuthRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
|
||||||
return callbackify(() => this.assetService.canUploadFile(asUploadRequest(request, file)), callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
private filename(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
|
||||||
return callbackify(
|
|
||||||
() => this.assetService.getUploadFilename(asUploadRequest(request, file)),
|
|
||||||
callback as Callback<string>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private destination(request: AuthRequest, file: Express.Multer.File, callback: DiskStorageCallback) {
|
|
||||||
return callbackify(
|
|
||||||
() => this.assetService.getUploadFolder(asUploadRequest(request, file)),
|
|
||||||
callback as Callback<string>,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleFile(request: AuthRequest, file: Express.Multer.File, callback: Callback<Partial<ImmichFile>>) {
|
|
||||||
(file as ImmichMulterFile).uuid = randomUUID();
|
|
||||||
|
|
||||||
request.on('error', (error) => {
|
|
||||||
this.logger.warn('Request error while uploading file, cleaning up', error);
|
|
||||||
this.assetService.onUploadError(request, file).catch(this.logger.error);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this.isAssetUploadFile(file)) {
|
|
||||||
this.defaultStorage._handleFile(request, file, callback);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hash = createHash('sha1');
|
|
||||||
file.stream.on('data', (chunk) => hash.update(chunk));
|
|
||||||
this.defaultStorage._handleFile(request, file, (error, info) => {
|
|
||||||
if (error) {
|
|
||||||
hash.destroy();
|
|
||||||
callback(error);
|
|
||||||
} else {
|
|
||||||
callback(null, { ...info, checksum: hash.digest() });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeFile(request: AuthRequest, file: Express.Multer.File, callback: (error: Error | null) => void) {
|
|
||||||
this.defaultStorage._removeFile(request, file, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isAssetUploadFile(file: Express.Multer.File) {
|
|
||||||
switch (file.fieldname as UploadFieldName) {
|
|
||||||
case UploadFieldName.ASSET_DATA: {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getHandler(route: RouteKey) {
|
|
||||||
switch (route) {
|
|
||||||
case RouteKey.Asset: {
|
|
||||||
return this.handlers.assetUpload;
|
|
||||||
}
|
|
||||||
|
|
||||||
case RouteKey.User: {
|
|
||||||
return this.handlers.userProfile;
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { CallHandler, ExecutionContext, NestInterceptor, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { transformException } from '@nestjs/platform-express/multer/multer/multer.utils';
|
||||||
|
import { NextFunction, RequestHandler, Response } from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { AuthenticatedRequest } from 'src/middleware/auth.guard';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
|
||||||
|
type Callback<T> = {
|
||||||
|
(error: Error): void;
|
||||||
|
(error: null, result: T): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadFile = {
|
||||||
|
requestId: string;
|
||||||
|
fieldName: string;
|
||||||
|
originalName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadingFile = UploadFile & {
|
||||||
|
stream: Readable;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadedFile = UploadFile & { metadata: UploadMetadata };
|
||||||
|
|
||||||
|
export type UploadMetadata = {
|
||||||
|
/** folder */
|
||||||
|
folder: string;
|
||||||
|
/** k filename */
|
||||||
|
filename: string;
|
||||||
|
/** full path */
|
||||||
|
path: string;
|
||||||
|
size: number;
|
||||||
|
checksum?: Buffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadFiles = {
|
||||||
|
assetData: Express.Multer.File[];
|
||||||
|
sidecarData: Express.Multer.File[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadRequest = AuthenticatedRequest & {
|
||||||
|
requestId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OnRequest = (req: UploadRequest, res: Response) => Promise<Observable<any> | void>;
|
||||||
|
|
||||||
|
const mapUploadFile = (req: UploadRequest, file: Express.Multer.File): UploadFile => {
|
||||||
|
const originalName = req.body?.filename || Buffer.from(file.originalname, 'latin1').toString('utf8');
|
||||||
|
return {
|
||||||
|
requestId: req.requestId,
|
||||||
|
fieldName: file.fieldname,
|
||||||
|
originalName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mapUploadedFile = (req: UploadRequest, file: Express.Multer.File): UploadedFile => {
|
||||||
|
return { ...mapUploadFile(req, file), metadata: (file as unknown as UploadedFile).metadata };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handle = <T>(target: () => T | Promise<T>, callback: Callback<T>) => {
|
||||||
|
void Promise.resolve(true)
|
||||||
|
.then(() => target())
|
||||||
|
.then((result) => callback(null, result))
|
||||||
|
.catch((error) => callback(error));
|
||||||
|
};
|
||||||
|
|
||||||
|
export class UploadInterceptor implements NestInterceptor {
|
||||||
|
private handler: RequestHandler;
|
||||||
|
private onRequest: OnRequest;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private options: {
|
||||||
|
/** pre-request hook */
|
||||||
|
onRequest?: OnRequest;
|
||||||
|
configure(instance: multer.Multer): RequestHandler;
|
||||||
|
canUpload(req: UploadRequest, file: UploadFile): boolean;
|
||||||
|
upload(req: UploadRequest, file: UploadingFile): Promise<UploadMetadata>;
|
||||||
|
remove(req: UploadRequest, file: UploadedFile): Promise<void>;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const storage = { _handleFile: this.handleFile.bind(this), _removeFile: this.removeFile.bind(this) };
|
||||||
|
this.handler = options.configure(multer({ fileFilter: this.canUpload.bind(this), storage }));
|
||||||
|
this.onRequest = options.onRequest ?? (() => Promise.resolve());
|
||||||
|
}
|
||||||
|
|
||||||
|
async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {
|
||||||
|
const http = context.switchToHttp();
|
||||||
|
const req = http.getRequest<UploadRequest>();
|
||||||
|
const res = http.getResponse<Response>();
|
||||||
|
|
||||||
|
if (!req.user) {
|
||||||
|
throw new UnauthorizedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
req.requestId = v4();
|
||||||
|
|
||||||
|
// hook to preempt the request before file upload
|
||||||
|
const response = await this.onRequest(req, res);
|
||||||
|
if (response) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const next: NextFunction = (error) => (error ? reject(transformException(error)) : resolve());
|
||||||
|
const maybePromise = this.handler(req, res, next);
|
||||||
|
Promise.resolve(maybePromise).catch((error) => reject(error));
|
||||||
|
});
|
||||||
|
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
|
||||||
|
private canUpload(req: UploadRequest, file: Express.Multer.File, callback: multer.FileFilterCallback) {
|
||||||
|
return handle(() => this.options.canUpload(req, mapUploadFile(req, file)), callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleFile(req: UploadRequest, file: Express.Multer.File, callback: Callback<UploadMetadata>) {
|
||||||
|
return handle<any>(
|
||||||
|
() =>
|
||||||
|
this.options
|
||||||
|
.upload(req, { ...mapUploadFile(req, file), stream: file.stream })
|
||||||
|
.then((metadata) => ({ metadata })),
|
||||||
|
callback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeFile(req: UploadRequest, file: Express.Multer.File, callback: Callback<void>) {
|
||||||
|
return handle(() => this.options.remove(req, mapUploadedFile(req, file)), callback);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import multer from 'multer';
|
||||||
|
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
|
import { UploadInterceptor } from 'src/middleware/upload.interceptor';
|
||||||
|
import { UserService } from 'src/services/user.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UserProfileUploadInterceptor extends UploadInterceptor {
|
||||||
|
constructor(service: UserService) {
|
||||||
|
super({
|
||||||
|
configure: (instance: multer.Multer) => instance.single(UploadFieldName.PROFILE_DATA),
|
||||||
|
canUpload: (req, file) => service.canUpload(req.user, file),
|
||||||
|
upload: (req, file) => service.onUpload(req.user, file),
|
||||||
|
remove: (req, file) => service.onUploadRemove(req.user, file),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
import {
|
import { BadRequestException, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
InternalServerErrorException,
|
|
||||||
NotFoundException,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { Stats } from 'node:fs';
|
import { Stats } from 'node:fs';
|
||||||
import { AssetFile } from 'src/database';
|
import { AssetFile } from 'src/database';
|
||||||
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
|
||||||
@@ -11,9 +6,8 @@ import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldN
|
|||||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetEditAction } from 'src/dtos/editing.dto';
|
import { AssetEditAction } from 'src/dtos/editing.dto';
|
||||||
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
import { UploadFile } from 'src/middleware/upload.interceptor';
|
||||||
import { AssetMediaService } from 'src/services/asset-media.service';
|
import { AssetMediaService } from 'src/services/asset-media.service';
|
||||||
import { UploadBody } from 'src/types';
|
|
||||||
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
|
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
|
||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||||
@@ -22,38 +16,17 @@ import { AuthFactory } from 'test/factories/auth.factory';
|
|||||||
import { authStub } from 'test/fixtures/auth.stub';
|
import { authStub } from 'test/fixtures/auth.stub';
|
||||||
import { fileStub } from 'test/fixtures/file.stub';
|
import { fileStub } from 'test/fixtures/file.stub';
|
||||||
import { userStub } from 'test/fixtures/user.stub';
|
import { userStub } from 'test/fixtures/user.stub';
|
||||||
|
import { newUuid } from 'test/small.factory';
|
||||||
import { newTestService, ServiceMocks } from 'test/utils';
|
import { newTestService, ServiceMocks } from 'test/utils';
|
||||||
|
|
||||||
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
|
||||||
|
|
||||||
const uploadFile = {
|
const create = (fieldName: UploadFieldName, originalName: string): UploadFile => {
|
||||||
nullAuth: {
|
return {
|
||||||
auth: null,
|
requestId: newUuid(),
|
||||||
body: {},
|
fieldName,
|
||||||
fieldName: UploadFieldName.ASSET_DATA,
|
originalName,
|
||||||
file: {
|
};
|
||||||
uuid: 'random-uuid',
|
|
||||||
checksum: Buffer.from('checksum', 'utf8'),
|
|
||||||
originalPath: '/data/library/admin/image.jpeg',
|
|
||||||
originalName: 'image.jpeg',
|
|
||||||
size: 1000,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
filename: (fieldName: UploadFieldName, filename: string, body?: UploadBody) => {
|
|
||||||
return {
|
|
||||||
auth: authStub.admin,
|
|
||||||
body: body || {},
|
|
||||||
fieldName,
|
|
||||||
file: {
|
|
||||||
uuid: 'random-uuid',
|
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
checksum: Buffer.from('checksum', 'utf8'),
|
|
||||||
originalPath: `/data/admin/${filename}`,
|
|
||||||
originalName: filename,
|
|
||||||
size: 1000,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validImages = [
|
const validImages = [
|
||||||
@@ -208,17 +181,17 @@ describe(AssetMediaService.name, () => {
|
|||||||
|
|
||||||
describe('getUploadAssetIdByChecksum', () => {
|
describe('getUploadAssetIdByChecksum', () => {
|
||||||
it('should return if checksum is undefined', async () => {
|
it('should return if checksum is undefined', async () => {
|
||||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin)).resolves.toBe(undefined);
|
await expect(sut.onBeforeUpload(authStub.admin)).resolves.toBe(undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle a non-existent asset', async () => {
|
it('should handle a non-existent asset', async () => {
|
||||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
|
await expect(sut.onBeforeUpload(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
|
||||||
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
expect(mocks.asset.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should find an existing asset', async () => {
|
it('should find an existing asset', async () => {
|
||||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
await expect(sut.onBeforeUpload(authStub.admin, file1.toString('hex'))).resolves.toEqual({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
status: AssetMediaStatus.DUPLICATE,
|
status: AssetMediaStatus.DUPLICATE,
|
||||||
});
|
});
|
||||||
@@ -227,7 +200,7 @@ describe(AssetMediaService.name, () => {
|
|||||||
|
|
||||||
it('should find an existing asset by base64', async () => {
|
it('should find an existing asset by base64', async () => {
|
||||||
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue('asset-id');
|
||||||
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
await expect(sut.onBeforeUpload(authStub.admin, file1.toString('base64'))).resolves.toEqual({
|
||||||
id: 'asset-id',
|
id: 'asset-id',
|
||||||
status: AssetMediaStatus.DUPLICATE,
|
status: AssetMediaStatus.DUPLICATE,
|
||||||
});
|
});
|
||||||
@@ -236,21 +209,17 @@ describe(AssetMediaService.name, () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('canUpload', () => {
|
describe('canUpload', () => {
|
||||||
it('should require an authenticated user', () => {
|
|
||||||
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const { fieldName, valid, invalid } of uploadTests) {
|
for (const { fieldName, valid, invalid } of uploadTests) {
|
||||||
describe(fieldName, () => {
|
describe(fieldName, () => {
|
||||||
for (const filetype of valid) {
|
for (const filetype of valid) {
|
||||||
it(`should accept ${filetype}`, () => {
|
it(`should accept ${filetype}`, () => {
|
||||||
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
|
expect(sut.canUpload(AuthFactory.create(), create(fieldName, `asset${filetype}`))).toEqual(true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const filetype of invalid) {
|
for (const filetype of invalid) {
|
||||||
it(`should reject ${filetype}`, () => {
|
it(`should reject ${filetype}`, () => {
|
||||||
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
|
expect(() => sut.canUpload(AuthFactory.create(), create(fieldName, `asset${filetype}`))).toThrowError(
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -265,70 +234,22 @@ describe(AssetMediaService.name, () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should prefer filename from body over name from path', () => {
|
|
||||||
const pathFilename = 'invalid-file-name';
|
|
||||||
const body = { filename: 'video.mov' };
|
|
||||||
expect(() => sut.canUploadFile(uploadFile.filename(UploadFieldName.ASSET_DATA, pathFilename))).toThrowError(
|
|
||||||
BadRequestException,
|
|
||||||
);
|
|
||||||
expect(sut.canUploadFile(uploadFile.filename(UploadFieldName.ASSET_DATA, pathFilename, body))).toEqual(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getUploadFilename', () => {
|
|
||||||
it('should require authentication', () => {
|
|
||||||
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be the original extension for asset upload', () => {
|
|
||||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
|
||||||
'random-uuid.jpg',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be the xmp extension for sidecar upload', () => {
|
|
||||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
|
|
||||||
'random-uuid.xmp',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be the original extension for profile upload', () => {
|
|
||||||
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
|
||||||
'random-uuid.jpg',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getUploadFolder', () => {
|
|
||||||
it('should require authentication', () => {
|
|
||||||
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return profile for profile uploads', () => {
|
|
||||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
|
|
||||||
expect.stringContaining('/data/profile/admin_id'),
|
|
||||||
);
|
|
||||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/profile/admin_id'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return upload for everything else', () => {
|
|
||||||
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
|
|
||||||
expect.stringContaining('/data/upload/admin_id/ra/nd'),
|
|
||||||
);
|
|
||||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('/data/upload/admin_id/ra/nd'));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('uploadAsset', () => {
|
describe('uploadAsset', () => {
|
||||||
it('should throw an error if the quota is exceeded', async () => {
|
it('should throw an error if the quota is exceeded', async () => {
|
||||||
const file = {
|
const file = {
|
||||||
uuid: 'random-uuid',
|
requestId: '1',
|
||||||
originalPath: 'fake_path/asset_1.jpeg',
|
fieldName: 'assetData',
|
||||||
mimeType: 'image/jpeg',
|
|
||||||
checksum: Buffer.from('file hash', 'utf8'),
|
|
||||||
originalName: 'asset_1.jpeg',
|
originalName: 'asset_1.jpeg',
|
||||||
size: 42,
|
metadata: {
|
||||||
|
uuid: 'random-uuid',
|
||||||
|
path: 'fake_path/asset_1.jpeg',
|
||||||
|
folder: 'fake_path',
|
||||||
|
filename: 'asset_1.jpeg',
|
||||||
|
checksum: Buffer.from('file hash', 'utf8'),
|
||||||
|
size: 42,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
mocks.asset.create.mockResolvedValue(assetEntity);
|
mocks.asset.create.mockResolvedValue(assetEntity);
|
||||||
@@ -342,9 +263,9 @@ describe(AssetMediaService.name, () => {
|
|||||||
).rejects.toBeInstanceOf(BadRequestException);
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
|
||||||
expect(mocks.asset.create).not.toHaveBeenCalled();
|
expect(mocks.asset.create).not.toHaveBeenCalled();
|
||||||
expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size);
|
expect(mocks.user.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.metadata.size);
|
||||||
expect(mocks.storage.utimes).not.toHaveBeenCalledWith(
|
expect(mocks.storage.utimes).not.toHaveBeenCalledWith(
|
||||||
file.originalPath,
|
file.metadata.path,
|
||||||
expect.any(Date),
|
expect.any(Date),
|
||||||
new Date(createDto.fileModifiedAt),
|
new Date(createDto.fileModifiedAt),
|
||||||
);
|
);
|
||||||
@@ -1017,31 +938,4 @@ describe(AssetMediaService.name, () => {
|
|||||||
expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
|
expect(mocks.asset.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('onUploadError', () => {
|
|
||||||
it('should queue a job to delete the uploaded file', async () => {
|
|
||||||
const request = {
|
|
||||||
body: {},
|
|
||||||
user: authStub.user1,
|
|
||||||
} as AuthRequest;
|
|
||||||
|
|
||||||
const file = {
|
|
||||||
fieldname: UploadFieldName.ASSET_DATA,
|
|
||||||
originalname: 'image.jpg',
|
|
||||||
mimetype: 'image/jpeg',
|
|
||||||
buffer: Buffer.from(''),
|
|
||||||
size: 1000,
|
|
||||||
uuid: 'random-uuid',
|
|
||||||
checksum: Buffer.from('checksum', 'utf8'),
|
|
||||||
originalPath: '/data/upload/user-id/ra/nd/random-uuid.jpg',
|
|
||||||
} as unknown as Express.Multer.File;
|
|
||||||
|
|
||||||
await sut.onUploadError(request, file);
|
|
||||||
|
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
|
||||||
name: JobName.FileDelete,
|
|
||||||
data: { files: [expect.stringContaining('/data/upload/user-id/ra/nd/random-uuid.jpg')] },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common';
|
||||||
import { extname } from 'node:path';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { extname, join } from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
import sanitize from 'sanitize-filename';
|
import sanitize from 'sanitize-filename';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { Asset } from 'src/database';
|
import { Asset } from 'src/database';
|
||||||
@@ -31,11 +33,10 @@ import {
|
|||||||
Permission,
|
Permission,
|
||||||
StorageFolder,
|
StorageFolder,
|
||||||
} from 'src/enum';
|
} from 'src/enum';
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
import { UploadedFile, UploadFile, UploadingFile, UploadMetadata } from 'src/middleware/upload.interceptor';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { UploadFile, UploadRequest } from 'src/types';
|
|
||||||
import { requireUploadAccess } from 'src/utils/access';
|
import { requireUploadAccess } from 'src/utils/access';
|
||||||
import { asUploadRequest, onBeforeLink } from 'src/utils/asset.util';
|
import { onBeforeLink } from 'src/utils/asset.util';
|
||||||
import { isAssetChecksumConstraint } from 'src/utils/database';
|
import { isAssetChecksumConstraint } from 'src/utils/database';
|
||||||
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
import { getFilenameExtension, getFileNameWithoutExtension, ImmichFileResponse } from 'src/utils/file';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
@@ -47,8 +48,8 @@ export interface AssetMediaRedirectResponse {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetMediaService extends BaseService {
|
export class AssetMediaService extends BaseService {
|
||||||
async getUploadAssetIdByChecksum(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
|
async onBeforeUpload(auth: AuthDto, checksum?: string): Promise<AssetMediaResponseDto | undefined> {
|
||||||
if (!checksum) {
|
if (!checksum || !auth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,78 +61,56 @@ export class AssetMediaService extends BaseService {
|
|||||||
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
|
return { id: assetId, status: AssetMediaStatus.DUPLICATE };
|
||||||
}
|
}
|
||||||
|
|
||||||
canUploadFile({ auth, fieldName, file, body }: UploadRequest): true {
|
canUpload(auth: AuthDto, file: UploadFile): true {
|
||||||
requireUploadAccess(auth);
|
requireUploadAccess(auth);
|
||||||
|
|
||||||
const filename = body.filename || file.originalName;
|
if (
|
||||||
|
(file.fieldName === UploadFieldName.ASSET_DATA && mimeTypes.isAsset(file.originalName)) ||
|
||||||
switch (fieldName) {
|
(file.fieldName === UploadFieldName.SIDECAR_DATA && mimeTypes.isSidecar(file.originalName))
|
||||||
case UploadFieldName.ASSET_DATA: {
|
) {
|
||||||
if (mimeTypes.isAsset(filename)) {
|
return true;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case UploadFieldName.SIDECAR_DATA: {
|
|
||||||
if (mimeTypes.isSidecar(filename)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case UploadFieldName.PROFILE_DATA: {
|
|
||||||
if (mimeTypes.isProfile(filename)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error(`Unsupported file type ${filename}`);
|
this.logger.error(`Unsupported file type ${file.originalName}`);
|
||||||
throw new BadRequestException(`Unsupported file type ${filename}`);
|
throw new BadRequestException(`Unsupported file type ${file.originalName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
getUploadFilename({ auth, fieldName, file, body }: UploadRequest): string {
|
async onUpload(auth: AuthDto, file: UploadingFile): Promise<UploadMetadata> {
|
||||||
requireUploadAccess(auth);
|
const stream = file.stream;
|
||||||
|
let checksum: Buffer | undefined;
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
const extension = extname(body.filename || file.originalName);
|
const hash = createHash('sha1');
|
||||||
|
|
||||||
const lookup = {
|
stream
|
||||||
[UploadFieldName.ASSET_DATA]: extension,
|
.on('data', (chunk: Buffer) => {
|
||||||
[UploadFieldName.SIDECAR_DATA]: '.xmp',
|
hash.update(chunk);
|
||||||
[UploadFieldName.PROFILE_DATA]: extension,
|
size += chunk.length;
|
||||||
};
|
})
|
||||||
|
.on('end', () => (checksum = hash.digest()))
|
||||||
|
.on('error', () => hash.destroy());
|
||||||
|
|
||||||
return sanitize(`${file.uuid}${lookup[fieldName]}`);
|
const extension = file.fieldName === UploadFieldName.ASSET_DATA ? extname(file.originalName) : '.xmp';
|
||||||
}
|
const filename = sanitize(`${file.requestId}${extension}`);
|
||||||
|
const folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, filename);
|
||||||
getUploadFolder({ auth, fieldName, file }: UploadRequest): string {
|
const path = join(folder, filename);
|
||||||
auth = requireUploadAccess(auth);
|
|
||||||
|
|
||||||
let folder = StorageCore.getNestedFolder(StorageFolder.Upload, auth.user.id, file.uuid);
|
|
||||||
if (fieldName === UploadFieldName.PROFILE_DATA) {
|
|
||||||
folder = StorageCore.getFolderLocation(StorageFolder.Profile, auth.user.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.storageRepository.mkdirSync(folder);
|
this.storageRepository.mkdirSync(folder);
|
||||||
|
|
||||||
return folder;
|
await pipeline(stream, this.storageRepository.createWriteStream(path));
|
||||||
|
|
||||||
|
return { filename, folder, path, checksum, size };
|
||||||
}
|
}
|
||||||
|
|
||||||
async onUploadError(request: AuthRequest, file: Express.Multer.File) {
|
async onUploadRemove(auth: AuthDto, file: UploadedFile): Promise<void> {
|
||||||
const uploadFilename = this.getUploadFilename(asUploadRequest(request, file));
|
await this.storageRepository.unlink(file.metadata.path);
|
||||||
const uploadFolder = this.getUploadFolder(asUploadRequest(request, file));
|
|
||||||
const uploadPath = `${uploadFolder}/${uploadFilename}`;
|
|
||||||
|
|
||||||
await this.jobRepository.queue({ name: JobName.FileDelete, data: { files: [uploadPath] } });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async uploadAsset(
|
async uploadAsset(
|
||||||
auth: AuthDto,
|
auth: AuthDto,
|
||||||
dto: AssetMediaCreateDto,
|
dto: AssetMediaCreateDto,
|
||||||
file: UploadFile,
|
file: UploadedFile,
|
||||||
sidecarFile?: UploadFile,
|
sidecarFile?: UploadedFile,
|
||||||
): Promise<AssetMediaResponseDto> {
|
): Promise<AssetMediaResponseDto> {
|
||||||
try {
|
try {
|
||||||
await this.requireAccess({
|
await this.requireAccess({
|
||||||
@@ -141,7 +120,7 @@ export class AssetMediaService extends BaseService {
|
|||||||
ids: [auth.user.id],
|
ids: [auth.user.id],
|
||||||
});
|
});
|
||||||
|
|
||||||
this.requireQuota(auth, file.size);
|
this.requireQuota(auth, file.metadata.size);
|
||||||
|
|
||||||
if (dto.livePhotoVideoId) {
|
if (dto.livePhotoVideoId) {
|
||||||
await onBeforeLink(
|
await onBeforeLink(
|
||||||
@@ -151,7 +130,7 @@ export class AssetMediaService extends BaseService {
|
|||||||
}
|
}
|
||||||
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
const asset = await this.create(auth.user.id, dto, file, sidecarFile);
|
||||||
|
|
||||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
await this.userRepository.updateUsage(auth.user.id, file.metadata.size);
|
||||||
|
|
||||||
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
return { id: asset.id, status: AssetMediaStatus.CREATED };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -163,8 +142,8 @@ export class AssetMediaService extends BaseService {
|
|||||||
auth: AuthDto,
|
auth: AuthDto,
|
||||||
id: string,
|
id: string,
|
||||||
dto: AssetMediaReplaceDto,
|
dto: AssetMediaReplaceDto,
|
||||||
file: UploadFile,
|
file: UploadedFile,
|
||||||
sidecarFile?: UploadFile,
|
sidecarFile?: UploadedFile,
|
||||||
): Promise<AssetMediaResponseDto> {
|
): Promise<AssetMediaResponseDto> {
|
||||||
try {
|
try {
|
||||||
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
|
await this.requireAccess({ auth, permission: Permission.AssetUpdate, ids: [id] });
|
||||||
@@ -174,9 +153,9 @@ export class AssetMediaService extends BaseService {
|
|||||||
throw new Error('Asset not found');
|
throw new Error('Asset not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.requireQuota(auth, file.size);
|
this.requireQuota(auth, file.metadata.size);
|
||||||
|
|
||||||
await this.replaceFileData(asset.id, dto, file, sidecarFile?.originalPath);
|
await this.replaceFileData(asset.id, dto, file, sidecarFile?.metadata.path);
|
||||||
|
|
||||||
// Next, create a backup copy of the existing record. The db record has already been updated above,
|
// Next, create a backup copy of the existing record. The db record has already been updated above,
|
||||||
// but the local variable holds the original file data paths.
|
// but the local variable holds the original file data paths.
|
||||||
@@ -185,7 +164,7 @@ export class AssetMediaService extends BaseService {
|
|||||||
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
|
await this.assetRepository.updateAll([copiedPhoto.id], { deletedAt: new Date(), status: AssetStatus.Trashed });
|
||||||
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id });
|
await this.eventRepository.emit('AssetTrash', { assetId: copiedPhoto.id, userId: auth.user.id });
|
||||||
|
|
||||||
await this.userRepository.updateUsage(auth.user.id, file.size);
|
await this.userRepository.updateUsage(auth.user.id, file.metadata.size);
|
||||||
|
|
||||||
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
|
return { status: AssetMediaStatus.REPLACED, id: copiedPhoto.id };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -325,18 +304,18 @@ export class AssetMediaService extends BaseService {
|
|||||||
private async handleUploadError(
|
private async handleUploadError(
|
||||||
error: any,
|
error: any,
|
||||||
auth: AuthDto,
|
auth: AuthDto,
|
||||||
file: UploadFile,
|
file: UploadedFile,
|
||||||
sidecarFile?: UploadFile,
|
sidecarFile?: UploadedFile,
|
||||||
): Promise<AssetMediaResponseDto> {
|
): Promise<AssetMediaResponseDto> {
|
||||||
// clean up files
|
// clean up files
|
||||||
await this.jobRepository.queue({
|
await this.jobRepository.queue({
|
||||||
name: JobName.FileDelete,
|
name: JobName.FileDelete,
|
||||||
data: { files: [file.originalPath, sidecarFile?.originalPath] },
|
data: { files: [file.metadata.path, sidecarFile?.metadata.path] },
|
||||||
});
|
});
|
||||||
|
|
||||||
// handle duplicates with a success response
|
// handle duplicates with a success response
|
||||||
if (isAssetChecksumConstraint(error)) {
|
if (isAssetChecksumConstraint(error)) {
|
||||||
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.checksum);
|
const duplicateId = await this.assetRepository.getUploadAssetIdByChecksum(auth.user.id, file.metadata.checksum!);
|
||||||
if (!duplicateId) {
|
if (!duplicateId) {
|
||||||
this.logger.error(`Error locating duplicate for checksum constraint`);
|
this.logger.error(`Error locating duplicate for checksum constraint`);
|
||||||
throw new InternalServerErrorException();
|
throw new InternalServerErrorException();
|
||||||
@@ -358,15 +337,15 @@ export class AssetMediaService extends BaseService {
|
|||||||
private async replaceFileData(
|
private async replaceFileData(
|
||||||
assetId: string,
|
assetId: string,
|
||||||
dto: AssetMediaReplaceDto,
|
dto: AssetMediaReplaceDto,
|
||||||
file: UploadFile,
|
file: UploadedFile,
|
||||||
sidecarPath?: string,
|
sidecarPath?: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.assetRepository.update({
|
await this.assetRepository.update({
|
||||||
id: assetId,
|
id: assetId,
|
||||||
|
|
||||||
checksum: file.checksum,
|
checksum: file.metadata.checksum,
|
||||||
originalPath: file.originalPath,
|
originalPath: file.metadata.path,
|
||||||
type: mimeTypes.assetType(file.originalPath),
|
type: mimeTypes.assetType(file.metadata.path),
|
||||||
originalFileName: file.originalName,
|
originalFileName: file.originalName,
|
||||||
|
|
||||||
deviceAssetId: dto.deviceAssetId,
|
deviceAssetId: dto.deviceAssetId,
|
||||||
@@ -383,9 +362,9 @@ export class AssetMediaService extends BaseService {
|
|||||||
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
|
? this.assetRepository.upsertFile({ assetId, type: AssetFileType.Sidecar, path: sidecarPath })
|
||||||
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
|
: this.assetRepository.deleteFile({ assetId, type: AssetFileType.Sidecar }));
|
||||||
|
|
||||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
await this.storageRepository.utimes(file.metadata.path, new Date(), new Date(dto.fileModifiedAt));
|
||||||
await this.assetRepository.upsertExif(
|
await this.assetRepository.upsertExif(
|
||||||
{ assetId, fileSizeInByte: file.size },
|
{ assetId, fileSizeInByte: file.metadata.size },
|
||||||
{ lockedPropertiesBehavior: 'override' },
|
{ lockedPropertiesBehavior: 'override' },
|
||||||
);
|
);
|
||||||
await this.jobRepository.queue({
|
await this.jobRepository.queue({
|
||||||
@@ -424,13 +403,13 @@ export class AssetMediaService extends BaseService {
|
|||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadFile, sidecarFile?: UploadFile) {
|
private async create(ownerId: string, dto: AssetMediaCreateDto, file: UploadedFile, sidecarFile?: UploadedFile) {
|
||||||
const asset = await this.assetRepository.create({
|
const asset = await this.assetRepository.create({
|
||||||
ownerId,
|
ownerId,
|
||||||
libraryId: null,
|
libraryId: null,
|
||||||
|
|
||||||
checksum: file.checksum,
|
checksum: file.metadata.checksum!,
|
||||||
originalPath: file.originalPath,
|
originalPath: file.metadata.path,
|
||||||
|
|
||||||
deviceAssetId: dto.deviceAssetId,
|
deviceAssetId: dto.deviceAssetId,
|
||||||
deviceId: dto.deviceId,
|
deviceId: dto.deviceId,
|
||||||
@@ -439,7 +418,7 @@ export class AssetMediaService extends BaseService {
|
|||||||
fileModifiedAt: dto.fileModifiedAt,
|
fileModifiedAt: dto.fileModifiedAt,
|
||||||
localDateTime: dto.fileCreatedAt,
|
localDateTime: dto.fileCreatedAt,
|
||||||
|
|
||||||
type: mimeTypes.assetType(file.originalPath),
|
type: mimeTypes.assetType(file.metadata.path),
|
||||||
isFavorite: dto.isFavorite,
|
isFavorite: dto.isFavorite,
|
||||||
duration: dto.duration || null,
|
duration: dto.duration || null,
|
||||||
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
visibility: dto.visibility ?? AssetVisibility.Timeline,
|
||||||
@@ -454,14 +433,14 @@ export class AssetMediaService extends BaseService {
|
|||||||
if (sidecarFile) {
|
if (sidecarFile) {
|
||||||
await this.assetRepository.upsertFile({
|
await this.assetRepository.upsertFile({
|
||||||
assetId: asset.id,
|
assetId: asset.id,
|
||||||
path: sidecarFile.originalPath,
|
path: sidecarFile.metadata.path,
|
||||||
type: AssetFileType.Sidecar,
|
type: AssetFileType.Sidecar,
|
||||||
});
|
});
|
||||||
await this.storageRepository.utimes(sidecarFile.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
await this.storageRepository.utimes(sidecarFile.metadata.path, new Date(), new Date(dto.fileModifiedAt));
|
||||||
}
|
}
|
||||||
await this.storageRepository.utimes(file.originalPath, new Date(), new Date(dto.fileModifiedAt));
|
await this.storageRepository.utimes(file.metadata.path, new Date(), new Date(dto.fileModifiedAt));
|
||||||
await this.assetRepository.upsertExif(
|
await this.assetRepository.upsertExif(
|
||||||
{ assetId: asset.id, fileSizeInByte: file.size },
|
{ assetId: asset.id, fileSizeInByte: file.metadata.size },
|
||||||
{ lockedPropertiesBehavior: 'override' },
|
{ lockedPropertiesBehavior: 'override' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Updateable } from 'kysely';
|
import { Updateable } from 'kysely';
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
|
import { extname, join } from 'node:path';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
|
import sanitize from 'sanitize-filename';
|
||||||
import { SALT_ROUNDS } from 'src/constants';
|
import { SALT_ROUNDS } from 'src/constants';
|
||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { OnJob } from 'src/decorators';
|
import { OnJob } from 'src/decorators';
|
||||||
@@ -11,15 +14,41 @@ import { UserPreferencesResponseDto, UserPreferencesUpdateDto, mapPreferences }
|
|||||||
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
import { CreateProfileImageResponseDto } from 'src/dtos/user-profile.dto';
|
||||||
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
import { UserAdminResponseDto, UserResponseDto, UserUpdateMeDto, mapUser, mapUserAdmin } from 'src/dtos/user.dto';
|
||||||
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
import { CacheControl, JobName, JobStatus, QueueName, StorageFolder, UserMetadataKey } from 'src/enum';
|
||||||
|
import { UploadFile, UploadMetadata, UploadedFile, UploadingFile } from 'src/middleware/upload.interceptor';
|
||||||
import { UserFindOptions } from 'src/repositories/user.repository';
|
import { UserFindOptions } from 'src/repositories/user.repository';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
import { BaseService } from 'src/services/base.service';
|
import { BaseService } from 'src/services/base.service';
|
||||||
import { JobOf, UserMetadataItem } from 'src/types';
|
import { JobOf, UserMetadataItem } from 'src/types';
|
||||||
import { ImmichFileResponse } from 'src/utils/file';
|
import { ImmichFileResponse } from 'src/utils/file';
|
||||||
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/utils/preferences';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UserService extends BaseService {
|
export class UserService extends BaseService {
|
||||||
|
canUpload(auth: AuthDto, file: UploadFile) {
|
||||||
|
return mimeTypes.isProfile(file.originalName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onUpload(auth: AuthDto, file: UploadingFile): Promise<UploadMetadata> {
|
||||||
|
const extension = extname(file.originalName);
|
||||||
|
const filename = sanitize(`${file.requestId}${extension}`);
|
||||||
|
const folder = StorageCore.getNestedFolder(StorageFolder.Profile, auth.user.id, filename);
|
||||||
|
const path = join(folder, filename);
|
||||||
|
|
||||||
|
this.storageRepository.mkdirSync(folder);
|
||||||
|
|
||||||
|
let size = 0;
|
||||||
|
file.stream.on('data', (chunk: Buffer) => (size += chunk.length));
|
||||||
|
|
||||||
|
await pipeline(file.stream, this.storageRepository.createWriteStream(path));
|
||||||
|
|
||||||
|
return { filename, folder, path, size };
|
||||||
|
}
|
||||||
|
|
||||||
|
async onUploadRemove(auth: AuthDto, file: UploadedFile) {
|
||||||
|
await this.storageRepository.unlink(file.metadata.path);
|
||||||
|
}
|
||||||
|
|
||||||
async search(auth: AuthDto): Promise<UserResponseDto[]> {
|
async search(auth: AuthDto): Promise<UserResponseDto[]> {
|
||||||
const config = await this.getConfig({ withCache: false });
|
const config = await this.getConfig({ withCache: false });
|
||||||
|
|
||||||
@@ -90,11 +119,11 @@ export class UserService extends BaseService {
|
|||||||
return mapUser(user);
|
return mapUser(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createProfileImage(auth: AuthDto, file: Express.Multer.File): Promise<CreateProfileImageResponseDto> {
|
async createProfileImage(auth: AuthDto, file: UploadedFile): Promise<CreateProfileImageResponseDto> {
|
||||||
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
const { profileImagePath: oldpath } = await this.findOrFail(auth.user.id, { withDeleted: false });
|
||||||
|
|
||||||
const user = await this.userRepository.update(auth.user.id, {
|
const user = await this.userRepository.update(auth.user.id, {
|
||||||
profileImagePath: file.path,
|
profileImagePath: file.metadata.path,
|
||||||
profileChangedAt: new Date(),
|
profileChangedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { VECTOR_EXTENSIONS } from 'src/constants';
|
import { VECTOR_EXTENSIONS } from 'src/constants';
|
||||||
import { Asset, AssetFile } from 'src/database';
|
import { Asset, AssetFile } from 'src/database';
|
||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
|
||||||
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
import { AssetEditActionItem } from 'src/dtos/editing.dto';
|
||||||
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
import { SetMaintenanceModeDto } from 'src/dtos/maintenance.dto';
|
||||||
import {
|
import {
|
||||||
@@ -420,37 +418,6 @@ export interface VectorUpdateResult {
|
|||||||
restartRequired: boolean;
|
restartRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImmichFile extends Express.Multer.File {
|
|
||||||
uuid: string;
|
|
||||||
/** sha1 hash of file */
|
|
||||||
checksum: Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadFile {
|
|
||||||
uuid: string;
|
|
||||||
checksum: Buffer;
|
|
||||||
originalPath: string;
|
|
||||||
originalName: string;
|
|
||||||
size: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadBody {
|
|
||||||
filename?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type UploadRequest = {
|
|
||||||
auth: AuthDto | null;
|
|
||||||
fieldName: UploadFieldName;
|
|
||||||
file: UploadFile;
|
|
||||||
body: UploadBody;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface UploadFiles {
|
|
||||||
assetData: ImmichFile[];
|
|
||||||
sidecarData: ImmichFile[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IBulkAsset {
|
export interface IBulkAsset {
|
||||||
getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>;
|
getAssetIds: (id: string, assetIds: string[]) => Promise<Set<string>>;
|
||||||
addAssetIds: (id: string, assetIds: string[]) => Promise<void>;
|
addAssetIds: (id: string, assetIds: string[]) => Promise<void>;
|
||||||
|
|||||||
@@ -2,16 +2,14 @@ import { BadRequestException } from '@nestjs/common';
|
|||||||
import { StorageCore } from 'src/cores/storage.core';
|
import { StorageCore } from 'src/cores/storage.core';
|
||||||
import { AssetFile, Exif } from 'src/database';
|
import { AssetFile, Exif } from 'src/database';
|
||||||
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
import { BulkIdErrorReason, BulkIdResponseDto } from 'src/dtos/asset-ids.response.dto';
|
||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { ExifResponseDto } from 'src/dtos/exif.dto';
|
import { ExifResponseDto } from 'src/dtos/exif.dto';
|
||||||
import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
|
import { AssetFileType, AssetType, AssetVisibility, Permission } from 'src/enum';
|
||||||
import { AuthRequest } from 'src/middleware/auth.guard';
|
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
import { EventRepository } from 'src/repositories/event.repository';
|
import { EventRepository } from 'src/repositories/event.repository';
|
||||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||||
import { IBulkAsset, ImmichFile, UploadFile, UploadRequest } from 'src/types';
|
import { IBulkAsset } from 'src/types';
|
||||||
import { checkAccess } from 'src/utils/access';
|
import { checkAccess } from 'src/utils/access';
|
||||||
|
|
||||||
export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => {
|
export const getAssetFile = (files: AssetFile[], type: AssetFileType, { isEdited }: { isEdited: boolean }) => {
|
||||||
@@ -186,25 +184,6 @@ export const onAfterUnlink = async (
|
|||||||
await eventRepository.emit('AssetShow', { assetId: livePhotoVideoId, userId });
|
await eventRepository.emit('AssetShow', { assetId: livePhotoVideoId, userId });
|
||||||
};
|
};
|
||||||
|
|
||||||
export function mapToUploadFile(file: ImmichFile): UploadFile {
|
|
||||||
return {
|
|
||||||
uuid: file.uuid,
|
|
||||||
checksum: file.checksum,
|
|
||||||
originalPath: file.path,
|
|
||||||
originalName: Buffer.from(file.originalname, 'latin1').toString('utf8'),
|
|
||||||
size: file.size,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const asUploadRequest = (request: AuthRequest, file: Express.Multer.File): UploadRequest => {
|
|
||||||
return {
|
|
||||||
auth: request.user || null,
|
|
||||||
body: request.body,
|
|
||||||
fieldName: file.fieldname as UploadFieldName,
|
|
||||||
file: mapToUploadFile(file as ImmichFile),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const isFlipped = (orientation?: string | null) => {
|
const isFlipped = (orientation?: string | null) => {
|
||||||
const value = Number(orientation);
|
const value = Number(orientation);
|
||||||
return value && [5, 6, 7, 8, -90, 90].includes(value);
|
return value && [5, 6, 7, 8, -90, 90].includes(value);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import postgres from 'postgres';
|
|||||||
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
import { UploadFieldName } from 'src/dtos/asset-media.dto';
|
||||||
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
import { AssetUploadInterceptor } from 'src/middleware/asset-upload.interceptor';
|
||||||
import { AuthGuard } from 'src/middleware/auth.guard';
|
import { AuthGuard } from 'src/middleware/auth.guard';
|
||||||
import { FileUploadInterceptor } from 'src/middleware/file-upload.interceptor';
|
|
||||||
import { AccessRepository } from 'src/repositories/access.repository';
|
import { AccessRepository } from 'src/repositories/access.repository';
|
||||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||||
@@ -120,7 +119,7 @@ export const controllerSetup = async (controller: ClassConstructor<unknown>, pro
|
|||||||
...providers,
|
...providers,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
.overrideInterceptor(FileUploadInterceptor)
|
.overrideInterceptor(AssetUploadInterceptor)
|
||||||
.useValue(memoryFileInterceptor)
|
.useValue(memoryFileInterceptor)
|
||||||
.overrideInterceptor(AssetUploadInterceptor)
|
.overrideInterceptor(AssetUploadInterceptor)
|
||||||
.useValue(noopInterceptor)
|
.useValue(noopInterceptor)
|
||||||
|
|||||||
Reference in New Issue
Block a user