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