From 38c2b9d42e6752de99c70108248077deb43c39c9 Mon Sep 17 00:00:00 2001 From: rubikscraft Date: Mon, 19 Sep 2022 16:53:58 +0200 Subject: [PATCH] make image converting a job too --- backend/src/app.module.ts | 2 - .../ingest-file-db/ingest-file-db.module.ts | 11 - .../ingest-file-db/ingest-file-db.service.ts | 32 -- .../preference-defaults.service.ts | 1 - backend/src/database/entities/index.ts | 2 - .../database/entities/ingest-file.entity.ts | 16 - .../src/layers/success/success.interceptor.ts | 16 +- backend/src/main.ts | 5 +- .../src/managers/image/convert.consumer.ts | 94 ++++++ backend/src/managers/image/convert.service.ts | 97 ++++++ ...mage.module.ts => image-manager.module.ts} | 32 +- .../managers/image/image-manager.service.ts | 106 +++++++ .../managers/image/image-processor.service.ts | 50 --- backend/src/managers/image/image.queue.ts | 12 + backend/src/managers/image/image.service.ts | 288 ------------------ .../{ingest => image}/ingest.consumer.ts | 43 +-- .../{ingest => image}/ingest.service.ts | 35 ++- backend/src/managers/ingest/ingest.module.ts | 21 -- .../api/experiment/experiment.controller.ts | 20 +- .../api/experiment/experiment.module.ts | 4 +- .../routes/image/image-manage.controller.ts | 4 +- backend/src/routes/image/image.controller.ts | 6 +- backend/src/routes/image/image.module.ts | 6 +- frontend/src/app/i18n/sys-pref.i18n.ts | 8 +- shared/src/dto/sys-preferences.enum.ts | 5 +- shared/src/validators/ms.validator.ts | 4 +- yarn.lock | 147 +-------- 27 files changed, 402 insertions(+), 665 deletions(-) delete mode 100644 backend/src/collections/ingest-file-db/ingest-file-db.module.ts delete mode 100644 backend/src/collections/ingest-file-db/ingest-file-db.service.ts delete mode 100644 backend/src/database/entities/ingest-file.entity.ts create mode 100644 backend/src/managers/image/convert.consumer.ts create mode 100644 backend/src/managers/image/convert.service.ts rename backend/src/managers/image/{image.module.ts => image-manager.module.ts} (74%) create mode 100644 backend/src/managers/image/image-manager.service.ts delete mode 100644 backend/src/managers/image/image-processor.service.ts create mode 100644 backend/src/managers/image/image.queue.ts delete mode 100644 backend/src/managers/image/image.service.ts rename backend/src/managers/{ingest => image}/ingest.consumer.ts (80%) rename backend/src/managers/{ingest => image}/ingest.service.ts (80%) delete mode 100644 backend/src/managers/ingest/ingest.module.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 3affe3e..145d76a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -12,7 +12,6 @@ import { PicsurLayersModule } from './layers/PicsurLayers.module'; import { PicsurLoggerModule } from './logger/logger.module'; import { AuthManagerModule } from './managers/auth/auth.module'; import { DemoManagerModule } from './managers/demo/demo.module'; -import { IngestManagerModule } from './managers/ingest/ingest.module'; import { UsageManagerModule } from './managers/usage/usage.module'; import { PicsurRoutesModule } from './routes/routes.module'; @@ -59,7 +58,6 @@ const imageCorsOverride = ( DemoManagerModule, PicsurRoutesModule, PicsurLayersModule, - IngestManagerModule, ], }) export class AppModule implements NestModule { diff --git a/backend/src/collections/ingest-file-db/ingest-file-db.module.ts b/backend/src/collections/ingest-file-db/ingest-file-db.module.ts deleted file mode 100644 index 32351f6..0000000 --- a/backend/src/collections/ingest-file-db/ingest-file-db.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { EIngestFileBackend } from '../../database/entities/ingest-file.entity'; -import { IngestFileDbService } from './ingest-file-db.service'; - -@Module({ - imports: [TypeOrmModule.forFeature([EIngestFileBackend])], - providers: [IngestFileDbService], - exports: [IngestFileDbService], -}) -export class IngestFileDbModule {} diff --git a/backend/src/collections/ingest-file-db/ingest-file-db.service.ts b/backend/src/collections/ingest-file-db/ingest-file-db.service.ts deleted file mode 100644 index 9f81b8f..0000000 --- a/backend/src/collections/ingest-file-db/ingest-file-db.service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { AsyncFailable, Fail, FT } from 'picsur-shared/dist/types'; -import { Repository } from 'typeorm'; -import { EIngestFileBackend } from '../../database/entities/ingest-file.entity'; - -@Injectable() -export class IngestFileDbService { - private readonly logger = new Logger(IngestFileDbService.name); - - constructor( - @InjectRepository(EIngestFileBackend) - private readonly ingressFileRepo: Repository, - ) {} - - public async uploadFile( - filename: string, - file: Buffer, - ): AsyncFailable { - const ingressFile = new EIngestFileBackend(); - ingressFile.filename = filename; - ingressFile.data = file; - - try { - await this.ingressFileRepo.save(ingressFile); - } catch (e) { - return Fail(FT.Database, e); - } - - return ingressFile.id; - } -} diff --git a/backend/src/collections/preference-db/preference-defaults.service.ts b/backend/src/collections/preference-db/preference-defaults.service.ts index a084b1d..34105a6 100644 --- a/backend/src/collections/preference-db/preference-defaults.service.ts +++ b/backend/src/collections/preference-db/preference-defaults.service.ts @@ -42,7 +42,6 @@ export class PreferenceDefaultsService { [SysPreference.BCryptStrength]: 10, [SysPreference.RemoveDerivativesAfter]: '7d', - [SysPreference.SaveDerivatives]: true, [SysPreference.AllowEditing]: true, [SysPreference.ConversionTimeLimit]: '15s', diff --git a/backend/src/database/entities/index.ts b/backend/src/database/entities/index.ts index 755b6fa..9515ac2 100644 --- a/backend/src/database/entities/index.ts +++ b/backend/src/database/entities/index.ts @@ -2,7 +2,6 @@ import { EApiKeyBackend } from './apikey.entity'; import { EImageDerivativeBackend } from './images/image-derivative.entity'; import { EImageFileBackend } from './images/image-file.entity'; import { EImageBackend } from './images/image.entity'; -import { EIngestFileBackend } from './ingest-file.entity'; import { ESysPreferenceBackend } from './system/sys-preference.entity'; import { ESystemStateBackend } from './system/system-state.entity'; import { EUsrPreferenceBackend } from './system/usr-preference.entity'; @@ -19,5 +18,4 @@ export const EntityList = [ EUsrPreferenceBackend, EApiKeyBackend, ESystemStateBackend, - EIngestFileBackend, ]; diff --git a/backend/src/database/entities/ingest-file.entity.ts b/backend/src/database/entities/ingest-file.entity.ts deleted file mode 100644 index dd4137f..0000000 --- a/backend/src/database/entities/ingest-file.entity.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity() -export class EIngestFileBackend { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ nullable: false }) - filename: string; - - @Column({ type: 'bytea', nullable: false }) - data: Buffer; - - @Column({ nullable: false, default: false }) - in_use: boolean; -} diff --git a/backend/src/layers/success/success.interceptor.ts b/backend/src/layers/success/success.interceptor.ts index 625484a..2cfc6bf 100644 --- a/backend/src/layers/success/success.interceptor.ts +++ b/backend/src/layers/success/success.interceptor.ts @@ -4,7 +4,7 @@ import { Injectable, Logger, NestInterceptor, - Optional, + Optional } from '@nestjs/common'; import { Reflector } from '@nestjs/core'; import { FastifyReply } from 'fastify'; @@ -46,6 +46,20 @@ export class SuccessInterceptor implements NestInterceptor { return data; } }), + map((data) => { + const request = context.switchToHttp().getRequest(); + const response = context.switchToHttp().getResponse(); + const traceString = `(${request.ip} -> ${request.method} ${request.url})`; + + this.logger.verbose( + `Handled ${traceString} with ${response.statusCode} in ${Math.ceil( + response.getResponseTime(), + )}ms`, + SuccessInterceptor.name, + ); + + return data; + }), ); } diff --git a/backend/src/main.ts b/backend/src/main.ts index 10864e2..68befda 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -44,12 +44,9 @@ async function bootstrap() { ); // Configure logger - const logger = app.get(PicsurLoggerService) - app.useLogger(logger); + app.useLogger(app.get(PicsurLoggerService)); app.flushLogs(); - console.log(logger); - app.useGlobalFilters(app.get(MainExceptionFilter)); app.useGlobalInterceptors(app.get(SuccessInterceptor)); app.useGlobalPipes(app.get(ZodValidationPipe)); diff --git a/backend/src/managers/image/convert.consumer.ts b/backend/src/managers/image/convert.consumer.ts new file mode 100644 index 0000000..bf1a42d --- /dev/null +++ b/backend/src/managers/image/convert.consumer.ts @@ -0,0 +1,94 @@ +import { OnQueueError, OnQueueFailed, Process, Processor } from '@nestjs/bull'; +import { Logger } from '@nestjs/common'; +import type { Job } from 'bull'; +import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto'; +import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum'; +import { IsFailure, ThrowIfFailed } from 'picsur-shared/dist/types'; +import { ParseFileType } from 'picsur-shared/dist/util/parse-mime'; +import { ImageFileDBService } from '../../collections/image-db/image-file-db.service'; +import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service'; +import { ImageConverterService } from './image-converter.service'; +import { ImageManagerService } from './image-manager.service'; +import { ImageQueueID, ImageQueueSubject } from './image.queue'; + +// This contains the job to convert an image to a derivative and store it + +export interface ImageConvertJobData { + uniqueKey: string; + imageId: string; + fileType: string; + options: ImageRequestParams; +} +export type ImageConvertJob = Job; + +@Processor(ImageQueueID) +export class ConvertConsumer { + private readonly logger = new Logger(ConvertConsumer.name); + + constructor( + private readonly imageFilesService: ImageFileDBService, + private readonly imageConverter: ImageConverterService, + private readonly sysPref: SysPreferenceDbService, + private readonly imageService: ImageManagerService, + ) {} + + @Process(ImageQueueSubject.CONVERT) + async convertImage(job: ImageConvertJob): Promise { + const { imageId, fileType, options, uniqueKey } = job.data; + + // Get file type + const targetFileType = ThrowIfFailed(ParseFileType(fileType)); + + // Get preferences + const allow_editing = ThrowIfFailed( + await this.sysPref.getBooleanPreference(SysPreference.AllowEditing), + ); + + // Get master image + const masterImage = ThrowIfFailed( + await this.imageService.getMaster(imageId), + ); + const sourceFileType = ThrowIfFailed(ParseFileType(masterImage.filetype)); + + // Conver timage + const startTime = Date.now(); + const convertResult = ThrowIfFailed( + await this.imageConverter.convert( + masterImage.data, + sourceFileType, + targetFileType, + allow_editing ? options : {}, + ), + ); + + this.logger.verbose( + `Converted ${imageId} from ${sourceFileType.identifier} to ${ + targetFileType.identifier + } in ${Date.now() - startTime}ms`, + ); + + ThrowIfFailed( + await this.imageFilesService.addDerivative( + imageId, + uniqueKey, + convertResult.filetype, + convertResult.image, + ), + ); + } + + @OnQueueError() + async handleError(error: any) { + if (IsFailure(error)) error.print(this.logger); + else this.logger.error(error); + } + + @OnQueueFailed() + async handleFailed(job: Job, error: any) { + if (IsFailure(error)) + error.print(this.logger, { + prefix: `[JOB ${job.id}]`, + }); + else this.logger.error(error); + } +} diff --git a/backend/src/managers/image/convert.service.ts b/backend/src/managers/image/convert.service.ts new file mode 100644 index 0000000..ef259c5 --- /dev/null +++ b/backend/src/managers/image/convert.service.ts @@ -0,0 +1,97 @@ +import { InjectQueue } from '@nestjs/bull'; +import { Injectable } from '@nestjs/common'; +import Crypto from 'crypto'; +import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto'; +import { + AsyncFailable, + Fail, + FT, + HasFailed, + ThrowIfFailed +} from 'picsur-shared/dist/types'; +import { ImageFileDBService } from '../../collections/image-db/image-file-db.service'; +import { EImageDerivativeBackend } from '../../database/entities/images/image-derivative.entity'; +import { ImageConvertJob } from './convert.consumer'; +import * as ImageQueue from './image.queue'; + +@Injectable() +export class ConvertService { + constructor( + @InjectQueue(ImageQueue.ImageQueueID) + private readonly imageQueue: ImageQueue.ImageQueueType, + private readonly imageFilesService: ImageFileDBService, + ) {} + + public async convertJob( + imageId: string, + fileType: string, + options: ImageRequestParams, + ): AsyncFailable { + const jobID = this.getConvertHash(imageId, { fileType, ...options }); + + /* + Jobs with the same ID don't get executed, we abuse this by passing it a hash of the input parameters. + This way, if the same image is requested with the same parameters, we don't have to convert it again. + Since it will always produce the same output with the same inputs + */ + + let job: ImageConvertJob; + try { + job = (await this.imageQueue.add( + ImageQueue.ImageQueueSubject.CONVERT, + { + imageId, + fileType, + options, + uniqueKey: jobID, + }, + { + jobId: jobID, + }, + )) as ImageConvertJob; + } catch (e) { + return Fail(FT.Internal, e); + } + + if (!job.id) return Fail(FT.Internal, undefined, 'Failed to queue job'); + return job; + } + + public async convertPromise( + imageId: string, + fileType: string, + options: ImageRequestParams, + ): AsyncFailable { + const uniqueKey = this.getConvertHash(imageId, { fileType, ...options }); + + const findExisting = ThrowIfFailed( + await this.imageFilesService.getDerivative(imageId, uniqueKey), + ); + if (findExisting !== null) return findExisting; + + const job = await this.convertJob(imageId, fileType, options); + if (HasFailed(job)) return job; + + try { + await job.finished(); + } catch (e) { + return Fail(FT.Internal, 'Failed to convert image', e); + } + + const findResult = ThrowIfFailed( + await this.imageFilesService.getDerivative(imageId, uniqueKey), + ); + if (findResult !== null) return findResult; + + return Fail(FT.Internal, 'Failed to convert image'); + } + + private getConvertHash(imageID: string, options: object) { + // Return a sha256 hash of the stringified options + const stringified = JSON.stringify(options) + '-' + imageID; + const hash = Crypto.createHash('sha256'); + hash.update(stringified); + const digest = hash.digest('hex'); + return digest; + } +} diff --git a/backend/src/managers/image/image.module.ts b/backend/src/managers/image/image-manager.module.ts similarity index 74% rename from backend/src/managers/image/image.module.ts rename to backend/src/managers/image/image-manager.module.ts index f90cc62..79d8fe8 100644 --- a/backend/src/managers/image/image.module.ts +++ b/backend/src/managers/image/image-manager.module.ts @@ -1,3 +1,4 @@ +import { BullModule } from '@nestjs/bull'; import { Logger, Module, OnModuleInit } from '@nestjs/common'; import { Interval } from '@nestjs/schedule'; import ms from 'ms'; @@ -8,18 +9,36 @@ import { ImageDBService } from '../../collections/image-db/image-db.service'; import { ImageFileDBService } from '../../collections/image-db/image-file-db.service'; import { PreferenceDbModule } from '../../collections/preference-db/preference-db.module'; import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service'; +import { ConvertConsumer } from './convert.consumer'; +import { ConvertService } from './convert.service'; import { ImageConverterService } from './image-converter.service'; -import { ImageProcessorService } from './image-processor.service'; -import { ImageManagerService } from './image.service'; +import { ImageManagerService } from './image-manager.service'; +import { ImageQueueID } from './image.queue'; +import { IngestConsumer } from './ingest.consumer'; +import { IngestService } from './ingest.service'; @Module({ - imports: [ImageDBModule, PreferenceDbModule], + imports: [ + ImageDBModule, + PreferenceDbModule, + BullModule.registerQueue({ + name: ImageQueueID, + }), + ], providers: [ ImageManagerService, - ImageProcessorService, ImageConverterService, + IngestConsumer, + ConvertConsumer, + IngestService, + ConvertService, + ], + exports: [ + ImageManagerService, + ImageConverterService, + IngestService, + ConvertService, ], - exports: [ImageManagerService, ImageConverterService], }) export class ImageManagerModule implements OnModuleInit { private readonly logger = new Logger(ImageManagerModule.name); @@ -49,11 +68,12 @@ export class ImageManagerModule implements OnModuleInit { return; } - const after_ms = ms(remove_derivatives_after as any); + let after_ms = ms(remove_derivatives_after as any); if (isNaN(after_ms) || after_ms === 0) { this.logger.log('remove_derivatives_after is 0, skipping cron'); return; } + if (after_ms < 60000) after_ms = 60000; const result = await this.imageFileDB.cleanupDerivatives(after_ms / 1000); if (HasFailed(result)) { diff --git a/backend/src/managers/image/image-manager.service.ts b/backend/src/managers/image/image-manager.service.ts new file mode 100644 index 0000000..16f59c9 --- /dev/null +++ b/backend/src/managers/image/image-manager.service.ts @@ -0,0 +1,106 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum'; +import { FileType } from 'picsur-shared/dist/dto/mimes.dto'; +import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types'; +import { FindResult } from 'picsur-shared/dist/types/find-result'; +import { ParseFileType } from 'picsur-shared/dist/util/parse-mime'; +import { ImageDBService } from '../../collections/image-db/image-db.service'; +import { ImageFileDBService } from '../../collections/image-db/image-file-db.service'; +import { EImageFileBackend } from '../../database/entities/images/image-file.entity'; +import { EImageBackend } from '../../database/entities/images/image.entity'; + +@Injectable() +export class ImageManagerService { + private readonly logger = new Logger(ImageManagerService.name); + + constructor( + private readonly imagesService: ImageDBService, + private readonly imageFilesService: ImageFileDBService, + ) {} + + public async findOne(id: string): AsyncFailable { + return await this.imagesService.findOne(id, undefined); + } + + public async findMany( + count: number, + page: number, + userid: string | undefined, + ): AsyncFailable> { + return await this.imagesService.findMany(count, page, userid); + } + + public async update( + id: string, + userid: string | undefined, + options: Partial>, + ): AsyncFailable { + if (options.expires_at !== undefined && options.expires_at !== null) { + if (options.expires_at < new Date()) { + return Fail(FT.UsrValidation, 'Expiration date must be in the future'); + } + } + return await this.imagesService.update(id, userid, options); + } + + public async deleteMany( + ids: string[], + userid: string | undefined, + ): AsyncFailable { + return await this.imagesService.delete(ids, userid); + } + + public async deleteWithKey( + imageId: string, + key: string, + ): AsyncFailable { + return await this.imagesService.deleteWithKey(imageId, key); + } + + // File getters ============================================================== + + public async getMaster(imageId: string): AsyncFailable { + return this.imageFilesService.getFile(imageId, ImageEntryVariant.MASTER); + } + + public async getMasterFileType(imageId: string): AsyncFailable { + const mime = await this.imageFilesService.getFileTypes(imageId); + if (HasFailed(mime)) return mime; + + if (mime['master'] === undefined) + return Fail(FT.NotFound, 'No master file'); + + return ParseFileType(mime['master']); + } + + public async getOriginal(imageId: string): AsyncFailable { + return this.imageFilesService.getFile(imageId, ImageEntryVariant.ORIGINAL); + } + + public async getOriginalFileType(imageId: string): AsyncFailable { + const filetypes = await this.imageFilesService.getFileTypes(imageId); + if (HasFailed(filetypes)) return filetypes; + + if (filetypes['original'] === undefined) + return Fail(FT.NotFound, 'No original file'); + + return ParseFileType(filetypes['original']); + } + + public async getFileMimes(imageId: string): AsyncFailable<{ + [ImageEntryVariant.MASTER]: string; + [ImageEntryVariant.ORIGINAL]: string | undefined; + }> { + const result = await this.imageFilesService.getFileTypes(imageId); + if (HasFailed(result)) return result; + + if (result[ImageEntryVariant.MASTER] === undefined) { + return Fail(FT.NotFound, 'No master file found'); + } + + return { + [ImageEntryVariant.MASTER]: result[ImageEntryVariant.MASTER]!, + [ImageEntryVariant.ORIGINAL]: result[ImageEntryVariant.ORIGINAL], + }; + } +} diff --git a/backend/src/managers/image/image-processor.service.ts b/backend/src/managers/image/image-processor.service.ts deleted file mode 100644 index 045b69f..0000000 --- a/backend/src/managers/image/image-processor.service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { - FileType, - ImageFileType, - SupportedFileTypeCategory -} from 'picsur-shared/dist/dto/mimes.dto'; - -import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types'; -import { ParseFileType } from 'picsur-shared/dist/util/parse-mime'; -import { ImageConverterService } from './image-converter.service'; -import { ImageResult } from './imageresult'; - -@Injectable() -export class ImageProcessorService { - constructor(private readonly imageConverter: ImageConverterService) {} - - public async process( - image: Buffer, - filetype: FileType, - ): AsyncFailable { - if (filetype.category === SupportedFileTypeCategory.Image) { - return await this.processStill(image, filetype); - } else if (filetype.category === SupportedFileTypeCategory.Animation) { - return await this.processAnimation(image, filetype); - } else { - return Fail(FT.SysValidation, 'Unsupported mime type'); - } - } - - private async processStill( - image: Buffer, - filetype: FileType, - ): AsyncFailable { - const outputFileType = ParseFileType(ImageFileType.QOI); - if (HasFailed(outputFileType)) return outputFileType; - - return this.imageConverter.convert(image, filetype, outputFileType, {}); - } - - private async processAnimation( - image: Buffer, - filetype: FileType, - ): AsyncFailable { - // Webps and gifs are stored as is for now - return { - image: image, - filetype: filetype.identifier, - }; - } -} diff --git a/backend/src/managers/image/image.queue.ts b/backend/src/managers/image/image.queue.ts new file mode 100644 index 0000000..6747680 --- /dev/null +++ b/backend/src/managers/image/image.queue.ts @@ -0,0 +1,12 @@ +import { Queue } from 'bull'; +import { ImageConvertJobData } from './convert.consumer'; +import { ImageIngestJobData } from './ingest.consumer'; + +export const ImageQueueID = 'image-queue'; +export type ImageQueueType = Queue; + +export enum ImageQueueSubject { + INGEST = 'ingest', + CONVERT = 'convert', +} + diff --git a/backend/src/managers/image/image.service.ts b/backend/src/managers/image/image.service.ts deleted file mode 100644 index 734c53b..0000000 --- a/backend/src/managers/image/image.service.ts +++ /dev/null @@ -1,288 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import Crypto from 'crypto'; -import { fileTypeFromBuffer, FileTypeResult } from 'file-type'; -import { ImageRequestParams } from 'picsur-shared/dist/dto/api/image.dto'; -import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum'; -import { - AnimFileType, - FileType, ImageFileType, - Mime2FileType -} from 'picsur-shared/dist/dto/mimes.dto'; -import { SysPreference } from 'picsur-shared/dist/dto/sys-preferences.enum'; -import { UsrPreference } from 'picsur-shared/dist/dto/usr-preferences.enum'; -import { AsyncFailable, Fail, FT, HasFailed } from 'picsur-shared/dist/types'; -import { FindResult } from 'picsur-shared/dist/types/find-result'; -import { ParseFileType } from 'picsur-shared/dist/util/parse-mime'; -import { IsQOI } from 'qoi-img'; -import { ImageDBService } from '../../collections/image-db/image-db.service'; -import { ImageFileDBService } from '../../collections/image-db/image-file-db.service'; -import { SysPreferenceDbService } from '../../collections/preference-db/sys-preference-db.service'; -import { UsrPreferenceDbService } from '../../collections/preference-db/usr-preference-db.service'; -import { EImageDerivativeBackend } from '../../database/entities/images/image-derivative.entity'; -import { EImageFileBackend } from '../../database/entities/images/image-file.entity'; -import { EImageBackend } from '../../database/entities/images/image.entity'; -import { MutexFallBack } from '../../util/mutex-fallback'; -import { ImageConverterService } from './image-converter.service'; -import { ImageProcessorService } from './image-processor.service'; -import { WebPInfo } from './webpinfo/webpinfo'; - -@Injectable() -export class ImageManagerService { - private readonly logger = new Logger(ImageManagerService.name); - - constructor( - private readonly imagesService: ImageDBService, - private readonly imageFilesService: ImageFileDBService, - private readonly processService: ImageProcessorService, - private readonly convertService: ImageConverterService, - private readonly userPref: UsrPreferenceDbService, - private readonly sysPref: SysPreferenceDbService, - ) {} - - public async findOne(id: string): AsyncFailable { - return await this.imagesService.findOne(id, undefined); - } - - public async findMany( - count: number, - page: number, - userid: string | undefined, - ): AsyncFailable> { - return await this.imagesService.findMany(count, page, userid); - } - - public async update( - id: string, - userid: string | undefined, - options: Partial>, - ): AsyncFailable { - if (options.expires_at !== undefined && options.expires_at !== null) { - if (options.expires_at < new Date()) { - return Fail(FT.UsrValidation, 'Expiration date must be in the future'); - } - } - return await this.imagesService.update(id, userid, options); - } - - public async deleteMany( - ids: string[], - userid: string | undefined, - ): AsyncFailable { - return await this.imagesService.delete(ids, userid); - } - - public async deleteWithKey( - imageId: string, - key: string, - ): AsyncFailable { - return await this.imagesService.deleteWithKey(imageId, key); - } - - public async upload( - userid: string, - filename: string, - image: Buffer, - withDeleteKey: boolean, - ): AsyncFailable { - const fileType = await this.getFileTypeFromBuffer(image); - if (HasFailed(fileType)) return fileType; - - // Check if need to save orignal - const keepOriginal = await this.userPref.getBooleanPreference( - userid, - UsrPreference.KeepOriginal, - ); - if (HasFailed(keepOriginal)) return keepOriginal; - - // Process - const processResult = await this.processService.process(image, fileType); - if (HasFailed(processResult)) return processResult; - - // Strip extension from filename - const name = (() => { - const index = filename.lastIndexOf('.'); - if (index === -1) return filename; - return filename.substring(0, index); - })(); - - // Save processed to db - const imageEntity = await this.imagesService.create( - userid, - name, - withDeleteKey, - ); - if (HasFailed(imageEntity)) return imageEntity; - - const imageFileEntity = await this.imageFilesService.setFile( - imageEntity.id, - ImageEntryVariant.MASTER, - processResult.image, - processResult.filetype, - ); - if (HasFailed(imageFileEntity)) return imageFileEntity; - - if (keepOriginal) { - const originalFileEntity = await this.imageFilesService.setFile( - imageEntity.id, - ImageEntryVariant.ORIGINAL, - image, - fileType.identifier, - ); - if (HasFailed(originalFileEntity)) return originalFileEntity; - } - - return imageEntity; - } - - public async getConverted( - imageId: string, - fileType: string, - options: ImageRequestParams, - ): AsyncFailable { - const targetFileType = ParseFileType(fileType); - if (HasFailed(targetFileType)) return targetFileType; - - const converted_key = this.getConvertHash({ mime: fileType, ...options }); - - const [save_derivatives, allow_editing] = await Promise.all([ - this.sysPref.getBooleanPreference(SysPreference.SaveDerivatives), - this.sysPref.getBooleanPreference(SysPreference.AllowEditing), - ]); - if (HasFailed(save_derivatives)) return save_derivatives; - if (HasFailed(allow_editing)) return allow_editing; - - return MutexFallBack( - converted_key, - () => { - if (save_derivatives) - return this.imageFilesService.getDerivative(imageId, converted_key); - else return Promise.resolve(null); - }, - async () => { - const masterImage = await this.getMaster(imageId); - if (HasFailed(masterImage)) return masterImage; - - const sourceFileType = ParseFileType(masterImage.filetype); - if (HasFailed(sourceFileType)) return sourceFileType; - - const startTime = Date.now(); - const convertResult = await this.convertService.convert( - masterImage.data, - sourceFileType, - targetFileType, - allow_editing ? options : {}, - ); - if (HasFailed(convertResult)) return convertResult; - - this.logger.verbose( - `Converted ${imageId} from ${sourceFileType.identifier} to ${ - targetFileType.identifier - } in ${Date.now() - startTime}ms`, - ); - - if (save_derivatives) { - return await this.imageFilesService.addDerivative( - imageId, - converted_key, - convertResult.filetype, - convertResult.image, - ); - } else { - const derivative = new EImageDerivativeBackend(); - derivative.filetype = convertResult.filetype; - derivative.data = convertResult.image; - derivative.image_id = imageId; - derivative.key = converted_key; - return derivative; - } - }, - ); - } - - // File getters ============================================================== - - public async getMaster(imageId: string): AsyncFailable { - return this.imageFilesService.getFile(imageId, ImageEntryVariant.MASTER); - } - - public async getMasterFileType(imageId: string): AsyncFailable { - const mime = await this.imageFilesService.getFileTypes(imageId); - if (HasFailed(mime)) return mime; - - if (mime['master'] === undefined) - return Fail(FT.NotFound, 'No master file'); - - return ParseFileType(mime['master']); - } - - public async getOriginal(imageId: string): AsyncFailable { - return this.imageFilesService.getFile(imageId, ImageEntryVariant.ORIGINAL); - } - - public async getOriginalFileType(imageId: string): AsyncFailable { - const filetypes = await this.imageFilesService.getFileTypes(imageId); - if (HasFailed(filetypes)) return filetypes; - - if (filetypes['original'] === undefined) - return Fail(FT.NotFound, 'No original file'); - - return ParseFileType(filetypes['original']); - } - - public async getFileMimes(imageId: string): AsyncFailable<{ - [ImageEntryVariant.MASTER]: string; - [ImageEntryVariant.ORIGINAL]: string | undefined; - }> { - const result = await this.imageFilesService.getFileTypes(imageId); - if (HasFailed(result)) return result; - - if (result[ImageEntryVariant.MASTER] === undefined) { - return Fail(FT.NotFound, 'No master file found'); - } - - return { - [ImageEntryVariant.MASTER]: result[ImageEntryVariant.MASTER]!, - [ImageEntryVariant.ORIGINAL]: result[ImageEntryVariant.ORIGINAL], - }; - } - - // Util stuff ================================================================== - - private async getFileTypeFromBuffer(image: Buffer): AsyncFailable { - const filetypeResult: FileTypeResult | undefined = await fileTypeFromBuffer( - image, - ); - - let mime: string | undefined; - if (filetypeResult === undefined) { - if (IsQOI(image)) mime = 'image/x-qoi'; - } else { - mime = filetypeResult.mime; - } - - if (mime === undefined) mime = 'other/unknown'; - - let filetype: string | undefined; - if (mime === 'image/webp') { - const header = await WebPInfo.from(image); - if (header.summary.isAnimated) filetype = AnimFileType.WEBP; - else filetype = ImageFileType.WEBP; - } - if (filetype === undefined) { - const parsed = Mime2FileType(mime); - if (HasFailed(parsed)) return parsed; - filetype = parsed; - } - - return ParseFileType(filetype); - } - - private getConvertHash(options: object) { - // Return a sha256 hash of the stringified options - const stringified = JSON.stringify(options); - const hash = Crypto.createHash('sha256'); - hash.update(stringified); - const digest = hash.digest('hex'); - return digest; - } -} diff --git a/backend/src/managers/ingest/ingest.consumer.ts b/backend/src/managers/image/ingest.consumer.ts similarity index 80% rename from backend/src/managers/ingest/ingest.consumer.ts rename to backend/src/managers/image/ingest.consumer.ts index d258cb1..69e397a 100644 --- a/backend/src/managers/ingest/ingest.consumer.ts +++ b/backend/src/managers/image/ingest.consumer.ts @@ -1,12 +1,6 @@ -import { - InjectQueue, - OnQueueError, - OnQueueFailed, - Process, - Processor -} from '@nestjs/bull'; +import { OnQueueError, OnQueueFailed, Process, Processor } from '@nestjs/bull'; import { Logger } from '@nestjs/common'; -import type { Job, Queue } from 'bull'; +import type { Job } from 'bull'; import { ImageEntryVariant } from 'picsur-shared/dist/dto/image-entry-variant.enum'; import { FileType, @@ -27,47 +21,28 @@ import { ImageFileDBService } from '../../collections/image-db/image-file-db.ser import { EImageBackend } from '../../database/entities/images/image.entity'; import { ImageConverterService } from '../image/image-converter.service'; import { ImageResult } from '../image/imageresult'; +import { ImageQueueID, ImageQueueSubject } from './image.queue'; -interface ImageIngestJobData { +export interface ImageIngestJobData { imageID: string; storeOriginal: boolean; } -export type ImageIngestQueue = Queue; export type ImageIngestJob = Job; -@Processor('image-ingest') +@Processor(ImageQueueID) export class IngestConsumer { private readonly logger = new Logger(IngestConsumer.name); - private i = 0; constructor( - @InjectQueue('image-ingest') private readonly ingestQueue: Queue, private readonly imagesService: ImageDBService, private readonly imageFilesService: ImageFileDBService, private readonly imageConverter: ImageConverterService, - ) { - this.logger.log('Ingest consumer started'); - this.logger.error('Ingest consumer started'); - } + ) {} - // @Process('group') - // async processJob(job: Job) { - // console.log('Received', job.data); - // await new Promise((resolve) => setTimeout(resolve, 4000)); - // console.log('Done'); - // return 'big chungus'; - // } - - @Process('image') - async processImage(job: ImageIngestJob): Promise { + @Process(ImageQueueSubject.INGEST) + async ingestImage(job: ImageIngestJob): Promise { const { imageID, storeOriginal } = job.data; - job.failedReason = 'Not implemented'; - - if (this.i === 0) { - throw Fail(FT.Internal, undefined, 'oops'); - } - // Already start the query for the image, we only need it when returning const imagePromise = this.imagesService.findOne(imageID, undefined); @@ -110,7 +85,7 @@ export class IngestConsumer { return image; } - public async process( + private async process( image: Buffer, filetype: FileType, ): AsyncFailable { diff --git a/backend/src/managers/ingest/ingest.service.ts b/backend/src/managers/image/ingest.service.ts similarity index 80% rename from backend/src/managers/ingest/ingest.service.ts rename to backend/src/managers/image/ingest.service.ts index dd0bae2..7922a90 100644 --- a/backend/src/managers/ingest/ingest.service.ts +++ b/backend/src/managers/image/ingest.service.ts @@ -17,14 +17,16 @@ import { ImageFileDBService } from '../../collections/image-db/image-file-db.ser import { UsrPreferenceDbService } from '../../collections/preference-db/usr-preference-db.service'; import { EImageBackend } from '../../database/entities/images/image.entity'; import { WebPInfo } from '../image/webpinfo/webpinfo'; -import type { ImageIngestJob, ImageIngestQueue } from './ingest.consumer'; +import * as ImageQueue from './image.queue'; +import { ImageIngestJob } from './ingest.consumer'; @Injectable() export class IngestService { private readonly logger = new Logger(IngestService.name); constructor( - @InjectQueue('image-ingest') private readonly ingestQueue: ImageIngestQueue, + @InjectQueue(ImageQueue.ImageQueueID) + private readonly imageQueue: ImageQueue.ImageQueueType, private readonly imagesService: ImageDBService, private readonly imageFilesService: ImageFileDBService, private readonly userPref: UsrPreferenceDbService, @@ -35,7 +37,7 @@ export class IngestService { filename: string, image: Buffer, withDeleteKey: boolean, - ): AsyncFailable { + ): AsyncFailable<[ImageIngestJob, EImageBackend]> { const fileType = await this.getFileTypeFromBuffer(image); if (HasFailed(fileType)) return fileType; @@ -69,13 +71,20 @@ export class IngestService { ); if (HasFailed(imageFileEntity)) return imageFileEntity; - const job = await this.ingestQueue.add('image', { - imageID: imageEntity.id, - storeOriginal: keepOriginal, - }); - if (!job.id) return Fail(FT.Internal, undefined, 'Failed to queue job'); + try { + const job = (await this.imageQueue.add( + ImageQueue.ImageQueueSubject.INGEST, + { + imageID: imageEntity.id, + storeOriginal: keepOriginal, + }, + )) as ImageIngestJob; + if (!job.id) return Fail(FT.Internal, undefined, 'Failed to queue job'); - return job; + return [job, imageEntity]; + } catch (e) { + return Fail(FT.Internal, e); + } } public async uploadPromise( @@ -84,11 +93,13 @@ export class IngestService { image: Buffer, withDeleteKey: boolean, ): AsyncFailable { - const job = await this.uploadJob(userid, filename, image, withDeleteKey); - if (HasFailed(job)) return job; + const result = await this.uploadJob(userid, filename, image, withDeleteKey); + if (HasFailed(result)) return result; + + const [job, imageEntity] = result; try { - const imageEntity: EImageBackend = await job.finished(); + await job.finished(); return imageEntity; } catch (e) { return Fail(FT.Internal, 'Failed to process image', e); diff --git a/backend/src/managers/ingest/ingest.module.ts b/backend/src/managers/ingest/ingest.module.ts deleted file mode 100644 index b75adec..0000000 --- a/backend/src/managers/ingest/ingest.module.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { BullModule } from '@nestjs/bull'; -import { Module } from '@nestjs/common'; -import { ImageDBModule } from '../../collections/image-db/image-db.module'; -import { PreferenceDbModule } from '../../collections/preference-db/preference-db.module'; -import { ImageManagerModule } from '../image/image.module'; -import { IngestConsumer } from './ingest.consumer'; -import { IngestService } from './ingest.service'; - -@Module({ - imports: [ - ImageDBModule, - ImageManagerModule, - PreferenceDbModule, - BullModule.registerQueue({ - name: 'image-ingest', - }), - ], - providers: [IngestConsumer, IngestService], - exports: [BullModule, IngestService], -}) -export class IngestManagerModule {} diff --git a/backend/src/routes/api/experiment/experiment.controller.ts b/backend/src/routes/api/experiment/experiment.controller.ts index 3305815..c7859b4 100644 --- a/backend/src/routes/api/experiment/experiment.controller.ts +++ b/backend/src/routes/api/experiment/experiment.controller.ts @@ -1,6 +1,4 @@ -import { InjectQueue } from '@nestjs/bull'; import { Controller, Get, Logger } from '@nestjs/common'; -import type { Queue } from 'bull'; import { NoPermissions } from '../../../decorators/permissions.decorator'; import { ReturnsAnything } from '../../../decorators/returns.decorator'; @@ -9,27 +7,11 @@ import { ReturnsAnything } from '../../../decorators/returns.decorator'; export class ExperimentController { private readonly logger = new Logger(ExperimentController.name); - constructor( - @InjectQueue('image-ingest') private readonly ingestQueue: Queue, - ) { - this.logger.log('experiment consumer started'); - this.logger.error('experiment consumer started'); - console.log(this.logger); - } + constructor() {} @Get() @ReturnsAnything() async testRoute(): Promise { - console.log('Create job'); - const job = await this.ingestQueue.add({ - foo: Buffer.from('aaaaaheleool'), - }); - console.log('Job created', job.id); - - const result = await job.finished(); - - console.log('Job finished', result); - return 'ok'; } } diff --git a/backend/src/routes/api/experiment/experiment.module.ts b/backend/src/routes/api/experiment/experiment.module.ts index 8fed456..cd6336f 100644 --- a/backend/src/routes/api/experiment/experiment.module.ts +++ b/backend/src/routes/api/experiment/experiment.module.ts @@ -1,13 +1,13 @@ import { Module } from '@nestjs/common'; import { PicsurLoggerModule } from '../../../logger/logger.module'; -import { IngestManagerModule } from '../../../managers/ingest/ingest.module'; +import { ImageManagerModule } from '../../../managers/image/image-manager.module'; import { ExperimentController } from './experiment.controller'; // This is comletely useless module, but is used for testing // TODO: remove when out of beta @Module({ - imports: [IngestManagerModule, PicsurLoggerModule], + imports: [ImageManagerModule, PicsurLoggerModule], controllers: [ExperimentController] }) export class ExperimentModule {} diff --git a/backend/src/routes/image/image-manage.controller.ts b/backend/src/routes/image/image-manage.controller.ts index 4e086b3..c5a51e4 100644 --- a/backend/src/routes/image/image-manage.controller.ts +++ b/backend/src/routes/image/image-manage.controller.ts @@ -30,8 +30,8 @@ import { } from '../../decorators/permissions.decorator'; import { ReqUserID } from '../../decorators/request-user.decorator'; import { Returns, ReturnsAnything } from '../../decorators/returns.decorator'; -import { ImageManagerService } from '../../managers/image/image.service'; -import { IngestService } from '../../managers/ingest/ingest.service'; +import { ImageManagerService } from '../../managers/image/image-manager.service'; +import { IngestService } from '../../managers/image/ingest.service'; import { GetNextAsync } from '../../util/iterator'; @Controller('api/image') @RequiredPermissions(Permission.ImageUpload) diff --git a/backend/src/routes/image/image.controller.ts b/backend/src/routes/image/image.controller.ts index 6335991..2eb5a0a 100644 --- a/backend/src/routes/image/image.controller.ts +++ b/backend/src/routes/image/image.controller.ts @@ -13,7 +13,8 @@ import { ImageFullIdParam } from '../../decorators/image-id/image-full-id.decora import { ImageIdParam } from '../../decorators/image-id/image-id.decorator'; import { RequiredPermissions } from '../../decorators/permissions.decorator'; import { Returns } from '../../decorators/returns.decorator'; -import { ImageManagerService } from '../../managers/image/image.service'; +import { ConvertService } from '../../managers/image/convert.service'; +import { ImageManagerService } from '../../managers/image/image-manager.service'; import type { ImageFullId } from '../../models/constants/image-full-id.const'; import { Permission } from '../../models/constants/permissions.const'; import { EUserBackend2EUser } from '../../models/transformers/user.transformer'; @@ -29,6 +30,7 @@ export class ImageController { constructor( private readonly imagesService: ImageManagerService, private readonly userService: UserDbService, + private readonly convertService: ConvertService, ) {} @Head(':id') @@ -67,7 +69,7 @@ export class ImageController { } const image = ThrowIfFailed( - await this.imagesService.getConverted( + await this.convertService.convertPromise( fullid.id, fullid.filetype, params, diff --git a/backend/src/routes/image/image.module.ts b/backend/src/routes/image/image.module.ts index fb43c69..0828e38 100644 --- a/backend/src/routes/image/image.module.ts +++ b/backend/src/routes/image/image.module.ts @@ -1,9 +1,7 @@ import { Module } from '@nestjs/common'; -import { IngestFileDbModule } from '../../collections/ingest-file-db/ingest-file-db.module'; import { UserDbModule } from '../../collections/user-db/user-db.module'; import { DecoratorsModule } from '../../decorators/decorators.module'; -import { ImageManagerModule } from '../../managers/image/image.module'; -import { IngestManagerModule } from '../../managers/ingest/ingest.module'; +import { ImageManagerModule } from '../../managers/image/image-manager.module'; import { ImageManageController } from './image-manage.controller'; import { ImageController } from './image.controller'; @@ -11,8 +9,6 @@ import { ImageController } from './image.controller'; imports: [ ImageManagerModule, UserDbModule, - IngestFileDbModule, - IngestManagerModule, DecoratorsModule, ], controllers: [ImageController, ImageManageController], diff --git a/frontend/src/app/i18n/sys-pref.i18n.ts b/frontend/src/app/i18n/sys-pref.i18n.ts index ed42bf2..f6415da 100644 --- a/frontend/src/app/i18n/sys-pref.i18n.ts +++ b/frontend/src/app/i18n/sys-pref.i18n.ts @@ -17,13 +17,7 @@ export const SysPreferenceUI: { [SysPreference.RemoveDerivativesAfter]: { name: 'Cached Images Expiry Time', helpText: - 'Time before cached images are deleted. This does not affect the original image. Set to 0 to disable.', - category: 'Image Processing', - }, - [SysPreference.SaveDerivatives]: { - name: 'Cache Converted Images', - helpText: - 'Cache converted images, this will reduce the time it takes to load images. It does however use more disk space.', + 'Time before cached converted images are deleted. This does not affect the original image. A lower cache time will save on disk space but cost more cpu. Set to 0 to disable.', category: 'Image Processing', }, [SysPreference.AllowEditing]: { diff --git a/shared/src/dto/sys-preferences.enum.ts b/shared/src/dto/sys-preferences.enum.ts index 8edba0b..93953df 100644 --- a/shared/src/dto/sys-preferences.enum.ts +++ b/shared/src/dto/sys-preferences.enum.ts @@ -13,7 +13,6 @@ export enum SysPreference { JwtExpiresIn = 'jwt_expires_in', BCryptStrength = 'bcrypt_strength', - SaveDerivatives = 'save_derivatives', RemoveDerivativesAfter = 'remove_derivatives_after', AllowEditing = 'allow_editing', @@ -41,7 +40,6 @@ export const SysPreferenceValueTypes: { [SysPreference.BCryptStrength]: 'number', [SysPreference.RemoveDerivativesAfter]: 'string', - [SysPreference.SaveDerivatives]: 'boolean', [SysPreference.AllowEditing]: 'boolean', [SysPreference.ConversionTimeLimit]: 'string', @@ -63,8 +61,7 @@ export const SysPreferenceValidators: { [SysPreference.JwtExpiresIn]: IsValidMS(), [SysPreference.BCryptStrength]: IsPosInt(), - [SysPreference.RemoveDerivativesAfter]: IsValidMS(), - [SysPreference.SaveDerivatives]: z.boolean(), + [SysPreference.RemoveDerivativesAfter]: IsValidMS(60000), [SysPreference.AllowEditing]: z.boolean(), [SysPreference.ConversionTimeLimit]: IsValidMS(), diff --git a/shared/src/validators/ms.validator.ts b/shared/src/validators/ms.validator.ts index bcbfb2a..3192aae 100644 --- a/shared/src/validators/ms.validator.ts +++ b/shared/src/validators/ms.validator.ts @@ -1,7 +1,7 @@ import ms from 'ms'; import { z } from 'zod'; -export const IsValidMS = () => +export const IsValidMS = (min = 0) => z.preprocess( (v: any) => { try { @@ -17,5 +17,5 @@ export const IsValidMS = () => }), }) .int() - .min(0), + .min(min), ); diff --git a/yarn.lock b/yarn.lock index 06a5bd0..2bf7aa5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -39,17 +39,7 @@ __metadata: languageName: node linkType: hard -"@angular-devkit/architect@npm:0.1402.2, @angular-devkit/architect@npm:>=0.1400.0 < 0.1500.0": - version: 0.1402.2 - resolution: "@angular-devkit/architect@npm:0.1402.2" - dependencies: - "@angular-devkit/core": 14.2.2 - rxjs: 6.6.7 - checksum: a014bbd941582ad4a263b9d8870accef78c5e26528ba3f7a760c860dbb4ba6b66216361f562da591b579662f2710fb5810d748610aa17b7b4c17f4caa08794e3 - languageName: node - linkType: hard - -"@angular-devkit/architect@npm:0.1402.3": +"@angular-devkit/architect@npm:0.1402.3, @angular-devkit/architect@npm:>=0.1400.0 < 0.1500.0": version: 0.1402.3 resolution: "@angular-devkit/architect@npm:0.1402.3" dependencies: @@ -59,7 +49,7 @@ __metadata: languageName: node linkType: hard -"@angular-devkit/build-angular@npm:14.2.3": +"@angular-devkit/build-angular@npm:14.2.3, @angular-devkit/build-angular@npm:^14.0.0": version: 14.2.3 resolution: "@angular-devkit/build-angular@npm:14.2.3" dependencies: @@ -155,115 +145,6 @@ __metadata: languageName: node linkType: hard -"@angular-devkit/build-angular@npm:^14.0.0": - version: 14.2.2 - resolution: "@angular-devkit/build-angular@npm:14.2.2" - dependencies: - "@ampproject/remapping": 2.2.0 - "@angular-devkit/architect": 0.1402.2 - "@angular-devkit/build-webpack": 0.1402.2 - "@angular-devkit/core": 14.2.2 - "@babel/core": 7.18.10 - "@babel/generator": 7.18.12 - "@babel/helper-annotate-as-pure": 7.18.6 - "@babel/plugin-proposal-async-generator-functions": 7.18.10 - "@babel/plugin-transform-async-to-generator": 7.18.6 - "@babel/plugin-transform-runtime": 7.18.10 - "@babel/preset-env": 7.18.10 - "@babel/runtime": 7.18.9 - "@babel/template": 7.18.10 - "@discoveryjs/json-ext": 0.5.7 - "@ngtools/webpack": 14.2.2 - ansi-colors: 4.1.3 - babel-loader: 8.2.5 - babel-plugin-istanbul: 6.1.1 - browserslist: ^4.9.1 - cacache: 16.1.2 - copy-webpack-plugin: 11.0.0 - critters: 0.0.16 - css-loader: 6.7.1 - esbuild: 0.15.5 - esbuild-wasm: 0.15.5 - glob: 8.0.3 - https-proxy-agent: 5.0.1 - inquirer: 8.2.4 - jsonc-parser: 3.1.0 - karma-source-map-support: 1.4.0 - less: 4.1.3 - less-loader: 11.0.0 - license-webpack-plugin: 4.0.2 - loader-utils: 3.2.0 - mini-css-extract-plugin: 2.6.1 - minimatch: 5.1.0 - open: 8.4.0 - ora: 5.4.1 - parse5-html-rewriting-stream: 6.0.1 - piscina: 3.2.0 - postcss: 8.4.16 - postcss-import: 15.0.0 - postcss-loader: 7.0.1 - postcss-preset-env: 7.8.0 - regenerator-runtime: 0.13.9 - resolve-url-loader: 5.0.0 - rxjs: 6.6.7 - sass: 1.54.4 - sass-loader: 13.0.2 - semver: 7.3.7 - source-map-loader: 4.0.0 - source-map-support: 0.5.21 - stylus: 0.59.0 - stylus-loader: 7.0.0 - terser: 5.14.2 - text-table: 0.2.0 - tree-kill: 1.2.2 - tslib: 2.4.0 - webpack: 5.74.0 - webpack-dev-middleware: 5.3.3 - webpack-dev-server: 4.11.0 - webpack-merge: 5.8.0 - webpack-subresource-integrity: 5.1.0 - peerDependencies: - "@angular/compiler-cli": ^14.0.0 - "@angular/localize": ^14.0.0 - "@angular/service-worker": ^14.0.0 - karma: ^6.3.0 - ng-packagr: ^14.0.0 - protractor: ^7.0.0 - tailwindcss: ^2.0.0 || ^3.0.0 - typescript: ">=4.6.2 <4.9" - dependenciesMeta: - esbuild: - optional: true - peerDependenciesMeta: - "@angular/localize": - optional: true - "@angular/service-worker": - optional: true - karma: - optional: true - ng-packagr: - optional: true - protractor: - optional: true - tailwindcss: - optional: true - checksum: 3829654f52b26bc7bd5c753f867a0b24d649e0b650c24cd6ff0ecbfc0708bab8403c64ece40a349147d2104dd21472ac71e7cf7b8e58e1622f430fab99701086 - languageName: node - linkType: hard - -"@angular-devkit/build-webpack@npm:0.1402.2": - version: 0.1402.2 - resolution: "@angular-devkit/build-webpack@npm:0.1402.2" - dependencies: - "@angular-devkit/architect": 0.1402.2 - rxjs: 6.6.7 - peerDependencies: - webpack: ^5.30.0 - webpack-dev-server: ^4.0.0 - checksum: fb97fa5eceb043cefce63ee4a8a598496002506c05423437914b721c41e78408750d28b42b9dc1d4594c1c599ea5c28b7be1903b8a3041c46c1e9dd1f2d7575c - languageName: node - linkType: hard - "@angular-devkit/build-webpack@npm:0.1402.3": version: 0.1402.3 resolution: "@angular-devkit/build-webpack@npm:0.1402.3" @@ -295,7 +176,7 @@ __metadata: languageName: node linkType: hard -"@angular-devkit/core@npm:14.2.2, @angular-devkit/core@npm:^14.0.0": +"@angular-devkit/core@npm:14.2.2": version: 14.2.2 resolution: "@angular-devkit/core@npm:14.2.2" dependencies: @@ -313,7 +194,7 @@ __metadata: languageName: node linkType: hard -"@angular-devkit/core@npm:14.2.3": +"@angular-devkit/core@npm:14.2.3, @angular-devkit/core@npm:^14.0.0": version: 14.2.3 resolution: "@angular-devkit/core@npm:14.2.3" dependencies: @@ -2745,17 +2626,6 @@ __metadata: languageName: node linkType: hard -"@ngtools/webpack@npm:14.2.2": - version: 14.2.2 - resolution: "@ngtools/webpack@npm:14.2.2" - peerDependencies: - "@angular/compiler-cli": ^14.0.0 - typescript: ">=4.6.2 <4.9" - webpack: ^5.54.0 - checksum: fce1268f3686ed1f974161bed3662915a98a90c75b7bc8fb166fea346794351ef92bc5bb3cb95714f9d235beaa1b14c8856d4ca8d7eac431d30918a96d404df8 - languageName: node - linkType: hard - "@ngtools/webpack@npm:14.2.3": version: 14.2.3 resolution: "@ngtools/webpack@npm:14.2.3" @@ -3177,14 +3047,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:*": - version: 18.7.16 - resolution: "@types/node@npm:18.7.16" - checksum: 01a3d35c764a3f0e7370b56e1ad4203731131883c65784e020009014171b3f53c4649cde6c7aa4f1026b907ee87ef6ae6ece2bc518151dc7b81100fe8b1db3ad - languageName: node - linkType: hard - -"@types/node@npm:>=10.0.0, @types/node@npm:^18.7.18": +"@types/node@npm:*, @types/node@npm:>=10.0.0, @types/node@npm:^18.7.18": version: 18.7.18 resolution: "@types/node@npm:18.7.18" checksum: 8aec61f0f96e2a69ce51f1f40f949ca578bbb4fe05d7c0b8ce3aeeb848e90f755837f17f6ac132ca404d974fe9b2974150ad3b4984fc9dc7c3ceddb10bae0167