From bbd7d5d20e1309ed8ab7b9405c3665e8ef5d79f5 Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Sun, 9 Aug 2026 22:19:15 +0200 Subject: [PATCH 01/10] cms-api: Extract dominant color computation into DamDominantColorService Computing the dominant color of an uploaded image was the only part of FilesService that used imgproxy, which made ImgproxyModule a hard dependency of the whole DAM file handling. Move the computation into DamDominantColorService, so ImgproxyService is injected in one small service instead. FilesService.calculateDominantColor remains as a deprecated delegator. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/dam-dominant-color-service.md | 9 ++ packages/api/cms-api/src/dam/dam.module.ts | 3 + .../src/dam/files/files.service.spec.ts | 2 +- .../cms-api/src/dam/files/files.service.ts | 88 ++--------------- .../calculateDominantImageColor.command.ts | 12 +-- .../dam/images/dam-dominant-color.service.ts | 99 +++++++++++++++++++ packages/api/cms-api/src/index.ts | 1 + 7 files changed, 125 insertions(+), 89 deletions(-) create mode 100644 .changeset/dam-dominant-color-service.md create mode 100644 packages/api/cms-api/src/dam/images/dam-dominant-color.service.ts diff --git a/.changeset/dam-dominant-color-service.md b/.changeset/dam-dominant-color-service.md new file mode 100644 index 00000000000..ee6d6119cf5 --- /dev/null +++ b/.changeset/dam-dominant-color-service.md @@ -0,0 +1,9 @@ +--- +"@comet/cms-api": minor +--- + +Add `DamDominantColorService` + +Computing the dominant color of an image was the only part of `FilesService` that used imgproxy. It now lives in `DamDominantColorService`, which is the only DAM service that depends on `ImgproxyService`. + +`FilesService.calculateDominantColor` remains available as a deprecated delegator. diff --git a/packages/api/cms-api/src/dam/dam.module.ts b/packages/api/cms-api/src/dam/dam.module.ts index 66b9c7104e2..c35c68f9ee7 100644 --- a/packages/api/cms-api/src/dam/dam.module.ts +++ b/packages/api/cms-api/src/dam/dam.module.ts @@ -29,6 +29,7 @@ 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 { DamDominantColorService } from "./images/dam-dominant-color.service"; import { ImageCropArea } from "./images/entities/image-crop-area.entity"; import { createImagesController } from "./images/images.controller"; import { ImagesService } from "./images/images.service"; @@ -114,6 +115,7 @@ export class DamModule { FileLicensesResolver, FoldersResolver, FoldersService, + DamDominantColorService, ImagesService, IsAllowedImageSizeConstraint, IsAllowedImageAspectRatioConstraint, @@ -136,6 +138,7 @@ export class DamModule { exports: [ FilesService, FoldersService, + DamDominantColorService, ImagesService, damConfigProvider, PixelImageBlockTransformerService, 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..77c138d5434 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, // dominantColorService ); 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..3ae9002f478 100644 --- a/packages/api/cms-api/src/dam/files/files.service.ts +++ b/packages/api/cms-api/src/dam/files/files.service.ts @@ -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 { DamDominantColorService } from "../images/dam-dominant-color.service"; import { ImageCropAreaInput } from "../images/dto/image-crop-area.input"; import { DamScopeInterface } from "../types"; import { DamMediaAlternative } from "./dam-media-alternatives/entities/dam-media-alternative.entity"; @@ -127,9 +122,9 @@ export class FilesService { @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, + private readonly dominantColorService: DamDominantColorService, ) {} private selectQueryBuilder(): QueryBuilder { @@ -419,7 +414,7 @@ export class FilesService { const entityManager = this.orm.em.fork(); const image = await entityManager.findOneOrFail(DamFileImage, result.image.id); - this.calculateDominantColor(contentHash).then((dominantColor) => { + this.dominantColorService.calculateDominantColor(contentHash).then((dominantColor) => { image.dominantColor = dominantColor; return entityManager.flush(); }); @@ -570,80 +565,11 @@ export class FilesService { return name; } + /** + * @deprecated Use `DamDominantColorService.calculateDominantColor` instead. + */ 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.dominantColorService.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..d0131885716 --- /dev/null +++ b/packages/api/cms-api/src/dam/images/dam-dominant-color.service.ts @@ -0,0 +1,99 @@ +import { forwardRef, 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"; + +const inflate = promisify(inflateCallback); + +@Injectable() +export class DamDominantColorService { + private readonly logger = new Logger(DamDominantColorService.name); + + constructor( + @Inject(DAM_CONFIG) private readonly config: DamConfig, + private readonly imgproxyService: ImgproxyService, + @Inject(forwardRef(() => BlobStorageBackendService)) 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/index.ts b/packages/api/cms-api/src/index.ts index af18f69ac0b..4f5be5b5853 100644 --- a/packages/api/cms-api/src/index.ts +++ b/packages/api/cms-api/src/index.ts @@ -192,6 +192,7 @@ export { createFolderEntity, FolderInterface } from "./dam/files/entities/folder export { FileImagesResolver } from "./dam/files/file-image.resolver"; export { FilesService } from "./dam/files/files.service"; export { FoldersService } from "./dam/files/folders.service"; +export { DamDominantColorService } from "./dam/images/dam-dominant-color.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"; From 0c0c331067c6e6c09b88704724f9bbec9a2e0e3d Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Sun, 9 Aug 2026 22:24:35 +0200 Subject: [PATCH 02/10] cms-api: Split DamModule into DamFilesModule, DamImagesModule and DamBlocksModule DamModule registered file handling, image scaling, the block transformers and the dependents resolver as one unit, so a project that only needs DAM file upload and storage had to bring ImgproxyModule and DependenciesModule along with it. Move the registrations into four sub-modules and turn DamModule into a facade that composes all of them, so DamModule.register() behaves as before. FilesService receives the dominant color calculator through the optional DAM_DOMINANT_COLOR_CALCULATOR token, which DamImagesModule provides. Without that module, uploads skip the color. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/dam-module-split.md | 15 ++ .../api/cms-api/src/dam/dam-blocks.module.ts | 23 ++++ .../cms-api/src/dam/dam-dependents.module.ts | 18 +++ .../api/cms-api/src/dam/dam-files.module.ts | 104 ++++++++++++++ .../api/cms-api/src/dam/dam-images.module.ts | 37 +++++ packages/api/cms-api/src/dam/dam.constants.ts | 1 + packages/api/cms-api/src/dam/dam.module.ts | 130 ++---------------- .../dominant-color-calculator.interface.ts | 3 + .../src/dam/files/files.service.spec.ts | 2 +- .../cms-api/src/dam/files/files.service.ts | 18 +-- .../dam/images/dam-dominant-color.service.ts | 3 +- .../{files => images}/file-image.resolver.ts | 6 +- packages/api/cms-api/src/index.ts | 6 +- 13 files changed, 234 insertions(+), 132 deletions(-) create mode 100644 .changeset/dam-module-split.md create mode 100644 packages/api/cms-api/src/dam/dam-blocks.module.ts create mode 100644 packages/api/cms-api/src/dam/dam-dependents.module.ts create mode 100644 packages/api/cms-api/src/dam/dam-files.module.ts create mode 100644 packages/api/cms-api/src/dam/dam-images.module.ts create mode 100644 packages/api/cms-api/src/dam/dominant-color-calculator.interface.ts rename packages/api/cms-api/src/dam/{files => images}/file-image.resolver.ts (91%) diff --git a/.changeset/dam-module-split.md b/.changeset/dam-module-split.md new file mode 100644 index 00000000000..e8627ee290d --- /dev/null +++ b/.changeset/dam-module-split.md @@ -0,0 +1,15 @@ +--- +"@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, so existing setups behave identically. + +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..023b825f6b6 --- /dev/null +++ b/packages/api/cms-api/src/dam/dam-blocks.module.ts @@ -0,0 +1,23 @@ +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 { + static register(): DynamicModule { + 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..27328876fe8 --- /dev/null +++ b/packages/api/cms-api/src/dam/dam-dependents.module.ts @@ -0,0 +1,18 @@ +import { DynamicModule, Module, Type } from "@nestjs/common"; + +import { DependentsResolverFactory } from ".."; +import { FileInterface } from "./files/entities/file.entity"; + +interface DamDependentsModuleOptions { + File: Type; +} + +@Module({}) +export class DamDependentsModule { + static register({ File }: DamDependentsModuleOptions): DynamicModule { + 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..51bb11128ac --- /dev/null +++ b/packages/api/cms-api/src/dam/dam-files.module.ts @@ -0,0 +1,104 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { DynamicModule, Global, Module, Type, ValueProvider } from "@nestjs/common"; +import { TypeMetadataStorage } from "@nestjs/graphql"; + +import { damDefaultAcceptedMimetypes } from ".."; +import { FileValidationService } from "../file-utils/file-validation.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 { 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: DamConfig; + Scope?: Type; + Folder: Type; + File: Type; +} + +@Global() +@Module({}) +export class DamFilesModule { + static register({ damConfig, Scope, Folder, File }: DamFilesModuleOptions): DynamicModule { + if (File.name !== FILE_ENTITY) { + throw new Error(`DamModule: Your File entity must be named ${FILE_ENTITY}`); + } + + 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..d564a10e929 --- /dev/null +++ b/packages/api/cms-api/src/dam/dam-images.module.ts @@ -0,0 +1,37 @@ +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 { + static register({ damBasePath }: DamImagesModuleOptions): DynamicModule { + 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.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 c35c68f9ee7..acd77d6f821 100644 --- a/packages/api/cms-api/src/dam/dam.module.ts +++ b/packages/api/cms-api/src/dam/dam.module.ts @@ -1,41 +1,12 @@ -import { MikroOrmModule } from "@mikro-orm/nestjs"; -import { DynamicModule, Global, Module, Type, ValueProvider } from "@nestjs/common"; -import { TypeMetadataStorage } from "@nestjs/graphql"; +import { DynamicModule, Global, Module, Type } from "@nestjs/common"; -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 { 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 { DamDominantColorService } from "./images/dam-dominant-color.service"; -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 { @@ -59,92 +30,15 @@ export class DamModule { basePath: options.damConfig.basePath ?? "dam", }; - if (File.name !== FILE_ENTITY) { - throw new Error(`DamModule: Your File entity must be named ${FILE_ENTITY}`); - } - - 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 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, - DamDominantColorService, - 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, - DamDominantColorService, - 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 77c138d5434..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 @@ -40,7 +40,7 @@ function createServiceWithMockQueryBuilder() { null as never, // DAM_CONFIG null as never, // orm null as never, // entityManager - null as never, // dominantColorService + 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 3ae9002f478..ce24edd548c 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, Logger, Optional } from "@nestjs/common"; import { createHmac } from "crypto"; import exifr from "exifr"; import { createReadStream } from "fs"; @@ -19,8 +19,8 @@ import { FocalPoint } from "../../file-utils/focal-point.enum"; 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 { DamDominantColorService } from "../images/dam-dominant-color.service"; +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"; @@ -124,7 +124,7 @@ export class FilesService { @Inject(DAM_CONFIG) private readonly config: DamConfig, private readonly orm: MikroORM, private readonly entityManager: EntityManager, - private readonly dominantColorService: DamDominantColorService, + @Optional() @Inject(DAM_DOMINANT_COLOR_CALCULATOR) private readonly dominantColorCalculator?: DominantColorCalculatorInterface, ) {} private selectQueryBuilder(): QueryBuilder { @@ -406,7 +406,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 @@ -414,7 +415,7 @@ export class FilesService { const entityManager = this.orm.em.fork(); const image = await entityManager.findOneOrFail(DamFileImage, result.image.id); - this.dominantColorService.calculateDominantColor(contentHash).then((dominantColor) => { + dominantColorCalculator.calculateDominantColor(contentHash).then((dominantColor) => { image.dominantColor = dominantColor; return entityManager.flush(); }); @@ -566,10 +567,11 @@ export class FilesService { } /** - * @deprecated Use `DamDominantColorService.calculateDominantColor` instead. + * @deprecated Use `DamDominantColorService.calculateDominantColor` instead. Returns `undefined` when `DamImagesModule`, + * which provides the imgproxy-backed calculator, is not registered. */ async calculateDominantColor(contentHash: string): Promise { - return this.dominantColorService.calculateDominantColor(contentHash); + return this.dominantColorCalculator?.calculateDominantColor(contentHash); } async createFileUrl(file: FileInterface, { previewDamUrls = false }: { previewDamUrls?: boolean }): Promise { 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 index d0131885716..91a94f08df8 100644 --- 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 @@ -8,11 +8,12 @@ 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 { +export class DamDominantColorService implements DominantColorCalculatorInterface { private readonly logger = new Logger(DamDominantColorService.name); constructor( 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 4f5be5b5853..1e4a559dd94 100644 --- a/packages/api/cms-api/src/index.ts +++ b/packages/api/cms-api/src/index.ts @@ -183,13 +183,16 @@ 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 { DamBlocksModule } from "./dam/dam-blocks.module"; +export { DamDependentsModule } from "./dam/dam-dependents.module"; +export { DamFilesModule } from "./dam/dam-files.module"; +export { DamImagesModule } from "./dam/dam-images.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 { DamDominantColorService } from "./dam/images/dam-dominant-color.service"; @@ -197,6 +200,7 @@ 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"; From 1bff218263e660c2e2a0c3ab99d818c9087fdc44 Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Mon, 10 Aug 2026 13:41:41 +0200 Subject: [PATCH 03/10] cms-api: Guard the DAM modules against duplicate registration DamModule registers the four DAM sub-modules internally, so registering one of them alongside DamModule goes wrong in a different way each time: DamFilesModule and DamImagesModule mount their routes twice, DamDependentsModule adds a second resolver for the dependents field and breaks the schema, DamBlocksModule leaves two instances of each transformer service with one silently unused. Throw on the second register() call instead, naming the module and what to do about it, the way UserPermissionsModule already guards its permission enum. Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/cms-api/src/dam/dam-blocks.module.ts | 11 +++++++++++ packages/api/cms-api/src/dam/dam-dependents.module.ts | 11 +++++++++++ packages/api/cms-api/src/dam/dam-files.module.ts | 11 +++++++++++ packages/api/cms-api/src/dam/dam-images.module.ts | 11 +++++++++++ packages/api/cms-api/src/dam/dam.module.ts | 7 +++++++ 5 files changed, 51 insertions(+) diff --git a/packages/api/cms-api/src/dam/dam-blocks.module.ts b/packages/api/cms-api/src/dam/dam-blocks.module.ts index 023b825f6b6..df712ee13d4 100644 --- a/packages/api/cms-api/src/dam/dam-blocks.module.ts +++ b/packages/api/cms-api/src/dam/dam-blocks.module.ts @@ -8,7 +8,18 @@ import { DamVideoBlockTransformerService } from "./blocks/video/dam-video-block- @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: [ diff --git a/packages/api/cms-api/src/dam/dam-dependents.module.ts b/packages/api/cms-api/src/dam/dam-dependents.module.ts index 27328876fe8..aef71981d11 100644 --- a/packages/api/cms-api/src/dam/dam-dependents.module.ts +++ b/packages/api/cms-api/src/dam/dam-dependents.module.ts @@ -9,7 +9,18 @@ interface DamDependentsModuleOptions { @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 index 51bb11128ac..92c42bc0e17 100644 --- a/packages/api/cms-api/src/dam/dam-files.module.ts +++ b/packages/api/cms-api/src/dam/dam-files.module.ts @@ -35,7 +35,18 @@ interface DamFilesModuleOptions { @Global() @Module({}) export class DamFilesModule { + private static registered = false; + static register({ damConfig, 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}`); } diff --git a/packages/api/cms-api/src/dam/dam-images.module.ts b/packages/api/cms-api/src/dam/dam-images.module.ts index d564a10e929..a662461c5de 100644 --- a/packages/api/cms-api/src/dam/dam-images.module.ts +++ b/packages/api/cms-api/src/dam/dam-images.module.ts @@ -17,7 +17,18 @@ interface DamImagesModuleOptions { @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: [ diff --git a/packages/api/cms-api/src/dam/dam.module.ts b/packages/api/cms-api/src/dam/dam.module.ts index acd77d6f821..d370fdd198e 100644 --- a/packages/api/cms-api/src/dam/dam.module.ts +++ b/packages/api/cms-api/src/dam/dam.module.ts @@ -19,12 +19,19 @@ interface DamModuleOptions { @Global() @Module({}) export class DamModule { + private static registered = false; + static register({ Scope, Folder = createFolderEntity({ Scope }), File = createFileEntity({ Scope, Folder }), ...options }: DamModuleOptions): DynamicModule { + 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 damConfig = { ...options.damConfig, basePath: options.damConfig.basePath ?? "dam", From 0a69215cfa63c40955da8436cb259c1533d31c83 Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Mon, 10 Aug 2026 13:44:02 +0200 Subject: [PATCH 04/10] cms-api: Export only DamFilesModule of the DAM sub-modules DamImagesModule, DamBlocksModule and DamDependentsModule are composition details of DamModule, and registering one of them next to DamModule is now an error. Exporting them advertises a setup that isn't supported, so keep them internal. DamFilesModule stays exported because it is the one that can be registered on its own. Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/cms-api/src/index.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/api/cms-api/src/index.ts b/packages/api/cms-api/src/index.ts index 1e4a559dd94..524e05377ea 100644 --- a/packages/api/cms-api/src/index.ts +++ b/packages/api/cms-api/src/index.ts @@ -183,10 +183,7 @@ 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 { DamBlocksModule } from "./dam/dam-blocks.module"; -export { DamDependentsModule } from "./dam/dam-dependents.module"; export { DamFilesModule } from "./dam/dam-files.module"; -export { DamImagesModule } from "./dam/dam-images.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"; From 45e348668d5830aa530ffe37a2a9f534f04b9980 Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Mon, 10 Aug 2026 21:53:41 +0200 Subject: [PATCH 05/10] cms-api: Keep the dominant color service internal The service is an implementation detail of DamImagesModule and is not meant to be injected by projects, so it does not belong in the public API. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/dam-dominant-color-service.md | 6 +++--- packages/api/cms-api/src/index.ts | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.changeset/dam-dominant-color-service.md b/.changeset/dam-dominant-color-service.md index ee6d6119cf5..552df7686e0 100644 --- a/.changeset/dam-dominant-color-service.md +++ b/.changeset/dam-dominant-color-service.md @@ -2,8 +2,8 @@ "@comet/cms-api": minor --- -Add `DamDominantColorService` +Deprecate `FilesService.calculateDominantColor` -Computing the dominant color of an image was the only part of `FilesService` that used imgproxy. It now lives in `DamDominantColorService`, which is the only DAM service that depends on `ImgproxyService`. +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` remains available as a deprecated delegator. +`FilesService.calculateDominantColor` returns the color when `DamImagesModule` is registered, and `undefined` when it is not. diff --git a/packages/api/cms-api/src/index.ts b/packages/api/cms-api/src/index.ts index 524e05377ea..e4a8c25db03 100644 --- a/packages/api/cms-api/src/index.ts +++ b/packages/api/cms-api/src/index.ts @@ -192,7 +192,6 @@ export { DamFileImage } from "./dam/files/entities/file-image.entity"; export { createFolderEntity, FolderInterface } from "./dam/files/entities/folder.entity"; export { FilesService } from "./dam/files/files.service"; export { FoldersService } from "./dam/files/folders.service"; -export { DamDominantColorService } from "./dam/images/dam-dominant-color.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"; From 87dd82e874d8d7a46d02231679978464a39b725e Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Mon, 10 Aug 2026 21:53:49 +0200 Subject: [PATCH 06/10] cms-api: Remove the unused logger from FilesService The last use moved out with the dominant color computation. Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/cms-api/src/dam/files/files.service.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 ce24edd548c..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, Optional } from "@nestjs/common"; +import { forwardRef, Inject, Injectable, Optional } from "@nestjs/common"; import { createHmac } from "crypto"; import exifr from "exifr"; import { createReadStream } from "fs"; @@ -114,8 +114,6 @@ 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, From 6201fea3aac3e7aa6d8d240c3d29905baeaecc84 Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Mon, 10 Aug 2026 21:53:56 +0200 Subject: [PATCH 07/10] cms-api: Drop the forwardRef around BlobStorageBackendService BlobStorageBackendService imports nothing from dam/, so there is no import cycle the forwardRef would resolve here. Co-Authored-By: Claude Opus 5 (1M context) --- .../api/cms-api/src/dam/images/dam-dominant-color.service.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 91a94f08df8..629bcefc4d1 100644 --- 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 @@ -1,4 +1,4 @@ -import { forwardRef, Inject, Injectable, Logger } from "@nestjs/common"; +import { Inject, Injectable, Logger } from "@nestjs/common"; import { promisify } from "util"; import { inflate as inflateCallback } from "zlib"; @@ -19,7 +19,7 @@ export class DamDominantColorService implements DominantColorCalculatorInterface constructor( @Inject(DAM_CONFIG) private readonly config: DamConfig, private readonly imgproxyService: ImgproxyService, - @Inject(forwardRef(() => BlobStorageBackendService)) private readonly blobStorageBackendService: BlobStorageBackendService, + private readonly blobStorageBackendService: BlobStorageBackendService, ) {} async calculateDominantColor(contentHash: string): Promise { From 10f3d770e20fccd0f19a61bdeecbd8523399c487 Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Mon, 10 Aug 2026 21:54:15 +0200 Subject: [PATCH 08/10] cms-api: Import the DAM module dependencies by their source path Both new modules pulled a symbol from the package index, which imports the modules back. Importing the source files directly avoids the cycle. Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/cms-api/src/dam/dam-dependents.module.ts | 2 +- packages/api/cms-api/src/dam/dam-files.module.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/cms-api/src/dam/dam-dependents.module.ts b/packages/api/cms-api/src/dam/dam-dependents.module.ts index aef71981d11..680fe18c11f 100644 --- a/packages/api/cms-api/src/dam/dam-dependents.module.ts +++ b/packages/api/cms-api/src/dam/dam-dependents.module.ts @@ -1,6 +1,6 @@ import { DynamicModule, Module, Type } from "@nestjs/common"; -import { DependentsResolverFactory } from ".."; +import { DependentsResolverFactory } from "../dependencies/dependents.resolver.factory"; import { FileInterface } from "./files/entities/file.entity"; interface DamDependentsModuleOptions { diff --git a/packages/api/cms-api/src/dam/dam-files.module.ts b/packages/api/cms-api/src/dam/dam-files.module.ts index 92c42bc0e17..0500403e52a 100644 --- a/packages/api/cms-api/src/dam/dam-files.module.ts +++ b/packages/api/cms-api/src/dam/dam-files.module.ts @@ -2,9 +2,9 @@ import { MikroOrmModule } from "@mikro-orm/nestjs"; import { DynamicModule, Global, Module, Type, ValueProvider } from "@nestjs/common"; import { TypeMetadataStorage } from "@nestjs/graphql"; -import { damDefaultAcceptedMimetypes } from ".."; 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 } from "./dam.config"; import { DAM_CONFIG, DAM_FILE_VALIDATION_SERVICE } from "./dam.constants"; import { createDamItemsResolver } from "./files/dam-items.resolver"; From 4a69bde6f4741af28a28b4d89519ce7858e756c5 Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Mon, 10 Aug 2026 21:54:22 +0200 Subject: [PATCH 09/10] cms-api: Document the duplicate registration guard in the changeset Registering a DAM module twice now throws, so the changeset can no longer claim that existing setups behave identically. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/dam-module-split.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/dam-module-split.md b/.changeset/dam-module-split.md index e8627ee290d..61f06c4b637 100644 --- a/.changeset/dam-module-split.md +++ b/.changeset/dam-module-split.md @@ -4,7 +4,9 @@ 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, so existing setups behave identically. +`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`: From 05fd2a47c951eeaf5f052dbfd7bf5246b851bf90 Mon Sep 17 00:00:00 2001 From: Thomas Dax Date: Mon, 10 Aug 2026 22:01:58 +0200 Subject: [PATCH 10/10] cms-api: Default the base path in DamFilesModule DamModule accepts a damConfig without a basePath and falls back to "dam". DamFilesModule required it, so a project registering the module on its own could not pass the damConfig it already had. Co-Authored-By: Claude Opus 5 (1M context) --- packages/api/cms-api/src/dam/dam-files.module.ts | 11 ++++++++--- packages/api/cms-api/src/dam/dam.config.ts | 2 ++ packages/api/cms-api/src/dam/dam.module.ts | 4 ++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/api/cms-api/src/dam/dam-files.module.ts b/packages/api/cms-api/src/dam/dam-files.module.ts index 0500403e52a..f13eec30bee 100644 --- a/packages/api/cms-api/src/dam/dam-files.module.ts +++ b/packages/api/cms-api/src/dam/dam-files.module.ts @@ -5,7 +5,7 @@ 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 } from "./dam.config"; +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"; @@ -26,7 +26,7 @@ import { ImageCropArea } from "./images/entities/image-crop-area.entity"; import { DamScopeInterface } from "./types"; interface DamFilesModuleOptions { - damConfig: DamConfig; + damConfig: Omit & { basePath?: string }; Scope?: Type; Folder: Type; File: Type; @@ -37,7 +37,7 @@ interface DamFilesModuleOptions { export class DamFilesModule { private static registered = false; - static register({ damConfig, Scope, Folder, File }: DamFilesModuleOptions): DynamicModule { + 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) { @@ -51,6 +51,11 @@ export class DamFilesModule { 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, 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.module.ts b/packages/api/cms-api/src/dam/dam.module.ts index d370fdd198e..bd3edb1bb56 100644 --- a/packages/api/cms-api/src/dam/dam.module.ts +++ b/packages/api/cms-api/src/dam/dam.module.ts @@ -1,6 +1,6 @@ import { DynamicModule, Global, Module, Type } from "@nestjs/common"; -import { DamConfig } from "./dam.config"; +import { DamConfig, damDefaultBasePath } from "./dam.config"; import { DamBlocksModule } from "./dam-blocks.module"; import { DamDependentsModule } from "./dam-dependents.module"; import { DamFilesModule } from "./dam-files.module"; @@ -34,7 +34,7 @@ export class DamModule { const damConfig = { ...options.damConfig, - basePath: options.damConfig.basePath ?? "dam", + basePath: options.damConfig.basePath ?? damDefaultBasePath, }; return {