mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(server): split generated content into a separate folder (#2047)
* feat: organize media folders * fix: tests
This commit is contained in:
		| @@ -275,7 +275,7 @@ describe('AssetService', () => { | ||||
|       expect(assetRepositoryMock.create).toHaveBeenCalled(); | ||||
|       expect(assetRepositoryMock.save).toHaveBeenCalledWith({ | ||||
|         id: 'id_1', | ||||
|         originalPath: 'upload/user_id_1/2022/2022-06-19/asset_1.jpeg', | ||||
|         originalPath: 'upload/library/user_id_1/2022/2022-06-19/asset_1.jpeg', | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -137,16 +137,7 @@ describe('assetUploadOption', () => { | ||||
|       destination(mock.userRequest, mock.file, callback); | ||||
|  | ||||
|       expect(mkdirSync).not.toHaveBeenCalled(); | ||||
|       expect(callback).toHaveBeenCalledWith(null, 'upload/test-user/original/test-device'); | ||||
|     }); | ||||
|  | ||||
|     it('should sanitize the deviceId', () => { | ||||
|       const request = { ...mock.userRequest, body: { deviceId: 'test-devi\u0000ce' } } as Request; | ||||
|       destination(request, mock.file, callback); | ||||
|  | ||||
|       const [folderName] = existsSync.mock.calls[0]; | ||||
|       expect(folderName.endsWith('test-device')).toBeTruthy(); | ||||
|       expect(callback).toHaveBeenCalledWith(null, 'upload/test-user/original/test-device'); | ||||
|       expect(callback).toHaveBeenCalledWith(null, 'upload/upload/test-user'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant'; | ||||
| import { StorageCore, StorageFolder } from '@app/domain/storage'; | ||||
| import { BadRequestException, Logger, UnauthorizedException } from '@nestjs/common'; | ||||
| import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; | ||||
| import { createHash, randomUUID } from 'crypto'; | ||||
| import { Request } from 'express'; | ||||
| import { existsSync, mkdirSync } from 'fs'; | ||||
| import { diskStorage, StorageEngine } from 'multer'; | ||||
| import { extname, join } from 'path'; | ||||
| import { extname } from 'path'; | ||||
| import sanitize from 'sanitize-filename'; | ||||
| import { AuthUserDto } from '../decorators/auth-user.decorator'; | ||||
| import { patchFormData } from '../utils/path-form-data.util'; | ||||
| @@ -20,6 +20,8 @@ export const assetUploadOption: MulterOptions = { | ||||
|   storage: customStorage(), | ||||
| }; | ||||
|  | ||||
| const storageCore = new StorageCore(); | ||||
|  | ||||
| export function customStorage(): StorageEngine { | ||||
|   const storage = diskStorage({ destination, filename }); | ||||
|  | ||||
| @@ -71,16 +73,13 @@ function destination(req: Request, file: Express.Multer.File, cb: any) { | ||||
|  | ||||
|   const user = req.user as AuthUserDto; | ||||
|  | ||||
|   const basePath = APP_UPLOAD_LOCATION; | ||||
|   const sanitizedDeviceId = sanitize(String(req.body['deviceId'])); | ||||
|   const originalUploadFolder = join(basePath, user.id, 'original', sanitizedDeviceId); | ||||
|  | ||||
|   if (!existsSync(originalUploadFolder)) { | ||||
|     mkdirSync(originalUploadFolder, { recursive: true }); | ||||
|   const uploadFolder = storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id); | ||||
|   if (!existsSync(uploadFolder)) { | ||||
|     mkdirSync(uploadFolder, { recursive: true }); | ||||
|   } | ||||
|  | ||||
|   // Save original to disk | ||||
|   cb(null, originalUploadFolder); | ||||
|   cb(null, uploadFolder); | ||||
| } | ||||
|  | ||||
| function filename(req: Request, file: Express.Multer.File, cb: any) { | ||||
|   | ||||
| @@ -85,7 +85,7 @@ describe('profileImageUploadOption', () => { | ||||
|       destination(mock.userRequest, mock.file, callback); | ||||
|  | ||||
|       expect(mkdirSync).not.toHaveBeenCalled(); | ||||
|       expect(callback).toHaveBeenCalledWith(null, './upload/test-user/profile'); | ||||
|       expect(callback).toHaveBeenCalledWith(null, 'upload/profile/test-user'); | ||||
|     }); | ||||
|   }); | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| import { APP_UPLOAD_LOCATION } from '@app/domain/domain.constant'; | ||||
| import { StorageCore, StorageFolder } from '@app/domain/storage'; | ||||
| import { BadRequestException, UnauthorizedException } from '@nestjs/common'; | ||||
| import { MulterOptions } from '@nestjs/platform-express/multer/interfaces/multer-options.interface'; | ||||
| import { Request } from 'express'; | ||||
| @@ -19,6 +19,8 @@ export const profileImageUploadOption: MulterOptions = { | ||||
|  | ||||
| export const multerUtils = { fileFilter, filename, destination }; | ||||
|  | ||||
| const storageCore = new StorageCore(); | ||||
|  | ||||
| function fileFilter(req: Request, file: any, cb: any) { | ||||
|   if (!req.user) { | ||||
|     return cb(new UnauthorizedException()); | ||||
| @@ -38,9 +40,7 @@ function destination(req: Request, file: Express.Multer.File, cb: any) { | ||||
|  | ||||
|   const user = req.user as AuthUserDto; | ||||
|  | ||||
|   const basePath = APP_UPLOAD_LOCATION; | ||||
|   const profileImageLocation = `${basePath}/${user.id}/profile`; | ||||
|  | ||||
|   const profileImageLocation = storageCore.getFolderLocation(StorageFolder.PROFILE, user.id); | ||||
|   if (!existsSync(profileImageLocation)) { | ||||
|     mkdirSync(profileImageLocation, { recursive: true }); | ||||
|   } | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| import { | ||||
|   APP_UPLOAD_LOCATION, | ||||
|   IAssetJob, | ||||
|   IAssetRepository, | ||||
|   IBaseJob, | ||||
|   IJobRepository, | ||||
|   IStorageRepository, | ||||
|   JobName, | ||||
|   QueueName, | ||||
|   StorageCore, | ||||
|   StorageFolder, | ||||
|   SystemConfigService, | ||||
|   WithoutProperty, | ||||
| } from '@app/domain'; | ||||
| @@ -14,15 +16,18 @@ import { Process, Processor } from '@nestjs/bull'; | ||||
| import { Inject, Logger } from '@nestjs/common'; | ||||
| import { Job } from 'bull'; | ||||
| import ffmpeg, { FfprobeData } from 'fluent-ffmpeg'; | ||||
| import { existsSync, mkdirSync } from 'fs'; | ||||
| import { join } from 'path'; | ||||
|  | ||||
| @Processor(QueueName.VIDEO_CONVERSION) | ||||
| export class VideoTranscodeProcessor { | ||||
|   readonly logger = new Logger(VideoTranscodeProcessor.name); | ||||
|   private storageCore = new StorageCore(); | ||||
|  | ||||
|   constructor( | ||||
|     @Inject(IAssetRepository) private assetRepository: IAssetRepository, | ||||
|     @Inject(IJobRepository) private jobRepository: IJobRepository, | ||||
|     private systemConfigService: SystemConfigService, | ||||
|     @Inject(IStorageRepository) private storageRepository: IStorageRepository, | ||||
|   ) {} | ||||
|  | ||||
|   @Process({ name: JobName.QUEUE_VIDEO_CONVERSION, concurrency: 1 }) | ||||
| @@ -43,14 +48,12 @@ export class VideoTranscodeProcessor { | ||||
|   @Process({ name: JobName.VIDEO_CONVERSION, concurrency: 2 }) | ||||
|   async handleVideoConversion(job: Job<IAssetJob>) { | ||||
|     const { asset } = job.data; | ||||
|     const basePath = APP_UPLOAD_LOCATION; | ||||
|     const encodedVideoPath = `${basePath}/${asset.ownerId}/encoded-video`; | ||||
|  | ||||
|     if (!existsSync(encodedVideoPath)) { | ||||
|       mkdirSync(encodedVideoPath, { recursive: true }); | ||||
|     } | ||||
|     const encodedVideoPath = this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, asset.ownerId); | ||||
|  | ||||
|     const savedEncodedPath = `${encodedVideoPath}/${asset.id}.mp4`; | ||||
|     this.storageRepository.mkdirSync(encodedVideoPath); | ||||
|  | ||||
|     const savedEncodedPath = join(encodedVideoPath, `${asset.id}.mp4`); | ||||
|  | ||||
|     await this.runVideoEncode(asset, savedEncodedPath); | ||||
|   } | ||||
|   | ||||
| @@ -17,7 +17,7 @@ export const serverVersion: IServerVersion = { | ||||
|  | ||||
| export const SERVER_VERSION = `${serverVersion.major}.${serverVersion.minor}.${serverVersion.patch}`; | ||||
|  | ||||
| export const APP_UPLOAD_LOCATION = './upload'; | ||||
| export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload'; | ||||
|  | ||||
| export const MACHINE_LEARNING_URL = process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003'; | ||||
| export const MACHINE_LEARNING_ENABLED = MACHINE_LEARNING_URL !== 'false'; | ||||
|   | ||||
| @@ -75,16 +75,15 @@ describe(MediaService.name, () => { | ||||
|     it('should generate a thumbnail for an image', async () => { | ||||
|       await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.image) }); | ||||
|  | ||||
|       expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/user-id/thumb/device-id'); | ||||
|       expect(mediaMock.resize).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/user-id/thumb/device-id/asset-id.jpeg', | ||||
|         { size: 1440, format: 'jpeg' }, | ||||
|       ); | ||||
|       expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); | ||||
|       expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', { | ||||
|         size: 1440, | ||||
|         format: 'jpeg', | ||||
|       }); | ||||
|       expect(mediaMock.extractThumbnailFromExif).not.toHaveBeenCalled(); | ||||
|       expect(assetMock.save).toHaveBeenCalledWith({ | ||||
|         id: 'asset-id', | ||||
|         resizePath: 'upload/user-id/thumb/device-id/asset-id.jpeg', | ||||
|         resizePath: 'upload/thumbs/user-id/asset-id.jpeg', | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
| @@ -93,33 +92,32 @@ describe(MediaService.name, () => { | ||||
|  | ||||
|       await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.image) }); | ||||
|  | ||||
|       expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/user-id/thumb/device-id'); | ||||
|       expect(mediaMock.resize).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/user-id/thumb/device-id/asset-id.jpeg', | ||||
|         { size: 1440, format: 'jpeg' }, | ||||
|       ); | ||||
|       expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); | ||||
|       expect(mediaMock.resize).toHaveBeenCalledWith('/original/path.ext', 'upload/thumbs/user-id/asset-id.jpeg', { | ||||
|         size: 1440, | ||||
|         format: 'jpeg', | ||||
|       }); | ||||
|       expect(mediaMock.extractThumbnailFromExif).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/user-id/thumb/device-id/asset-id.jpeg', | ||||
|         'upload/thumbs/user-id/asset-id.jpeg', | ||||
|       ); | ||||
|       expect(assetMock.save).toHaveBeenCalledWith({ | ||||
|         id: 'asset-id', | ||||
|         resizePath: 'upload/user-id/thumb/device-id/asset-id.jpeg', | ||||
|         resizePath: 'upload/thumbs/user-id/asset-id.jpeg', | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|     it('should generate a thumbnail for a video', async () => { | ||||
|       await sut.handleGenerateJpegThumbnail({ asset: _.cloneDeep(assetEntityStub.video) }); | ||||
|  | ||||
|       expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/user-id/thumb/device-id'); | ||||
|       expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs/user-id'); | ||||
|       expect(mediaMock.extractVideoThumbnail).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/user-id/thumb/device-id/asset-id.jpeg', | ||||
|         'upload/thumbs/user-id/asset-id.jpeg', | ||||
|       ); | ||||
|       expect(assetMock.save).toHaveBeenCalledWith({ | ||||
|         id: 'asset-id', | ||||
|         resizePath: 'upload/user-id/thumb/device-id/asset-id.jpeg', | ||||
|         resizePath: 'upload/thumbs/user-id/asset-id.jpeg', | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -1,17 +1,16 @@ | ||||
| import { AssetType } from '@app/infra/db/entities'; | ||||
| import { Inject, Injectable, Logger } from '@nestjs/common'; | ||||
| import { join } from 'path'; | ||||
| import sanitize from 'sanitize-filename'; | ||||
| import { IAssetRepository, mapAsset, WithoutProperty } from '../asset'; | ||||
| import { CommunicationEvent, ICommunicationRepository } from '../communication'; | ||||
| import { APP_UPLOAD_LOCATION } from '../domain.constant'; | ||||
| import { IAssetJob, IBaseJob, IJobRepository, JobName } from '../job'; | ||||
| import { IStorageRepository } from '../storage'; | ||||
| import { IStorageRepository, StorageCore, StorageFolder } from '../storage'; | ||||
| import { IMediaRepository } from './media.repository'; | ||||
|  | ||||
| @Injectable() | ||||
| export class MediaService { | ||||
|   private logger = new Logger(MediaService.name); | ||||
|   private storageCore = new StorageCore(); | ||||
|  | ||||
|   constructor( | ||||
|     @Inject(IAssetRepository) private assetRepository: IAssetRepository, | ||||
| @@ -41,11 +40,9 @@ export class MediaService { | ||||
|     const { asset } = data; | ||||
|  | ||||
|     try { | ||||
|       const basePath = APP_UPLOAD_LOCATION; | ||||
|       const sanitizedDeviceId = sanitize(String(asset.deviceId)); | ||||
|       const resizePath = join(basePath, asset.ownerId, 'thumb', sanitizedDeviceId); | ||||
|       const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`); | ||||
|       const resizePath = this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, asset.ownerId); | ||||
|       this.storageRepository.mkdirSync(resizePath); | ||||
|       const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`); | ||||
|  | ||||
|       if (asset.type == AssetType.IMAGE) { | ||||
|         try { | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { APP_UPLOAD_LOCATION, serverVersion } from '../domain.constant'; | ||||
| import { APP_MEDIA_LOCATION, serverVersion } from '../domain.constant'; | ||||
| import { asHumanReadable } from '../domain.util'; | ||||
| import { IStorageRepository } from '../storage'; | ||||
| import { IUserRepository, UserStatsQueryResponse } from '../user'; | ||||
| @@ -13,7 +13,7 @@ export class ServerInfoService { | ||||
|   ) {} | ||||
|  | ||||
|   async getInfo(): Promise<ServerInfoResponseDto> { | ||||
|     const diskInfo = await this.storageRepository.checkDiskUsage(APP_UPLOAD_LOCATION); | ||||
|     const diskInfo = await this.storageRepository.checkDiskUsage(APP_MEDIA_LOCATION); | ||||
|  | ||||
|     const usagePercentage = (((diskInfo.total - diskInfo.free) / diskInfo.total) * 100).toFixed(2); | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,11 @@ | ||||
| import { AssetEntity, AssetType, SystemConfig } from '@app/infra/db/entities'; | ||||
| import { Logger } from '@nestjs/common'; | ||||
| import handlebar from 'handlebars'; | ||||
| import * as luxon from 'luxon'; | ||||
| import path from 'node:path'; | ||||
| import sanitize from 'sanitize-filename'; | ||||
| import { IStorageRepository, StorageCore, StorageFolder } from '../storage'; | ||||
| import { | ||||
|   IStorageRepository, | ||||
|   ISystemConfigRepository, | ||||
|   supportedDayTokens, | ||||
|   supportedHourTokens, | ||||
| @@ -7,20 +13,14 @@ import { | ||||
|   supportedMonthTokens, | ||||
|   supportedSecondTokens, | ||||
|   supportedYearTokens, | ||||
| } from '@app/domain'; | ||||
| import { AssetEntity, AssetType, SystemConfig } from '@app/infra/db/entities'; | ||||
| import { Logger } from '@nestjs/common'; | ||||
| import handlebar from 'handlebars'; | ||||
| import * as luxon from 'luxon'; | ||||
| import path from 'node:path'; | ||||
| import sanitize from 'sanitize-filename'; | ||||
| import { APP_UPLOAD_LOCATION } from '../domain.constant'; | ||||
| } from '../system-config'; | ||||
| import { SystemConfigCore } from '../system-config/system-config.core'; | ||||
|  | ||||
| export class StorageTemplateCore { | ||||
|   private logger = new Logger(StorageTemplateCore.name); | ||||
|   private configCore: SystemConfigCore; | ||||
|   private storageTemplate: HandlebarsTemplateDelegate<any>; | ||||
|   private storageCore = new StorageCore(); | ||||
|  | ||||
|   constructor( | ||||
|     configRepository: ISystemConfigRepository, | ||||
| @@ -38,7 +38,7 @@ export class StorageTemplateCore { | ||||
|       const source = asset.originalPath; | ||||
|       const ext = path.extname(source).split('.').pop() as string; | ||||
|       const sanitized = sanitize(path.basename(filename, `.${ext}`)); | ||||
|       const rootPath = path.join(APP_UPLOAD_LOCATION, asset.ownerId); | ||||
|       const rootPath = this.storageCore.getFolderLocation(StorageFolder.LIBRARY, asset.ownerId); | ||||
|       const storagePath = this.render(this.storageTemplate, asset, sanitized, ext); | ||||
|       const fullPath = path.normalize(path.join(rootPath, storagePath)); | ||||
|       let destination = `${fullPath}.${ext}`; | ||||
|   | ||||
| @@ -42,11 +42,11 @@ describe(StorageTemplateService.name, () => { | ||||
|       assetMock.save.mockResolvedValue(assetEntityStub.image); | ||||
|  | ||||
|       when(storageMock.checkFileExists) | ||||
|         .calledWith('upload/user-id/2023/2023-02-23/asset-id.ext') | ||||
|         .calledWith('upload/library/user-id/2023/2023-02-23/asset-id.ext') | ||||
|         .mockResolvedValue(true); | ||||
|  | ||||
|       when(storageMock.checkFileExists) | ||||
|         .calledWith('upload/user-id/2023/2023-02-23/asset-id+1.ext') | ||||
|         .calledWith('upload/library/user-id/2023/2023-02-23/asset-id+1.ext') | ||||
|         .mockResolvedValue(false); | ||||
|  | ||||
|       await sut.handleTemplateMigration(); | ||||
| @@ -55,7 +55,7 @@ describe(StorageTemplateService.name, () => { | ||||
|       expect(storageMock.checkFileExists).toHaveBeenCalledTimes(2); | ||||
|       expect(assetMock.save).toHaveBeenCalledWith({ | ||||
|         id: assetEntityStub.image.id, | ||||
|         originalPath: 'upload/user-id/2023/2023-02-23/asset-id+1.ext', | ||||
|         originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
| @@ -63,7 +63,7 @@ describe(StorageTemplateService.name, () => { | ||||
|       assetMock.getAll.mockResolvedValue([ | ||||
|         { | ||||
|           ...assetEntityStub.image, | ||||
|           originalPath: 'upload/user-id/2023/2023-02-23/asset-id.ext', | ||||
|           originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext', | ||||
|         }, | ||||
|       ]); | ||||
|  | ||||
| @@ -79,7 +79,7 @@ describe(StorageTemplateService.name, () => { | ||||
|       assetMock.getAll.mockResolvedValue([ | ||||
|         { | ||||
|           ...assetEntityStub.image, | ||||
|           originalPath: 'upload/user-id/2023/2023-02-23/asset-id+1.ext', | ||||
|           originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id+1.ext', | ||||
|         }, | ||||
|       ]); | ||||
|  | ||||
| @@ -100,11 +100,11 @@ describe(StorageTemplateService.name, () => { | ||||
|       expect(assetMock.getAll).toHaveBeenCalled(); | ||||
|       expect(storageMock.moveFile).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/user-id/2023/2023-02-23/asset-id.ext', | ||||
|         'upload/library/user-id/2023/2023-02-23/asset-id.ext', | ||||
|       ); | ||||
|       expect(assetMock.save).toHaveBeenCalledWith({ | ||||
|         id: assetEntityStub.image.id, | ||||
|         originalPath: 'upload/user-id/2023/2023-02-23/asset-id.ext', | ||||
|         originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext', | ||||
|       }); | ||||
|     }); | ||||
|  | ||||
| @@ -117,7 +117,7 @@ describe(StorageTemplateService.name, () => { | ||||
|       expect(assetMock.getAll).toHaveBeenCalled(); | ||||
|       expect(storageMock.moveFile).toHaveBeenCalledWith( | ||||
|         '/original/path.ext', | ||||
|         'upload/user-id/2023/2023-02-23/asset-id.ext', | ||||
|         'upload/library/user-id/2023/2023-02-23/asset-id.ext', | ||||
|       ); | ||||
|       expect(assetMock.save).not.toHaveBeenCalled(); | ||||
|     }); | ||||
| @@ -131,11 +131,11 @@ describe(StorageTemplateService.name, () => { | ||||
|       expect(assetMock.getAll).toHaveBeenCalled(); | ||||
|       expect(assetMock.save).toHaveBeenCalledWith({ | ||||
|         id: assetEntityStub.image.id, | ||||
|         originalPath: 'upload/user-id/2023/2023-02-23/asset-id.ext', | ||||
|         originalPath: 'upload/library/user-id/2023/2023-02-23/asset-id.ext', | ||||
|       }); | ||||
|       expect(storageMock.moveFile.mock.calls).toEqual([ | ||||
|         ['/original/path.ext', 'upload/user-id/2023/2023-02-23/asset-id.ext'], | ||||
|         ['upload/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'], | ||||
|         ['/original/path.ext', 'upload/library/user-id/2023/2023-02-23/asset-id.ext'], | ||||
|         ['upload/library/user-id/2023/2023-02-23/asset-id.ext', '/original/path.ext'], | ||||
|       ]); | ||||
|     }); | ||||
|   }); | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import { AssetEntity, SystemConfig } from '@app/infra/db/entities'; | ||||
| import { Inject, Injectable, Logger } from '@nestjs/common'; | ||||
| import { IAssetRepository } from '../asset/asset.repository'; | ||||
| import { APP_UPLOAD_LOCATION } from '../domain.constant'; | ||||
| import { APP_MEDIA_LOCATION } from '../domain.constant'; | ||||
| import { IStorageRepository } from '../storage/storage.repository'; | ||||
| import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config'; | ||||
| import { StorageTemplateCore } from './storage-template.core'; | ||||
| @@ -41,7 +41,7 @@ export class StorageTemplateService { | ||||
|       } | ||||
|  | ||||
|       this.logger.debug('Cleaning up empty directories...'); | ||||
|       await this.storageRepository.removeEmptyDirs(APP_UPLOAD_LOCATION); | ||||
|       await this.storageRepository.removeEmptyDirs(APP_MEDIA_LOCATION); | ||||
|     } catch (error: any) { | ||||
|       this.logger.error('Error running template migration', error); | ||||
|     } finally { | ||||
|   | ||||
| @@ -1,2 +1,3 @@ | ||||
| export * from './storage.core'; | ||||
| export * from './storage.repository'; | ||||
| export * from './storage.service'; | ||||
|   | ||||
							
								
								
									
										16
									
								
								server/libs/domain/src/storage/storage.core.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								server/libs/domain/src/storage/storage.core.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| import { join } from 'node:path'; | ||||
| import { APP_MEDIA_LOCATION } from '../domain.constant'; | ||||
|  | ||||
| export enum StorageFolder { | ||||
|   ENCODED_VIDEO = 'encoded-video', | ||||
|   LIBRARY = 'library', | ||||
|   UPLOAD = 'upload', | ||||
|   PROFILE = 'profile', | ||||
|   THUMBNAILS = 'thumbs', | ||||
| } | ||||
|  | ||||
| export class StorageCore { | ||||
|   getFolderLocation(folder: StorageFolder, userId: string) { | ||||
|     return join(APP_MEDIA_LOCATION, folder, userId); | ||||
|   } | ||||
| } | ||||
| @@ -467,7 +467,13 @@ describe(UserService.name, () => { | ||||
|  | ||||
|       await sut.handleUserDelete({ user }); | ||||
|  | ||||
|       expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/deleted-user', { force: true, recursive: true }); | ||||
|       const options = { force: true, recursive: true }; | ||||
|  | ||||
|       expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/library/deleted-user', options); | ||||
|       expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/upload/deleted-user', options); | ||||
|       expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/profile/deleted-user', options); | ||||
|       expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/thumbs/deleted-user', options); | ||||
|       expect(storageMock.unlinkDir).toHaveBeenCalledWith('upload/encoded-video/deleted-user', options); | ||||
|       expect(tokenMock.deleteAll).toHaveBeenCalledWith(user.id); | ||||
|       expect(keyMock.deleteAll).toHaveBeenCalledWith(user.id); | ||||
|       expect(albumMock.deleteAll).toHaveBeenCalledWith(user.id); | ||||
|   | ||||
| @@ -2,14 +2,13 @@ import { UserEntity } from '@app/infra/db/entities'; | ||||
| import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common'; | ||||
| import { randomBytes } from 'crypto'; | ||||
| import { ReadStream } from 'fs'; | ||||
| import { join } from 'path'; | ||||
| import { IAlbumRepository } from '../album/album.repository'; | ||||
| import { IKeyRepository } from '../api-key/api-key.repository'; | ||||
| import { IAssetRepository } from '../asset/asset.repository'; | ||||
| import { AuthUserDto } from '../auth'; | ||||
| import { ICryptoRepository } from '../crypto/crypto.repository'; | ||||
| import { APP_UPLOAD_LOCATION } from '../domain.constant'; | ||||
| import { IJobRepository, IUserDeletionJob, JobName } from '../job'; | ||||
| import { StorageCore, StorageFolder } from '../storage'; | ||||
| import { IStorageRepository } from '../storage/storage.repository'; | ||||
| import { IUserTokenRepository } from '../user-token/user-token.repository'; | ||||
| import { IUserRepository } from '../user/user.repository'; | ||||
| @@ -28,6 +27,8 @@ import { UserCore } from './user.core'; | ||||
| export class UserService { | ||||
|   private logger = new Logger(UserService.name); | ||||
|   private userCore: UserCore; | ||||
|   private storageCore = new StorageCore(); | ||||
|  | ||||
|   constructor( | ||||
|     @Inject(IUserRepository) private userRepository: IUserRepository, | ||||
|     @Inject(ICryptoRepository) cryptoRepository: ICryptoRepository, | ||||
| @@ -162,9 +163,18 @@ export class UserService { | ||||
|     this.logger.log(`Deleting user: ${user.id}`); | ||||
|  | ||||
|     try { | ||||
|       const userAssetDir = join(APP_UPLOAD_LOCATION, user.id); | ||||
|       this.logger.warn(`Removing user from filesystem: ${userAssetDir}`); | ||||
|       await this.storageRepository.unlinkDir(userAssetDir, { recursive: true, force: true }); | ||||
|       const folders = [ | ||||
|         this.storageCore.getFolderLocation(StorageFolder.LIBRARY, user.id), | ||||
|         this.storageCore.getFolderLocation(StorageFolder.UPLOAD, user.id), | ||||
|         this.storageCore.getFolderLocation(StorageFolder.PROFILE, user.id), | ||||
|         this.storageCore.getFolderLocation(StorageFolder.THUMBNAILS, user.id), | ||||
|         this.storageCore.getFolderLocation(StorageFolder.ENCODED_VIDEO, user.id), | ||||
|       ]; | ||||
|  | ||||
|       for (const folder of folders) { | ||||
|         this.logger.warn(`Removing user from filesystem: ${folder}`); | ||||
|         await this.storageRepository.unlinkDir(folder, { recursive: true, force: true }); | ||||
|       } | ||||
|  | ||||
|       this.logger.warn(`Removing user from database: ${user.id}`); | ||||
|  | ||||
|   | ||||
| @@ -119,7 +119,7 @@ export const assetEntityStub = { | ||||
|     owner: userEntityStub.user1, | ||||
|     ownerId: 'user-id', | ||||
|     deviceId: 'device-id', | ||||
|     originalPath: '/original/path.ext', | ||||
|     originalPath: 'upload/upload/path.ext', | ||||
|     resizePath: null, | ||||
|     type: AssetType.IMAGE, | ||||
|     webpPath: null, | ||||
|   | ||||
| @@ -29,6 +29,7 @@ | ||||
|     "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", | ||||
|     "test:e2e": "jest --config ./apps/immich/test/jest-e2e.json --runInBand", | ||||
|     "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js", | ||||
|     "typeorm:migrations:create": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:create", | ||||
|     "typeorm:migrations:generate": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:generate -d ./libs/infra/src/db/config/database.config.ts", | ||||
|     "typeorm:migrations:run": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:run -d ./libs/infra/src/db/config/database.config.ts", | ||||
|     "typeorm:migrations:revert": "node --require ts-node/register ./node_modules/typeorm/cli.js migration:revert -d ./libs/infra/src/db/config/database.config.ts", | ||||
|   | ||||
		Reference in New Issue
	
	Block a user