feat(server) user-defined storage structure (#1098)

[Breaking] newly uploaded file will conform to the default structure of `{uploadLocation}/{userId}/year/year-month-day/filename.ext`
This commit is contained in:
Alex
2022-12-16 14:26:12 -06:00
committed by GitHub
parent 391d00bcb9
commit c754c860fd
59 changed files with 1892 additions and 173 deletions

View File

@@ -13,6 +13,7 @@ import { DownloadModule } from '../../modules/download/download.module';
import { TagModule } from '../tag/tag.module';
import { AlbumModule } from '../album/album.module';
import { UserModule } from '../user/user.module';
import { StorageModule } from '@app/storage';
const ASSET_REPOSITORY_PROVIDER = {
provide: ASSET_REPOSITORY,
@@ -28,6 +29,7 @@ const ASSET_REPOSITORY_PROVIDER = {
UserModule,
AlbumModule,
TagModule,
StorageModule,
forwardRef(() => AlbumModule),
BullModule.registerQueue({
name: QueueNameEnum.ASSET_UPLOADED,

View File

@@ -11,7 +11,8 @@ import { DownloadService } from '../../modules/download/download.service';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
import { Queue } from 'bull';
import { IAlbumRepository } from "../album/album-repository";
import { IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
describe('AssetService', () => {
let sui: AssetService;
@@ -22,6 +23,7 @@ describe('AssetService', () => {
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>;
let storageSeriveMock: jest.Mocked<StorageService>;
const authUser: AuthUserDto = Object.freeze({
id: 'user_id_1',
email: 'auth@test.com',
@@ -139,6 +141,7 @@ describe('AssetService', () => {
assetUploadedQueueMock,
videoConversionQueueMock,
downloadServiceMock as DownloadService,
storageSeriveMock,
);
});

View File

@@ -55,6 +55,7 @@ import { Queue } from 'bull';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
const fileInfo = promisify(stat);
@@ -79,6 +80,8 @@ export class AssetService {
private videoConversionQueue: Queue<IVideoTranscodeJob>,
private downloadService: DownloadService,
private storageService: StorageService,
) {}
public async handleUploadedAsset(
@@ -113,6 +116,8 @@ export class AssetService {
throw new BadRequestException('Asset not created');
}
await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname);
await this.videoConversionQueue.add(
mp4ConversionProcessorName,
{ asset: livePhotoAssetEntity },
@@ -139,13 +144,15 @@ export class AssetService {
throw new BadRequestException('Asset not created');
}
const movedAsset = await this.storageService.moveAsset(assetEntity, originalAssetData.originalname);
await this.assetUploadedQueue.add(
assetUploadedProcessorName,
{ asset: assetEntity, fileName: originalAssetData.originalname },
{ jobId: assetEntity.id },
{ asset: movedAsset, fileName: originalAssetData.originalname },
{ jobId: movedAsset.id },
);
return new AssetFileUploadResponseDto(assetEntity.id);
return new AssetFileUploadResponseDto(movedAsset.id);
} catch (err) {
await this.backgroundTaskService.deleteFileOnDisk([
{

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class SystemConfigStorageTemplateDto {
@IsNotEmpty()
@IsString()
template!: string;
}

View File

@@ -2,6 +2,7 @@ import { SystemConfig } from '@app/database/entities/system-config.entity';
import { ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
export class SystemConfigDto {
@ValidateNested()
@@ -9,6 +10,9 @@ export class SystemConfigDto {
@ValidateNested()
oauth!: SystemConfigOAuthDto;
@ValidateNested()
storageTemplate!: SystemConfigStorageTemplateDto;
}
export function mapConfig(config: SystemConfig): SystemConfigDto {

View File

@@ -0,0 +1,9 @@
export class SystemConfigTemplateStorageOptionDto {
yearOptions!: string[];
monthOptions!: string[];
dayOptions!: string[];
hourOptions!: string[];
minuteOptions!: string[];
secondOptions!: string[];
presetOptions!: string[];
}

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
import { SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigService } from './system-config.service';
@@ -25,4 +26,9 @@ export class SystemConfigController {
public updateConfig(@Body(ValidationPipe) dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.systemConfigService.updateConfig(dto);
}
@Get('storage-template-options')
public getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
return this.systemConfigService.getStorageTemplateOptions();
}
}

View File

@@ -1,6 +1,16 @@
import {
supportedDayTokens,
supportedHourTokens,
supportedMinuteTokens,
supportedMonthTokens,
supportedPresetTokens,
supportedSecondTokens,
supportedYearTokens,
} from '@app/storage/constants/supported-datetime-template';
import { Injectable } from '@nestjs/common';
import { ImmichConfigService } from 'libs/immich-config/src';
import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
@Injectable()
export class SystemConfigService {
@@ -17,7 +27,21 @@ export class SystemConfigService {
}
public async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
await this.immichConfigService.updateConfig(dto);
return this.getConfig();
const config = await this.immichConfigService.updateConfig(dto);
return mapConfig(config);
}
public getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
const options = new SystemConfigTemplateStorageOptionDto();
options.dayOptions = supportedDayTokens;
options.monthOptions = supportedMonthTokens;
options.yearOptions = supportedYearTokens;
options.hourOptions = supportedHourTokens;
options.minuteOptions = supportedMinuteTokens;
options.secondOptions = supportedSecondTokens;
options.presetOptions = supportedPresetTokens;
return options;
}
}

View File

@@ -19,7 +19,7 @@ describe('UserService', () => {
email: 'immich@test.com',
});
const adminUser: UserEntity = Object.freeze({
const adminUser: UserEntity = {
id: 'admin_id',
email: 'admin@test.com',
password: 'admin_password',
@@ -32,9 +32,9 @@ describe('UserService', () => {
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
});
};
const immichUser: UserEntity = Object.freeze({
const immichUser: UserEntity = {
id: 'immich_id',
email: 'immich@test.com',
password: 'immich_password',
@@ -47,9 +47,9 @@ describe('UserService', () => {
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
});
};
const updatedImmichUser: UserEntity = Object.freeze({
const updatedImmichUser: UserEntity = {
id: 'immich_id',
email: 'immich@test.com',
password: 'immich_password',
@@ -62,7 +62,7 @@ describe('UserService', () => {
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
});
};
beforeAll(() => {
userRepositoryMock = newUserRepositoryMock();
@@ -75,7 +75,7 @@ describe('UserService', () => {
});
describe('Update user', () => {
it('should update user', () => {
it('should update user', async () => {
const requestor = immichAuthUser;
const userToUpdate = immichUser;
@@ -83,11 +83,11 @@ describe('UserService', () => {
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(userToUpdate));
userRepositoryMock.update.mockImplementationOnce(() => Promise.resolve(updatedImmichUser));
const result = sui.updateUser(requestor, {
const result = await sui.updateUser(requestor, {
id: userToUpdate.id,
shouldChangePassword: true,
});
expect(result).resolves.toBeDefined();
expect(result.shouldChangePassword).toEqual(true);
});
it('user can only update its information', () => {

View File

@@ -44,6 +44,7 @@ export class ThumbnailGeneratorProcessor {
private configService: ConfigService,
) {
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
// TODO - Add observable paterrn to listen to the config change
}
@Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
@@ -59,9 +60,7 @@ export class ThumbnailGeneratorProcessor {
mkdirSync(resizePath, { recursive: true });
}
const temp = asset.originalPath.split('/');
const originalFilename = temp[temp.length - 1].split('.')[0];
const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
if (asset.type == AssetType.IMAGE) {
try {

View File

@@ -2169,12 +2169,38 @@
}
]
}
},
"/system-config/storage-template-options": {
"get": {
"operationId": "getStorageTemplateOptions",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigTemplateStorageOptionDto"
}
}
}
}
},
"tags": [
"System Config"
],
"security": [
{
"bearer": []
}
]
}
}
},
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.38.0",
"version": "1.38.2",
"contact": {}
},
"tags": [],
@@ -3664,6 +3690,17 @@
"autoRegister"
]
},
"SystemConfigStorageTemplateDto": {
"type": "object",
"properties": {
"template": {
"type": "string"
}
},
"required": [
"template"
]
},
"SystemConfigDto": {
"type": "object",
"properties": {
@@ -3672,11 +3709,71 @@
},
"oauth": {
"$ref": "#/components/schemas/SystemConfigOAuthDto"
},
"storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
}
},
"required": [
"ffmpeg",
"oauth"
"oauth",
"storageTemplate"
]
},
"SystemConfigTemplateStorageOptionDto": {
"type": "object",
"properties": {
"yearOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"monthOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"dayOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"hourOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"minuteOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"secondOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"presetOptions": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"yearOptions",
"monthOptions",
"dayOptions",
"hourOptions",
"minuteOptions",
"secondOptions",
"presetOptions"
]
}
}

View File

@@ -25,6 +25,7 @@ export enum SystemConfigKey {
OAUTH_SCOPE = 'oauth.scope',
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
STORAGE_TEMPLATE = 'storageTemplate.template',
}
export interface SystemConfig {
@@ -44,4 +45,7 @@ export interface SystemConfig {
buttonText: string;
autoRegister: boolean;
};
storageTemplate: {
template: string;
};
}

View File

@@ -1,11 +1,24 @@
import { SystemConfigEntity } from '@app/database/entities/system-config.entity';
import { Module } from '@nestjs/common';
import { Module, Provider } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ImmichConfigService } from './immich-config.service';
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
const providers: Provider[] = [
ImmichConfigService,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [ImmichConfigService],
useFactory: async (configService: ImmichConfigService) => {
return configService.getConfig();
},
},
];
@Module({
imports: [TypeOrmModule.forFeature([SystemConfigEntity])],
providers: [ImmichConfigService],
exports: [ImmichConfigService],
providers: [...providers],
exports: [...providers],
})
export class ImmichConfigModule {}

View File

@@ -1,9 +1,12 @@
import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/database/entities/system-config.entity';
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as _ from 'lodash';
import { Subject } from 'rxjs';
import { DeepPartial, In, Repository } from 'typeorm';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
const defaults: SystemConfig = Object.freeze({
ffmpeg: {
crf: '23',
@@ -21,10 +24,19 @@ const defaults: SystemConfig = Object.freeze({
buttonText: 'Login with OAuth',
autoRegister: true,
},
storageTemplate: {
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
});
@Injectable()
export class ImmichConfigService {
private logger = new Logger(ImmichConfigService.name);
private validators: SystemConfigValidator[] = [];
public config$ = new Subject<SystemConfig>();
constructor(
@InjectRepository(SystemConfigEntity)
private systemConfigRepository: Repository<SystemConfigEntity>,
@@ -34,6 +46,10 @@ export class ImmichConfigService {
return defaults;
}
public addValidator(validator: SystemConfigValidator) {
this.validators.push(validator);
}
public async getConfig() {
const overrides = await this.systemConfigRepository.find();
const config: DeepPartial<SystemConfig> = {};
@@ -45,7 +61,16 @@ export class ImmichConfigService {
return _.defaultsDeep(config, defaults) as SystemConfig;
}
public async updateConfig(config: DeepPartial<SystemConfig> | null): Promise<void> {
public async updateConfig(config: SystemConfig): Promise<SystemConfig> {
try {
for (const validator of this.validators) {
await validator(config);
}
} catch (e) {
this.logger.warn(`Unable to save system config due to a validation error: ${e}`);
throw new BadRequestException(e instanceof Error ? e.message : e);
}
const updates: SystemConfigEntity[] = [];
const deletes: SystemConfigEntity[] = [];
@@ -70,5 +95,11 @@ export class ImmichConfigService {
if (deletes.length > 0) {
await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) });
}
const newConfig = await this.getConfig();
this.config$.next(newConfig);
return newConfig;
}
}

View File

@@ -0,0 +1,20 @@
export const supportedYearTokens = ['y', 'yy'];
export const supportedMonthTokens = ['M', 'MM', 'MMM', 'MMMM'];
export const supportedDayTokens = ['d', 'dd'];
export const supportedHourTokens = ['h', 'hh', 'H', 'HH'];
export const supportedMinuteTokens = ['m', 'mm'];
export const supportedSecondTokens = ['s', 'ss'];
export const supportedPresetTokens = [
'{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}/{{dd}}/{{filename}}',
'{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}-{{MMM}}-{{dd}}/{{filename}}',
'{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
];

View File

@@ -0,0 +1,2 @@
export * from './storage.module';
export * from './storage.service';

View File

@@ -0,0 +1,6 @@
export interface IImmichStorage {
write(): Promise<void>;
read(): Promise<void>;
}
export enum IStorageType {}

View File

@@ -0,0 +1,13 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
import { SystemConfigEntity } from '@app/database/entities/system-config.entity';
import { ImmichConfigModule } from '@app/immich-config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StorageService } from './storage.service';
@Module({
imports: [TypeOrmModule.forFeature([AssetEntity, SystemConfigEntity]), ImmichConfigModule],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@@ -0,0 +1,153 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { SystemConfig } from '@app/database/entities/system-config.entity';
import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import fsPromise from 'fs/promises';
import handlebar from 'handlebars';
import * as luxon from 'luxon';
import mv from 'mv';
import { constants } from 'node:fs';
import path from 'node:path';
import { promisify } from 'node:util';
import sanitize from 'sanitize-filename';
import { Repository } from 'typeorm';
import {
supportedDayTokens,
supportedHourTokens,
supportedMinuteTokens,
supportedMonthTokens,
supportedSecondTokens,
supportedYearTokens,
} from './constants/supported-datetime-template';
const moveFile = promisify<string, string, mv.Options>(mv);
@Injectable()
export class StorageService {
readonly log = new Logger(StorageService.name);
private storageTemplate: HandlebarsTemplateDelegate<any>;
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
private immichConfigService: ImmichConfigService,
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
) {
this.storageTemplate = this.compile(config.storageTemplate.template);
this.immichConfigService.addValidator((config) => this.validateConfig(config));
this.immichConfigService.config$.subscribe((config) => {
this.log.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`);
this.storageTemplate = this.compile(config.storageTemplate.template);
});
}
public async moveAsset(asset: AssetEntity, filename: string): Promise<AssetEntity> {
try {
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.userId);
const storagePath = this.render(this.storageTemplate, asset, sanitized, ext);
const fullPath = path.normalize(path.join(rootPath, storagePath));
if (!fullPath.startsWith(rootPath)) {
this.log.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`);
return asset;
}
let duplicateCount = 0;
let destination = `${fullPath}.${ext}`;
while (true) {
const exists = await this.checkFileExist(destination);
if (!exists) {
break;
}
duplicateCount++;
destination = `${fullPath}_${duplicateCount}.${ext}`;
}
await this.safeMove(source, destination);
asset.originalPath = destination;
return await this.assetRepository.save(asset);
} catch (error: any) {
this.log.error(error, error.stack);
return asset;
}
}
private safeMove(source: string, destination: string): Promise<void> {
return moveFile(source, destination, { mkdirp: true, clobber: false });
}
private async checkFileExist(path: string): Promise<boolean> {
try {
await fsPromise.access(path, constants.F_OK);
return true;
} catch (_) {
return false;
}
}
private validateConfig(config: SystemConfig) {
this.validateStorageTemplate(config.storageTemplate.template);
}
private validateStorageTemplate(templateString: string) {
try {
const template = this.compile(templateString);
// test render an asset
this.render(
template,
{
createdAt: new Date().toISOString(),
originalPath: '/upload/test/IMG_123.jpg',
} as AssetEntity,
'IMG_123',
'jpg',
);
} catch (e) {
this.log.warn(`Storage template validation failed: ${e}`);
throw new Error(`Invalid storage template: ${e}`);
}
}
private compile(template: string) {
return handlebar.compile(template, {
knownHelpers: undefined,
strict: true,
});
}
private render(template: HandlebarsTemplateDelegate<any>, asset: AssetEntity, filename: string, ext: string) {
const substitutions: Record<string, string> = {
filename,
ext,
};
const dt = luxon.DateTime.fromISO(new Date(asset.createdAt).toISOString());
const dateTokens = [
...supportedYearTokens,
...supportedMonthTokens,
...supportedDayTokens,
...supportedHourTokens,
...supportedMinuteTokens,
...supportedSecondTokens,
];
for (const token of dateTokens) {
substitutions[token] = dt.toFormat(token);
}
return template(substitutions);
}
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"outDir": "../../dist/libs/storage"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@@ -79,6 +79,15 @@
"compilerOptions": {
"tsConfigPath": "libs/immich-config/tsconfig.lib.json"
}
},
"storage": {
"type": "library",
"root": "libs/storage",
"entryFile": "index",
"sourceRoot": "libs/storage/src",
"compilerOptions": {
"tsConfigPath": "libs/storage/tsconfig.lib.json"
}
}
}
}
}

180
server/package-lock.json generated
View File

@@ -36,11 +36,13 @@
"fdir": "^5.3.0",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.0.3",
"mv": "^2.1.1",
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0",
@@ -76,6 +78,7 @@
"@types/jest": "27.0.2",
"@types/lodash": "^4.14.178",
"@types/multer": "^1.4.7",
"@types/mv": "^2.1.2",
"@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.30.2",
@@ -2544,6 +2547,12 @@
"@types/express": "*"
}
},
"node_modules/@types/mv": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.2.tgz",
"integrity": "sha512-IvAjPuiQ2exDicnTrMidt1m+tj3gZ60BM0PaoRsU0m9Cn+lrOyemuO9Tf8CvHFmXlxMjr1TVCfadi9sfwbSuKg==",
"dev": true
},
"node_modules/@types/node": {
"version": "16.11.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
@@ -6168,6 +6177,34 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
"integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ=="
},
"node_modules/handlebars": {
"version": "4.7.7",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.0",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/handlebars/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@@ -8178,6 +8215,45 @@
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"node_modules/mv": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
"integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
"dependencies": {
"mkdirp": "~0.5.1",
"ncp": "~2.0.0",
"rimraf": "~2.4.0"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/mv/node_modules/glob": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
"integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
"dependencies": {
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "2 || 3",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
}
},
"node_modules/mv/node_modules/rimraf": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
"integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
"dependencies": {
"glob": "^6.0.1"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -8204,6 +8280,14 @@
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
"dev": true
},
"node_modules/ncp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
"integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==",
"bin": {
"ncp": "bin/ncp"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -8215,8 +8299,7 @@
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"node_modules/nest-commander": {
"version": "3.3.0",
@@ -11006,6 +11089,18 @@
"node": ">=4.2.0"
}
},
"node_modules/uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/uid2": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
@@ -11329,6 +11424,11 @@
"node": ">=0.10.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@@ -13393,6 +13493,12 @@
"@types/express": "*"
}
},
"@types/mv": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.2.tgz",
"integrity": "sha512-IvAjPuiQ2exDicnTrMidt1m+tj3gZ60BM0PaoRsU0m9Cn+lrOyemuO9Tf8CvHFmXlxMjr1TVCfadi9sfwbSuKg==",
"dev": true
},
"@types/node": {
"version": "16.11.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
@@ -16213,6 +16319,25 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
"integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ=="
},
"handlebars": {
"version": "4.7.7",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
"requires": {
"minimist": "^1.2.5",
"neo-async": "^2.6.0",
"source-map": "^0.6.1",
"uglify-js": "^3.1.4",
"wordwrap": "^1.0.0"
},
"dependencies": {
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}
}
},
"har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@@ -17773,6 +17898,38 @@
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"mv": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
"integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
"requires": {
"mkdirp": "~0.5.1",
"ncp": "~2.0.0",
"rimraf": "~2.4.0"
},
"dependencies": {
"glob": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
"integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
"requires": {
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "2 || 3",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"rimraf": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
"integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
"requires": {
"glob": "^6.0.1"
}
}
}
},
"mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -17799,6 +17956,11 @@
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
"dev": true
},
"ncp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
"integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA=="
},
"negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -17807,8 +17969,7 @@
"neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"nest-commander": {
"version": "3.3.0",
@@ -19794,6 +19955,12 @@
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
"devOptional": true
},
"uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
"optional": true
},
"uid2": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
@@ -20049,6 +20216,11 @@
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true
},
"wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
},
"wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -59,11 +59,13 @@
"fdir": "^5.3.0",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.0.3",
"mv": "^2.1.1",
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0",
@@ -96,6 +98,7 @@
"@types/jest": "27.0.2",
"@types/lodash": "^4.14.178",
"@types/multer": "^1.4.7",
"@types/mv": "^2.1.2",
"@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.30.2",
@@ -142,7 +145,8 @@
"@app/database/config": "<rootDir>/libs/database/src/config",
"@app/common": "<rootDir>/libs/common/src",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1",
"^@app/immich-config(|/.*)$": "<rootDir>/libs/immich-config/src/$1"
"^@app/immich-config(|/.*)$": "<rootDir>/libs/immich-config/src/$1",
"^@app/storage(|/.*)$": "<rootDir>/libs/storage/src/$1"
}
}
}

View File

@@ -16,15 +16,41 @@
"esModuleInterop": true,
"baseUrl": "./",
"paths": {
"@app/common": ["libs/common/src"],
"@app/common/*": ["libs/common/src/*"],
"@app/database": ["libs/database/src"],
"@app/database/*": ["libs/database/src/*"],
"@app/job": ["libs/job/src"],
"@app/job/*": ["libs/job/src/*"],
"@app/immich-config": ["libs/immich-config/src"],
"@app/immich-config/*": ["libs/immich-config/src/*"]
"@app/common": [
"libs/common/src"
],
"@app/common/*": [
"libs/common/src/*"
],
"@app/database": [
"libs/database/src"
],
"@app/database/*": [
"libs/database/src/*"
],
"@app/job": [
"libs/job/src"
],
"@app/job/*": [
"libs/job/src/*"
],
"@app/immich-config": [
"libs/immich-config/src"
],
"@app/immich-config/*": [
"libs/immich-config/src/*"
],
"@app/storage": [
"libs/storage/src"
],
"@app/storage/*": [
"libs/storage/src/*"
]
}
},
"exclude": ["dist", "node_modules", "upload"]
}
"exclude": [
"dist",
"node_modules",
"upload"
]
}