diff --git a/.changeset/dam-dominant-color-service.md b/.changeset/dam-dominant-color-service.md new file mode 100644 index 00000000000..552df7686e0 --- /dev/null +++ b/.changeset/dam-dominant-color-service.md @@ -0,0 +1,9 @@ +--- +"@comet/cms-api": minor +--- + +Deprecate `FilesService.calculateDominantColor` + +Computing the dominant color of an image was the only part of `FilesService` that used imgproxy. It moved to an internal service that `DamImagesModule` provides, so file handling no longer depends on imgproxy. + +`FilesService.calculateDominantColor` returns the color when `DamImagesModule` is registered, and `undefined` when it is not. diff --git a/.changeset/dam-module-split.md b/.changeset/dam-module-split.md new file mode 100644 index 00000000000..61f06c4b637 --- /dev/null +++ b/.changeset/dam-module-split.md @@ -0,0 +1,17 @@ +--- +"@comet/cms-api": minor +--- + +Split `DamModule` into composable sub-modules + +`DamModule` is now a facade composing four sub-modules: `DamFilesModule` (file and folder storage, upload and serving), `DamImagesModule` (image scaling via imgproxy, dominant color), `DamBlocksModule` (block transformers) and `DamDependentsModule` (the `dependents` field on `DamFile`). `DamModule.register()` keeps its signature and still composes all four. + +`DamModule` and its sub-modules now throw when they are registered more than once in the same process. A second registration would mount the DAM routes twice and add the `dependents` field to the file type again, so it never worked as intended. + +Projects that only need DAM file upload and storage can register `DamFilesModule` on its own, without `ImgproxyModule` and without `DependenciesModule`: + +```ts +DamFilesModule.register({ damConfig, Scope: DamScope, File: DamFile, Folder: DamFolder }); +``` + +In that setup, `FilesService` receives no dominant color calculator, so uploads skip the color and `FilesService.calculateDominantColor` returns `undefined`. diff --git a/packages/api/cms-api/src/dam/dam-blocks.module.ts b/packages/api/cms-api/src/dam/dam-blocks.module.ts new file mode 100644 index 00000000000..df712ee13d4 --- /dev/null +++ b/packages/api/cms-api/src/dam/dam-blocks.module.ts @@ -0,0 +1,34 @@ +import { DynamicModule, Global, Module } from "@nestjs/common"; + +import { DamFileDownloadLinkBlockTransformerService } from "./blocks/dam-file-download-link-block-transformer.service"; +import { PixelImageBlockTransformerService } from "./blocks/pixel-image-block-transformer.service"; +import { SvgImageBlockTransformerService } from "./blocks/svg-image-block-transformer.service"; +import { DamVideoBlockTransformerService } from "./blocks/video/dam-video-block-transformer.service"; + +@Global() +@Module({}) +export class DamBlocksModule { + private static registered = false; + + static register(): DynamicModule { + // The module is global and exports the block transformer services. DamModule registers it internally, so registering both + // would leave two instances of each service, one of them silently unused. + if (DamBlocksModule.registered) { + throw new Error( + "DamBlocksModule has already been registered. It is registered by DamModule, so register either DamModule or DamBlocksModule, not both.", + ); + } + DamBlocksModule.registered = true; + + return { + module: DamBlocksModule, + providers: [ + PixelImageBlockTransformerService, + SvgImageBlockTransformerService, + DamVideoBlockTransformerService, + DamFileDownloadLinkBlockTransformerService, + ], + exports: [PixelImageBlockTransformerService, SvgImageBlockTransformerService, DamVideoBlockTransformerService], + }; + } +} diff --git a/packages/api/cms-api/src/dam/dam-dependents.module.ts b/packages/api/cms-api/src/dam/dam-dependents.module.ts new file mode 100644 index 00000000000..680fe18c11f --- /dev/null +++ b/packages/api/cms-api/src/dam/dam-dependents.module.ts @@ -0,0 +1,29 @@ +import { DynamicModule, Module, Type } from "@nestjs/common"; + +import { DependentsResolverFactory } from "../dependencies/dependents.resolver.factory"; +import { FileInterface } from "./files/entities/file.entity"; + +interface DamDependentsModuleOptions { + File: Type; +} + +@Module({}) +export class DamDependentsModule { + private static registered = false; + + static register({ File }: DamDependentsModuleOptions): DynamicModule { + // Every registration creates another resolver adding the dependents field to the file type, which breaks the schema. + // DamModule registers the module internally, so registering both would do exactly that. + if (DamDependentsModule.registered) { + throw new Error( + "DamDependentsModule has already been registered. It is registered by DamModule, so register either DamModule or DamDependentsModule, not both.", + ); + } + DamDependentsModule.registered = true; + + return { + module: DamDependentsModule, + providers: [DependentsResolverFactory.create(File)], + }; + } +} diff --git a/packages/api/cms-api/src/dam/dam-files.module.ts b/packages/api/cms-api/src/dam/dam-files.module.ts new file mode 100644 index 00000000000..f13eec30bee --- /dev/null +++ b/packages/api/cms-api/src/dam/dam-files.module.ts @@ -0,0 +1,120 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { DynamicModule, Global, Module, Type, ValueProvider } from "@nestjs/common"; +import { TypeMetadataStorage } from "@nestjs/graphql"; + +import { FileValidationService } from "../file-utils/file-validation.service"; +import { HasValidFilenameConstraint } from "./common/decorators/has-valid-filename.decorator"; +import { damDefaultAcceptedMimetypes } from "./common/mimeTypes/dam-default-accepted-mimetypes"; +import { DamConfig, damDefaultBasePath } from "./dam.config"; +import { DAM_CONFIG, DAM_FILE_VALIDATION_SERVICE } from "./dam.constants"; +import { createDamItemsResolver } from "./files/dam-items.resolver"; +import { DamItemsService } from "./files/dam-items.service"; +import { createDamMediaAlternativeResolver } from "./files/dam-media-alternatives/dam-media-alternative.resolver"; +import { DamMediaAlternative } from "./files/dam-media-alternatives/entities/dam-media-alternative.entity"; +import { FILE_ENTITY, FileInterface } from "./files/entities/file.entity"; +import { DamFileImage } from "./files/entities/file-image.entity"; +import { FolderInterface } from "./files/entities/folder.entity"; +import { FileLicensesResolver } from "./files/file-licenses.resolver"; +import { FileWarningService } from "./files/file-warning.service"; +import { createFilesController } from "./files/files.controller"; +import { createFilesResolver } from "./files/files.resolver"; +import { FilesService } from "./files/files.service"; +import { createFoldersController } from "./files/folders.controller"; +import { createFoldersResolver } from "./files/folders.resolver"; +import { FoldersService } from "./files/folders.service"; +import { ImageCropArea } from "./images/entities/image-crop-area.entity"; +import { DamScopeInterface } from "./types"; + +interface DamFilesModuleOptions { + damConfig: Omit & { basePath?: string }; + Scope?: Type; + Folder: Type; + File: Type; +} + +@Global() +@Module({}) +export class DamFilesModule { + private static registered = false; + + static register({ damConfig: damConfigOptions, Scope, Folder, File }: DamFilesModuleOptions): DynamicModule { + // The module is global and declares the DAM file and folder routes. DamModule registers it internally, so registering + // both would mount those routes twice. + if (DamFilesModule.registered) { + throw new Error( + "DamFilesModule has already been registered. It is registered by DamModule, so register either DamModule or DamFilesModule, not both.", + ); + } + DamFilesModule.registered = true; + + if (File.name !== FILE_ENTITY) { + throw new Error(`DamModule: Your File entity must be named ${FILE_ENTITY}`); + } + + const damConfig: DamConfig = { + ...damConfigOptions, + basePath: damConfigOptions.basePath ?? damDefaultBasePath, + }; + + const damConfigProvider: ValueProvider = { + provide: DAM_CONFIG, + useValue: damConfig, + }; + + const fileValidationServiceProvider = { + provide: DAM_FILE_VALIDATION_SERVICE, + useValue: new FileValidationService({ + maxFileSize: damConfig.maxFileSize, + acceptedMimeTypes: damConfig.acceptedMimeTypes ?? damDefaultAcceptedMimetypes, + }), + }; + + const DamItemsResolver = createDamItemsResolver({ File, Folder, Scope }); + const FilesResolver = createFilesResolver({ File, Folder, Scope }); + const FoldersResolver = createFoldersResolver({ Folder, Scope }); + const DamMediaAlternativeResolver = createDamMediaAlternativeResolver({ File, Scope }); + + if (Scope) { + // Scope validation needs to happen after resolver generation. Otherwise the input type metadata has not been defined yet. + const scopeObjectType = TypeMetadataStorage.getObjectTypeMetadataByTarget(Scope); + + if (scopeObjectType?.name !== "DamScope") { + throw new Error( + `Invalid object type name for provided DAM scope class. Make sure to decorate the class with @ObjectType("DamScope")`, + ); + } + + const scopeInputType = TypeMetadataStorage.getInputTypeMetadataByTarget(Scope); + + if (scopeInputType?.name !== "DamScopeInput") { + throw new Error( + `Invalid input type name for provided DAM scope class. Make sure to decorate the class with @InputType("DamScopeInput")`, + ); + } + } + + return { + module: DamFilesModule, + imports: [MikroOrmModule.forFeature([File, Folder, DamFileImage, ImageCropArea, DamMediaAlternative])], + providers: [ + damConfigProvider, + fileValidationServiceProvider, + DamItemsResolver, + DamItemsService, + FilesResolver, + FilesService, + FileLicensesResolver, + FoldersResolver, + FoldersService, + HasValidFilenameConstraint, + FileWarningService, + DamMediaAlternativeResolver, + ], + controllers: [ + createFilesController({ Scope, damBasePath: damConfig.basePath }), + createFoldersController({ damBasePath: damConfig.basePath }), + ], + exports: [FilesService, FoldersService, DamItemsService, damConfigProvider], + }; + } +} diff --git a/packages/api/cms-api/src/dam/dam-images.module.ts b/packages/api/cms-api/src/dam/dam-images.module.ts new file mode 100644 index 00000000000..a662461c5de --- /dev/null +++ b/packages/api/cms-api/src/dam/dam-images.module.ts @@ -0,0 +1,48 @@ +import { DynamicModule, Global, Module } from "@nestjs/common"; + +import { DAM_DOMINANT_COLOR_CALCULATOR } from "./dam.constants"; +import { CalculateDominantImageColorCommand } from "./images/calculateDominantImageColor.command"; +import { DamDominantColorService } from "./images/dam-dominant-color.service"; +import { FileImagesResolver } from "./images/file-image.resolver"; +import { createImagesController } from "./images/images.controller"; +import { ImagesService } from "./images/images.service"; +import { IsAllowedImageAspectRatioConstraint } from "./images/validators/is-allowed-aspect-ratio.validator"; +import { IsAllowedImageSizeConstraint } from "./images/validators/is-allowed-image-size.validator"; +import { IsValidImageAspectRatioConstraint } from "./images/validators/is-valid-aspect-ratio.validator"; + +interface DamImagesModuleOptions { + damBasePath: string; +} + +@Global() +@Module({}) +export class DamImagesModule { + private static registered = false; + + static register({ damBasePath }: DamImagesModuleOptions): DynamicModule { + // The module is global and declares the DAM image routes. DamModule registers it internally, so registering both would + // mount those routes twice. + if (DamImagesModule.registered) { + throw new Error( + "DamImagesModule has already been registered. It is registered by DamModule, so register either DamModule or DamImagesModule, not both.", + ); + } + DamImagesModule.registered = true; + + return { + module: DamImagesModule, + providers: [ + ImagesService, + DamDominantColorService, + { provide: DAM_DOMINANT_COLOR_CALCULATOR, useExisting: DamDominantColorService }, + CalculateDominantImageColorCommand, + FileImagesResolver, + IsAllowedImageSizeConstraint, + IsAllowedImageAspectRatioConstraint, + IsValidImageAspectRatioConstraint, + ], + controllers: [createImagesController({ damBasePath })], + exports: [ImagesService, DamDominantColorService, DAM_DOMINANT_COLOR_CALCULATOR], + }; + } +} diff --git a/packages/api/cms-api/src/dam/dam.config.ts b/packages/api/cms-api/src/dam/dam.config.ts index b2944558fed..8669e785c01 100644 --- a/packages/api/cms-api/src/dam/dam.config.ts +++ b/packages/api/cms-api/src/dam/dam.config.ts @@ -1,3 +1,5 @@ +export const damDefaultBasePath = "dam"; + export interface DamConfig { secret: string; allowedImageSizes: number[]; diff --git a/packages/api/cms-api/src/dam/dam.constants.ts b/packages/api/cms-api/src/dam/dam.constants.ts index 5dec10df476..d332b921d13 100644 --- a/packages/api/cms-api/src/dam/dam.constants.ts +++ b/packages/api/cms-api/src/dam/dam.constants.ts @@ -1,2 +1,3 @@ export const DAM_CONFIG = "dam-config"; export const DAM_FILE_VALIDATION_SERVICE = "dam-file-validation-service"; +export const DAM_DOMINANT_COLOR_CALCULATOR = "dam-dominant-color-calculator"; diff --git a/packages/api/cms-api/src/dam/dam.module.ts b/packages/api/cms-api/src/dam/dam.module.ts index 66b9c7104e2..bd3edb1bb56 100644 --- a/packages/api/cms-api/src/dam/dam.module.ts +++ b/packages/api/cms-api/src/dam/dam.module.ts @@ -1,40 +1,12 @@ -import { MikroOrmModule } from "@mikro-orm/nestjs"; -import { DynamicModule, Global, Module, Type, ValueProvider } from "@nestjs/common"; -import { TypeMetadataStorage } from "@nestjs/graphql"; - -import { BlobStorageModule, damDefaultAcceptedMimetypes, DependentsResolverFactory } from ".."; -import { FileValidationService } from "../file-utils/file-validation.service"; -import { ImgproxyModule } from "../imgproxy/imgproxy.module"; -import { DamFileDownloadLinkBlockTransformerService } from "./blocks/dam-file-download-link-block-transformer.service"; -import { PixelImageBlockTransformerService } from "./blocks/pixel-image-block-transformer.service"; -import { SvgImageBlockTransformerService } from "./blocks/svg-image-block-transformer.service"; -import { DamVideoBlockTransformerService } from "./blocks/video/dam-video-block-transformer.service"; -import { HasValidFilenameConstraint } from "./common/decorators/has-valid-filename.decorator"; -import { DamConfig } from "./dam.config"; -import { DAM_CONFIG, DAM_FILE_VALIDATION_SERVICE } from "./dam.constants"; -import { createDamItemsResolver } from "./files/dam-items.resolver"; -import { DamItemsService } from "./files/dam-items.service"; -import { createDamMediaAlternativeResolver } from "./files/dam-media-alternatives/dam-media-alternative.resolver"; -import { DamMediaAlternative } from "./files/dam-media-alternatives/entities/dam-media-alternative.entity"; -import { createFileEntity, FILE_ENTITY, FileInterface } from "./files/entities/file.entity"; -import { DamFileImage } from "./files/entities/file-image.entity"; +import { DynamicModule, Global, Module, Type } from "@nestjs/common"; + +import { DamConfig, damDefaultBasePath } from "./dam.config"; +import { DamBlocksModule } from "./dam-blocks.module"; +import { DamDependentsModule } from "./dam-dependents.module"; +import { DamFilesModule } from "./dam-files.module"; +import { DamImagesModule } from "./dam-images.module"; +import { createFileEntity, FileInterface } from "./files/entities/file.entity"; import { createFolderEntity, FolderInterface } from "./files/entities/folder.entity"; -import { FileImagesResolver } from "./files/file-image.resolver"; -import { FileLicensesResolver } from "./files/file-licenses.resolver"; -import { FileWarningService } from "./files/file-warning.service"; -import { createFilesController } from "./files/files.controller"; -import { createFilesResolver } from "./files/files.resolver"; -import { FilesService } from "./files/files.service"; -import { createFoldersController } from "./files/folders.controller"; -import { createFoldersResolver } from "./files/folders.resolver"; -import { FoldersService } from "./files/folders.service"; -import { CalculateDominantImageColorCommand } from "./images/calculateDominantImageColor.command"; -import { ImageCropArea } from "./images/entities/image-crop-area.entity"; -import { createImagesController } from "./images/images.controller"; -import { ImagesService } from "./images/images.service"; -import { IsAllowedImageAspectRatioConstraint } from "./images/validators/is-allowed-aspect-ratio.validator"; -import { IsAllowedImageSizeConstraint } from "./images/validators/is-allowed-image-size.validator"; -import { IsValidImageAspectRatioConstraint } from "./images/validators/is-valid-aspect-ratio.validator"; import { DamScopeInterface } from "./types"; interface DamModuleOptions { @@ -47,101 +19,33 @@ interface DamModuleOptions { @Global() @Module({}) export class DamModule { + private static registered = false; + static register({ Scope, Folder = createFolderEntity({ Scope }), File = createFileEntity({ Scope, Folder }), ...options }: DamModuleOptions): DynamicModule { - const damConfig = { - ...options.damConfig, - basePath: options.damConfig.basePath ?? "dam", - }; - - if (File.name !== FILE_ENTITY) { - throw new Error(`DamModule: Your File entity must be named ${FILE_ENTITY}`); + if (DamModule.registered) { + throw new Error("DamModule has already been registered. Make sure to register it only once in your application."); } + DamModule.registered = true; - const damConfigProvider: ValueProvider = { - provide: DAM_CONFIG, - useValue: damConfig, - }; - - const fileValidationServiceProvider = { - provide: DAM_FILE_VALIDATION_SERVICE, - useValue: new FileValidationService({ - maxFileSize: damConfig.maxFileSize, - acceptedMimeTypes: damConfig.acceptedMimeTypes ?? damDefaultAcceptedMimetypes, - }), + const damConfig = { + ...options.damConfig, + basePath: options.damConfig.basePath ?? damDefaultBasePath, }; - const DamItemsResolver = createDamItemsResolver({ File, Folder, Scope }); - const FilesResolver = createFilesResolver({ File, Folder, Scope }); - const FileDependentsResolver = DependentsResolverFactory.create(File); - const FoldersResolver = createFoldersResolver({ Folder, Scope }); - const DamMediaAlternativeResolver = createDamMediaAlternativeResolver({ File, Scope }); - - if (Scope) { - // Scope validation needs to happen after resolver generation. Otherwise the input type metadata has not been defined yet. - const scopeObjectType = TypeMetadataStorage.getObjectTypeMetadataByTarget(Scope); - - if (scopeObjectType?.name !== "DamScope") { - throw new Error( - `Invalid object type name for provided DAM scope class. Make sure to decorate the class with @ObjectType("DamScope")`, - ); - } - - const scopeInputType = TypeMetadataStorage.getInputTypeMetadataByTarget(Scope); - - if (scopeInputType?.name !== "DamScopeInput") { - throw new Error( - `Invalid input type name for provided DAM scope class. Make sure to decorate the class with @InputType("DamScopeInput")`, - ); - } - } - return { module: DamModule, - imports: [MikroOrmModule.forFeature([File, Folder, DamFileImage, ImageCropArea, DamMediaAlternative]), BlobStorageModule, ImgproxyModule], - providers: [ - damConfigProvider, - DamItemsResolver, - DamItemsService, - fileValidationServiceProvider, - FilesResolver, - FileDependentsResolver, - FilesService, - FileLicensesResolver, - FoldersResolver, - FoldersService, - ImagesService, - IsAllowedImageSizeConstraint, - IsAllowedImageAspectRatioConstraint, - IsValidImageAspectRatioConstraint, - FileImagesResolver, - CalculateDominantImageColorCommand, - PixelImageBlockTransformerService, - SvgImageBlockTransformerService, - DamVideoBlockTransformerService, - DamFileDownloadLinkBlockTransformerService, - HasValidFilenameConstraint, - FileWarningService, - DamMediaAlternativeResolver, - ], - controllers: [ - createFilesController({ Scope, damBasePath: damConfig.basePath }), - createFoldersController({ damBasePath: damConfig.basePath }), - createImagesController({ damBasePath: damConfig.basePath }), - ], - exports: [ - FilesService, - FoldersService, - ImagesService, - damConfigProvider, - PixelImageBlockTransformerService, - SvgImageBlockTransformerService, - DamVideoBlockTransformerService, + imports: [ + DamFilesModule.register({ damConfig, Scope, Folder, File }), + DamDependentsModule.register({ File }), + DamImagesModule.register({ damBasePath: damConfig.basePath }), + DamBlocksModule.register(), ], + exports: [DamFilesModule, DamImagesModule, DamBlocksModule], }; } } diff --git a/packages/api/cms-api/src/dam/dominant-color-calculator.interface.ts b/packages/api/cms-api/src/dam/dominant-color-calculator.interface.ts new file mode 100644 index 00000000000..74c62950f02 --- /dev/null +++ b/packages/api/cms-api/src/dam/dominant-color-calculator.interface.ts @@ -0,0 +1,3 @@ +export interface DominantColorCalculatorInterface { + calculateDominantColor(contentHash: string): Promise; +} diff --git a/packages/api/cms-api/src/dam/files/files.service.spec.ts b/packages/api/cms-api/src/dam/files/files.service.spec.ts index 0ade4f9dd9e..4074e5719b0 100644 --- a/packages/api/cms-api/src/dam/files/files.service.spec.ts +++ b/packages/api/cms-api/src/dam/files/files.service.spec.ts @@ -38,9 +38,9 @@ function createServiceWithMockQueryBuilder() { null as never, // blobStorageBackendService null as never, // foldersService null as never, // DAM_CONFIG - null as never, // imgproxyService null as never, // orm null as never, // entityManager + null as never, // dominantColorCalculator ); const hasFolderConstraint = () => diff --git a/packages/api/cms-api/src/dam/files/files.service.ts b/packages/api/cms-api/src/dam/files/files.service.ts index 7fc59859a8e..e4cb750f184 100644 --- a/packages/api/cms-api/src/dam/files/files.service.ts +++ b/packages/api/cms-api/src/dam/files/files.service.ts @@ -1,6 +1,6 @@ import { InjectRepository } from "@mikro-orm/nestjs"; import { EntityManager, EntityRepository, MikroORM, QueryBuilder, raw, Utils } from "@mikro-orm/postgresql"; -import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import { forwardRef, Inject, Injectable, Optional } from "@nestjs/common"; import { createHmac } from "crypto"; import exifr from "exifr"; import { createReadStream } from "fs"; @@ -8,10 +8,6 @@ import * as hasha from "hasha"; import { basename, extname, parse } from "path"; import probe from "probe-image-size"; import * as rimraf from "rimraf"; -import { promisify } from "util"; -import { inflate as inflateCallback } from "zlib"; - -const inflate = promisify(inflateCallback); import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service"; import { createHashedPath } from "../../blob-storage/utils/create-hashed-path.util"; @@ -20,12 +16,11 @@ import { SortDirection } from "../../common/sorting/sort-direction.enum"; import { FileUploadInput } from "../../file-utils/file-upload.input"; import { slugifyFilename } from "../../file-utils/files.utils"; import { FocalPoint } from "../../file-utils/focal-point.enum"; -import { Extension, ResizingType } from "../../imgproxy/imgproxy.enum"; -import { ImgproxyService } from "../../imgproxy/imgproxy.service"; import { contentScopesAreEqual } from "../../user-permissions/content-scopes-are-equal"; import { CometImageResolutionException } from "../common/errors/image-resolution.exception"; import { DamConfig } from "../dam.config"; -import { DAM_CONFIG } from "../dam.constants"; +import { DAM_CONFIG, DAM_DOMINANT_COLOR_CALCULATOR } from "../dam.constants"; +import { DominantColorCalculatorInterface } from "../dominant-color-calculator.interface"; import { ImageCropAreaInput } from "../images/dto/image-crop-area.input"; import { DamScopeInterface } from "../types"; import { DamMediaAlternative } from "./dam-media-alternatives/entities/dam-media-alternative.entity"; @@ -119,17 +114,15 @@ const withFilesSelect = ( @Injectable() export class FilesService { - protected readonly logger = new Logger(FilesService.name); - constructor( @InjectRepository("DamFile") private readonly filesRepository: EntityRepository, @InjectRepository(DamMediaAlternative) private readonly damMediaAlternativesRepository: EntityRepository, @Inject(forwardRef(() => BlobStorageBackendService)) private readonly blobStorageBackendService: BlobStorageBackendService, private readonly foldersService: FoldersService, @Inject(DAM_CONFIG) private readonly config: DamConfig, - private readonly imgproxyService: ImgproxyService, private readonly orm: MikroORM, private readonly entityManager: EntityManager, + @Optional() @Inject(DAM_DOMINANT_COLOR_CALCULATOR) private readonly dominantColorCalculator?: DominantColorCalculatorInterface, ) {} private selectQueryBuilder(): QueryBuilder { @@ -411,7 +404,8 @@ export class FilesService { ...assignData, }); - if (result.image) { + if (result.image && this.dominantColorCalculator) { + const dominantColorCalculator = this.dominantColorCalculator; // We do not want for our users to await the dominant color calculation. To prevent concurrency issues we must use a separate Unit of // Work. This can be achieved by forking the EntityManager instance. // See https://mikro-orm.io/docs/faq#you-cannot-call-emflush-from-inside-lifecycle-hook-handlers and @@ -419,7 +413,7 @@ export class FilesService { const entityManager = this.orm.em.fork(); const image = await entityManager.findOneOrFail(DamFileImage, result.image.id); - this.calculateDominantColor(contentHash).then((dominantColor) => { + dominantColorCalculator.calculateDominantColor(contentHash).then((dominantColor) => { image.dominantColor = dominantColor; return entityManager.flush(); }); @@ -570,80 +564,12 @@ export class FilesService { return name; } + /** + * @deprecated Use `DamDominantColorService.calculateDominantColor` instead. Returns `undefined` when `DamImagesModule`, + * which provides the imgproxy-backed calculator, is not registered. + */ async calculateDominantColor(contentHash: string): Promise { - const path = this.imgproxyService - .builder() - .resize(ResizingType.AUTO, 1) - .format(Extension.PNG) - .generateUrl( - `${this.blobStorageBackendService.getBackendFilePathPrefix()}${this.config.filesDirectory}/${createHashedPath(contentHash)}`, - ); - - const imgUrl = this.imgproxyService.getSignedUrl(path); - let imageResponse: Response; - try { - imageResponse = await fetch(imgUrl); - } catch (error) { - this.logger.error("Failed to calculate dominant color: imgproxy is not available", error); - return undefined; - } - - if (!imageResponse.ok) { - this.logger.error(`Failed to calculate dominant color: imgproxy returned ${imageResponse.status} ${imageResponse.statusText}`); - return undefined; - } - - try { - const arrayBuffer = await imageResponse.arrayBuffer(); - const pngBuffer = Buffer.from(arrayBuffer); - - // Parse the dominant color from the 1x1 PNG produced by imgproxy - return await this.parsePngPixelColor(pngBuffer); - } catch (error) { - this.logger.error("Failed to calculate dominant color: could not parse imgproxy response", error); - return undefined; - } - } - - private async parsePngPixelColor(pngBuffer: Buffer): Promise { - // PNG: 8-byte signature, then chunks (4-byte length + 4-byte type + data + 4-byte CRC) - let offset = 8; - let colorType = -1; - let palette: Buffer | undefined; - - while (offset < pngBuffer.length) { - const length = pngBuffer.readUInt32BE(offset); - const type = pngBuffer.toString("ascii", offset + 4, offset + 8); - const data = pngBuffer.subarray(offset + 8, offset + 8 + length); - - if (type === "IHDR") { - colorType = data[9]; - } else if (type === "PLTE") { - palette = data; - } else if (type === "IDAT") { - const decompressed = await inflate(data); - // Decompressed scanline: [filter_byte, pixel_data...] - let r: number, g: number, b: number; - if (colorType === 3 && palette) { - // Indexed color: pixel value is a palette index - const index = decompressed[1] * 3; - [r, g, b] = [palette[index], palette[index + 1], palette[index + 2]]; - } else if (colorType === 0) { - // Grayscale - const gray = decompressed[1]; - [r, g, b] = [gray, gray, gray]; - } else { - // RGB (type 2) or RGBA (type 6): R, G, B are at bytes 1-3 - [r, g, b] = [decompressed[1], decompressed[2], decompressed[3]]; - } - return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; - } - - offset += 12 + length; - } - - this.logger.warn("Failed to calculate dominant color: no IDAT chunk found in PNG"); - return undefined; + return this.dominantColorCalculator?.calculateDominantColor(contentHash); } async createFileUrl(file: FileInterface, { previewDamUrls = false }: { previewDamUrls?: boolean }): Promise { diff --git a/packages/api/cms-api/src/dam/images/calculateDominantImageColor.command.ts b/packages/api/cms-api/src/dam/images/calculateDominantImageColor.command.ts index 287ea182e46..2da3d200403 100644 --- a/packages/api/cms-api/src/dam/images/calculateDominantImageColor.command.ts +++ b/packages/api/cms-api/src/dam/images/calculateDominantImageColor.command.ts @@ -1,9 +1,8 @@ -import { InjectRepository } from "@mikro-orm/nestjs"; -import { CreateRequestContext, EntityManager, EntityRepository } from "@mikro-orm/postgresql"; +import { CreateRequestContext, EntityManager } from "@mikro-orm/postgresql"; import { Command, CommandRunner } from "nest-commander"; import { FileInterface } from "../files/entities/file.entity"; -import { FilesService } from "../files/files.service"; +import { DamDominantColorService } from "./dam-dominant-color.service"; @Command({ name: "cms.dam.calculateDominantImageColor", @@ -11,8 +10,7 @@ import { FilesService } from "../files/files.service"; }) export class CalculateDominantImageColorCommand extends CommandRunner { constructor( - @InjectRepository("DamFile") private readonly filesRepository: EntityRepository, - private readonly fileService: FilesService, + private readonly dominantColorService: DamDominantColorService, private readonly em: EntityManager, ) { super(); @@ -22,12 +20,12 @@ export class CalculateDominantImageColorCommand extends CommandRunner { async run(): Promise { console.log("Calculate dominant color of images..."); - const files = await this.filesRepository.find({ image: { $ne: null } }); + const files = await this.em.getRepository("DamFile").find({ image: { $ne: null } }); console.log(`...for ${files.length} images ...`); for await (const file of files) { - const dominantColor = await this.fileService.calculateDominantColor(file.contentHash); + const dominantColor = await this.dominantColorService.calculateDominantColor(file.contentHash); if (file.image) { if (dominantColor) { console.log(`${dominantColor}, ${file.image.id}, ${file.name}`); diff --git a/packages/api/cms-api/src/dam/images/dam-dominant-color.service.ts b/packages/api/cms-api/src/dam/images/dam-dominant-color.service.ts new file mode 100644 index 00000000000..629bcefc4d1 --- /dev/null +++ b/packages/api/cms-api/src/dam/images/dam-dominant-color.service.ts @@ -0,0 +1,100 @@ +import { Inject, Injectable, Logger } from "@nestjs/common"; +import { promisify } from "util"; +import { inflate as inflateCallback } from "zlib"; + +import { BlobStorageBackendService } from "../../blob-storage/backends/blob-storage-backend.service"; +import { createHashedPath } from "../../blob-storage/utils/create-hashed-path.util"; +import { Extension, ResizingType } from "../../imgproxy/imgproxy.enum"; +import { ImgproxyService } from "../../imgproxy/imgproxy.service"; +import { DamConfig } from "../dam.config"; +import { DAM_CONFIG } from "../dam.constants"; +import { DominantColorCalculatorInterface } from "../dominant-color-calculator.interface"; + +const inflate = promisify(inflateCallback); + +@Injectable() +export class DamDominantColorService implements DominantColorCalculatorInterface { + private readonly logger = new Logger(DamDominantColorService.name); + + constructor( + @Inject(DAM_CONFIG) private readonly config: DamConfig, + private readonly imgproxyService: ImgproxyService, + private readonly blobStorageBackendService: BlobStorageBackendService, + ) {} + + async calculateDominantColor(contentHash: string): Promise { + const path = this.imgproxyService + .builder() + .resize(ResizingType.AUTO, 1) + .format(Extension.PNG) + .generateUrl( + `${this.blobStorageBackendService.getBackendFilePathPrefix()}${this.config.filesDirectory}/${createHashedPath(contentHash)}`, + ); + + const imgUrl = this.imgproxyService.getSignedUrl(path); + let imageResponse: Response; + try { + imageResponse = await fetch(imgUrl); + } catch (error) { + this.logger.error("Failed to calculate dominant color: imgproxy is not available", error); + return undefined; + } + + if (!imageResponse.ok) { + this.logger.error(`Failed to calculate dominant color: imgproxy returned ${imageResponse.status} ${imageResponse.statusText}`); + return undefined; + } + + try { + const arrayBuffer = await imageResponse.arrayBuffer(); + const pngBuffer = Buffer.from(arrayBuffer); + + // Parse the dominant color from the 1x1 PNG produced by imgproxy + return await this.parsePngPixelColor(pngBuffer); + } catch (error) { + this.logger.error("Failed to calculate dominant color: could not parse imgproxy response", error); + return undefined; + } + } + + private async parsePngPixelColor(pngBuffer: Buffer): Promise { + // PNG: 8-byte signature, then chunks (4-byte length + 4-byte type + data + 4-byte CRC) + let offset = 8; + let colorType = -1; + let palette: Buffer | undefined; + + while (offset < pngBuffer.length) { + const length = pngBuffer.readUInt32BE(offset); + const type = pngBuffer.toString("ascii", offset + 4, offset + 8); + const data = pngBuffer.subarray(offset + 8, offset + 8 + length); + + if (type === "IHDR") { + colorType = data[9]; + } else if (type === "PLTE") { + palette = data; + } else if (type === "IDAT") { + const decompressed = await inflate(data); + // Decompressed scanline: [filter_byte, pixel_data...] + let r: number, g: number, b: number; + if (colorType === 3 && palette) { + // Indexed color: pixel value is a palette index + const index = decompressed[1] * 3; + [r, g, b] = [palette[index], palette[index + 1], palette[index + 2]]; + } else if (colorType === 0) { + // Grayscale + const gray = decompressed[1]; + [r, g, b] = [gray, gray, gray]; + } else { + // RGB (type 2) or RGBA (type 6): R, G, B are at bytes 1-3 + [r, g, b] = [decompressed[1], decompressed[2], decompressed[3]]; + } + return `#${[r, g, b].map((v) => v.toString(16).padStart(2, "0")).join("")}`; + } + + offset += 12 + length; + } + + this.logger.warn("Failed to calculate dominant color: no IDAT chunk found in PNG"); + return undefined; + } +} diff --git a/packages/api/cms-api/src/dam/files/file-image.resolver.ts b/packages/api/cms-api/src/dam/images/file-image.resolver.ts similarity index 91% rename from packages/api/cms-api/src/dam/files/file-image.resolver.ts rename to packages/api/cms-api/src/dam/images/file-image.resolver.ts index 0678597951e..7e8adadac81 100644 --- a/packages/api/cms-api/src/dam/files/file-image.resolver.ts +++ b/packages/api/cms-api/src/dam/images/file-image.resolver.ts @@ -2,9 +2,9 @@ import { Args, Context, Int, Parent, ResolveField, Resolver } from "@nestjs/grap import { IncomingMessage } from "http"; import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; -import { ImagesService } from "../images/images.service"; -import { DamFileImage } from "./entities/file-image.entity"; -import { FilesService } from "./files.service"; +import { DamFileImage } from "../files/entities/file-image.entity"; +import { FilesService } from "../files/files.service"; +import { ImagesService } from "./images.service"; @Resolver(() => DamFileImage) @RequiredPermission(["dam"]) diff --git a/packages/api/cms-api/src/index.ts b/packages/api/cms-api/src/index.ts index af18f69ac0b..e4a8c25db03 100644 --- a/packages/api/cms-api/src/index.ts +++ b/packages/api/cms-api/src/index.ts @@ -183,19 +183,20 @@ export { damDefaultAcceptedMimetypes } from "./dam/common/mimeTypes/dam-default- export { DamConfig } from "./dam/dam.config"; export { DAM_CONFIG } from "./dam/dam.constants"; export { DamModule } from "./dam/dam.module"; +export { DamFilesModule } from "./dam/dam-files.module"; export { CreateFileInput, ImageFileInput, UpdateFileInput } from "./dam/files/dto/file.input"; export { CreateFolderInput, UpdateFolderInput } from "./dam/files/dto/folder.input"; export { DamFileAiContentType } from "./dam/files/entities/ai-content-type.enum"; export { createFileEntity, FileInterface } from "./dam/files/entities/file.entity"; export { DamFileImage } from "./dam/files/entities/file-image.entity"; export { createFolderEntity, FolderInterface } from "./dam/files/entities/folder.entity"; -export { FileImagesResolver } from "./dam/files/file-image.resolver"; export { FilesService } from "./dam/files/files.service"; export { FoldersService } from "./dam/files/folders.service"; export { ImageInterface } from "./dam/images/dto/image.interface"; export { HashImageParams, ImageParams } from "./dam/images/dto/image.params"; export { ImageCropAreaInput } from "./dam/images/dto/image-crop-area.input"; export { ImageCropArea } from "./dam/images/entities/image-crop-area.entity"; +export { FileImagesResolver } from "./dam/images/file-image.resolver"; export { ImagesService } from "./dam/images/images.service"; export { calculateInheritAspectRatio } from "./dam/images/images.util"; export { IsAllowedImageAspectRatio, IsAllowedImageAspectRatioConstraint } from "./dam/images/validators/is-allowed-aspect-ratio.validator";