From 75bd4f94cfbe5b2330481b7c1211c869dc251009 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 07:33:19 +0000 Subject: [PATCH] cms-api: Block delivery of DAM files with an expired license The license duration of a DAM file was purely informational: expired licenses were surfaced as warnings in the Admin, but the public DAM routes kept delivering the file, so a site could keep showing an image whose rights had run out. Add the opt-in `blockFilesWithExpiredLicense` option to `DamConfig`, which makes the public image and file routes respond with a 404 for files whose license has expired. Cap the cache lifetime of files with a license end date at the expiration date when the option is enabled, because browsers and CDNs would otherwise keep serving them for up to a year. Move the license validity checks out of `FileLicensesResolver` into `license.util.ts` so the resolver and the controllers share one implementation. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L22ETjLL8qy8xS2gcKZUmz --- .../dam-block-expired-license-delivery.md | 21 ++++ packages/api/cms-api/src/dam/dam.config.ts | 5 + .../src/dam/files/file-licenses.resolver.ts | 30 +---- .../cms-api/src/dam/files/files.controller.ts | 16 ++- .../src/dam/files/license.util.spec.ts | 118 ++++++++++++++++++ .../api/cms-api/src/dam/files/license.util.ts | 89 +++++++++++++ .../src/dam/images/images.controller.ts | 13 +- 7 files changed, 264 insertions(+), 28 deletions(-) create mode 100644 .changeset/dam-block-expired-license-delivery.md create mode 100644 packages/api/cms-api/src/dam/files/license.util.spec.ts create mode 100644 packages/api/cms-api/src/dam/files/license.util.ts diff --git a/.changeset/dam-block-expired-license-delivery.md b/.changeset/dam-block-expired-license-delivery.md new file mode 100644 index 00000000000..860196d8437 --- /dev/null +++ b/.changeset/dam-block-expired-license-delivery.md @@ -0,0 +1,21 @@ +--- +"@dextinity/cms-api": minor +--- + +Add `blockFilesWithExpiredLicense` option to `DamConfig` + +Until now, the license duration of a DAM file was purely informational: expired licenses were surfaced as warnings in the Admin, but the public DAM routes kept delivering the file. Enable `blockFilesWithExpiredLicense` to respond with a 404 for files whose license has expired. It requires `enableLicenseFeature` and defaults to `false`, so delivery is unchanged unless the option is set. + +The public routes cache aggressively (1 year for browsers, 1 day for CDNs). Therefore, the cache lifetime of files with a license end date is capped at the expiration date when the option is enabled — otherwise caches would keep serving a file long after its license expired. + +**Example** + +```ts +DamModule.register({ + damConfig: { + // ... + enableLicenseFeature: true, + blockFilesWithExpiredLicense: true, + }, +}); +``` diff --git a/packages/api/cms-api/src/dam/dam.config.ts b/packages/api/cms-api/src/dam/dam.config.ts index 8669e785c01..a06492c190e 100644 --- a/packages/api/cms-api/src/dam/dam.config.ts +++ b/packages/api/cms-api/src/dam/dam.config.ts @@ -9,6 +9,11 @@ export interface DamConfig { maxFileSize: number; requireLicense?: boolean; enableLicenseFeature?: boolean; + /** + * Block delivery of files with an expired license on the public DAM routes (images and files). + * Requires `enableLicenseFeature` to be enabled. Defaults to `false`. + */ + blockFilesWithExpiredLicense?: boolean; maxSrcResolution: number; basePath: string; } diff --git a/packages/api/cms-api/src/dam/files/file-licenses.resolver.ts b/packages/api/cms-api/src/dam/files/file-licenses.resolver.ts index 390170a777d..1b8957f6ad7 100644 --- a/packages/api/cms-api/src/dam/files/file-licenses.resolver.ts +++ b/packages/api/cms-api/src/dam/files/file-licenses.resolver.ts @@ -1,52 +1,34 @@ import { Parent, ResolveField, Resolver } from "@nestjs/graphql"; -import { add, differenceInCalendarDays, isAfter, isBefore } from "date-fns"; import { RequiredPermission } from "../../user-permissions/decorators/required-permission.decorator"; import { License } from "./entities/license.embeddable"; +import { getLicenseExpirationDate, hasLicenseExpired, isLicenseNotValidYet, isLicenseValid, licenseExpiresWithinThirtyDays } from "./license.util"; @Resolver(() => License) @RequiredPermission(["dam"]) export class FileLicensesResolver { - // if durationTo = '2023-02-27T00:00:00.000Z' then the license is still valid on 27.03.2023 - // and expires at '2023-02-28T00:00:00.000Z' @ResolveField(() => Date, { nullable: true, description: "The expirationDate is the durationTo + 1 day" }) expirationDate(@Parent() license: License): Date | undefined { - if (license.durationTo) { - return add(license.durationTo, { - days: 1, - }); - } - return undefined; + return getLicenseExpirationDate(license); } @ResolveField(() => Boolean) isNotValidYet(@Parent() license: License): boolean { - const currentDate = new Date(); - - return license.durationFrom !== undefined && isBefore(currentDate, license.durationFrom); + return isLicenseNotValidYet(license); } @ResolveField(() => Boolean) expiresWithinThirtyDays(@Parent() license: License): boolean { - const currentDate = new Date(); - const expirationDate = this.expirationDate(license); - - return expirationDate !== undefined && isBefore(currentDate, expirationDate) && differenceInCalendarDays(expirationDate, currentDate) <= 30; + return licenseExpiresWithinThirtyDays(license); } @ResolveField(() => Boolean) hasExpired(@Parent() license: License): boolean { - const currentDate = new Date(); - const expirationDate = this.expirationDate(license); - - return expirationDate !== undefined && isAfter(currentDate, expirationDate); + return hasLicenseExpired(license); } @ResolveField(() => Boolean) isValid(@Parent() license: License): boolean { - const isNotValidYet = this.isNotValidYet(license); - const hasExpired = this.hasExpired(license); - - return !(isNotValidYet || hasExpired); + return isLicenseValid(license); } } diff --git a/packages/api/cms-api/src/dam/files/files.controller.ts b/packages/api/cms-api/src/dam/files/files.controller.ts index d993e0dbeb5..4d0bc2816d6 100644 --- a/packages/api/cms-api/src/dam/files/files.controller.ts +++ b/packages/api/cms-api/src/dam/files/files.controller.ts @@ -44,6 +44,7 @@ import { FileParams, HashFileParams } from "./dto/file.params"; import { FileInterface } from "./entities/file.entity"; import { FilesService } from "./files.service"; import { FoldersService } from "./folders.service"; +import { createPublicCacheControlHeader, isFileBlockedByExpiredLicense } from "./license.util"; const fileUrl = `:fileId/:filename`; @@ -264,8 +265,15 @@ export function createFilesController({ Scope: PassedScope, damBasePath }: { Sco throw new BadRequestException("Content Hash mismatch!"); } + if (isFileBlockedByExpiredLicense({ file, config: this.damConfig })) { + throw new NotFoundException(); + } + res.setHeader("Content-Disposition", "attachment"); - return this.streamFile(file, res, { range, overrideHeaders: { "cache-control": "max-age=31536000, s-maxage=86400, public" } }); // Public cache, 1 year for browsers, 1 day for proxies/cdn's + return this.streamFile(file, res, { + range, + overrideHeaders: { "cache-control": createPublicCacheControlHeader({ file, config: this.damConfig }) }, + }); } @DisableDextinityGuards() @@ -289,10 +297,14 @@ export function createFilesController({ Scope: PassedScope, damBasePath }: { Sco throw new BadRequestException("Content Hash mismatch!"); } + if (isFileBlockedByExpiredLicense({ file, config: this.damConfig })) { + throw new NotFoundException(); + } + return this.streamFile(file, res, { range, overrideHeaders: { - "cache-control": "max-age=31536000, s-maxage=86400, public", // Public cache, 1 year for browsers, 1 day for proxies/cdn's + "cache-control": createPublicCacheControlHeader({ file, config: this.damConfig }), }, }); } diff --git a/packages/api/cms-api/src/dam/files/license.util.spec.ts b/packages/api/cms-api/src/dam/files/license.util.spec.ts new file mode 100644 index 00000000000..67ae450f120 --- /dev/null +++ b/packages/api/cms-api/src/dam/files/license.util.spec.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "vitest"; + +import type { DamConfig } from "../dam.config"; +import type { FileInterface } from "./entities/file.entity"; +import type { License } from "./entities/license.embeddable"; +import { createPublicCacheControlHeader, hasLicenseExpired, isFileBlockedByExpiredLicense, isLicenseValid } from "./license.util"; + +const now = new Date("2024-06-15T12:00:00.000Z"); + +function createFile(license?: Partial): FileInterface { + return { license: license as License | undefined } as FileInterface; +} + +function createConfig(config: Partial): DamConfig { + return config as DamConfig; +} + +const licenseFeatureWithBlocking = createConfig({ enableLicenseFeature: true, blockFilesWithExpiredLicense: true }); + +describe("hasLicenseExpired", () => { + it("returns false without a durationTo", () => { + expect(hasLicenseExpired({} as License, now)).toBe(false); + }); + + it("returns false on the last day of the license", () => { + expect(hasLicenseExpired({ durationTo: new Date("2024-06-15T00:00:00.000Z") } as License, now)).toBe(false); + }); + + it("returns true after durationTo + 1 day", () => { + expect(hasLicenseExpired({ durationTo: new Date("2024-06-13T00:00:00.000Z") } as License, now)).toBe(true); + }); +}); + +describe("isLicenseValid", () => { + it("returns false for a license that hasn't started yet", () => { + expect(isLicenseValid({ durationFrom: new Date("2024-07-01T00:00:00.000Z") } as License, now)).toBe(false); + }); + + it("returns true within the license duration", () => { + const license = { durationFrom: new Date("2024-06-01T00:00:00.000Z"), durationTo: new Date("2024-06-30T00:00:00.000Z") } as License; + + expect(isLicenseValid(license, now)).toBe(true); + }); +}); + +describe("isFileBlockedByExpiredLicense", () => { + const expiredFile = createFile({ durationTo: new Date("2024-06-01T00:00:00.000Z") }); + + it("doesn't block by default", () => { + expect(isFileBlockedByExpiredLicense({ file: expiredFile, config: createConfig({ enableLicenseFeature: true }), currentDate: now })).toBe( + false, + ); + }); + + it("doesn't block when the license feature is disabled", () => { + expect( + isFileBlockedByExpiredLicense({ file: expiredFile, config: createConfig({ blockFilesWithExpiredLicense: true }), currentDate: now }), + ).toBe(false); + }); + + it("blocks a file with an expired license", () => { + expect(isFileBlockedByExpiredLicense({ file: expiredFile, config: licenseFeatureWithBlocking, currentDate: now })).toBe(true); + }); + + it("doesn't block a file with a valid license", () => { + const file = createFile({ durationTo: new Date("2024-06-30T00:00:00.000Z") }); + + expect(isFileBlockedByExpiredLicense({ file, config: licenseFeatureWithBlocking, currentDate: now })).toBe(false); + }); + + it("doesn't block a file without a license", () => { + expect(isFileBlockedByExpiredLicense({ file: createFile(), config: licenseFeatureWithBlocking, currentDate: now })).toBe(false); + }); + + it("doesn't block a file whose license has no end date", () => { + expect( + isFileBlockedByExpiredLicense({ file: createFile({ durationTo: undefined }), config: licenseFeatureWithBlocking, currentDate: now }), + ).toBe(false); + }); +}); + +describe("createPublicCacheControlHeader", () => { + const defaultHeader = "max-age=31536000, s-maxage=86400, public"; + + it("caches for a year by default", () => { + const file = createFile({ durationTo: new Date("2024-06-16T00:00:00.000Z") }); + + expect(createPublicCacheControlHeader({ file, config: createConfig({ enableLicenseFeature: true }), currentDate: now })).toBe(defaultHeader); + }); + + it("caches for a year when the file has no license end date", () => { + expect(createPublicCacheControlHeader({ file: createFile(), config: licenseFeatureWithBlocking, currentDate: now })).toBe(defaultHeader); + }); + + it("caps the cache lifetime at the license's expiration date", () => { + // license expires 2024-06-16T00:00:00.000Z (durationTo + 1 day), which is 12 hours after now + const file = createFile({ durationTo: new Date("2024-06-15T00:00:00.000Z") }); + + expect(createPublicCacheControlHeader({ file, config: licenseFeatureWithBlocking, currentDate: now })).toBe( + `max-age=43200, s-maxage=43200, public`, + ); + }); + + it("only caps the shared max age if the license outlives it", () => { + // license expires in 2 days, which is longer than the default s-maxage of 1 day + const file = createFile({ durationTo: new Date("2024-06-16T12:00:00.000Z") }); + + expect(createPublicCacheControlHeader({ file, config: licenseFeatureWithBlocking, currentDate: now })).toBe( + `max-age=172800, s-maxage=86400, public`, + ); + }); + + it("prevents caching of an expired license", () => { + const file = createFile({ durationTo: new Date("2024-06-01T00:00:00.000Z") }); + + expect(createPublicCacheControlHeader({ file, config: licenseFeatureWithBlocking, currentDate: now })).toBe("max-age=0, s-maxage=0, public"); + }); +}); diff --git a/packages/api/cms-api/src/dam/files/license.util.ts b/packages/api/cms-api/src/dam/files/license.util.ts new file mode 100644 index 00000000000..c24cf1f42b0 --- /dev/null +++ b/packages/api/cms-api/src/dam/files/license.util.ts @@ -0,0 +1,89 @@ +import { add, differenceInCalendarDays, isAfter, isBefore } from "date-fns"; + +import type { DamConfig } from "../dam.config"; +import type { FileInterface } from "./entities/file.entity"; +import type { License } from "./entities/license.embeddable"; + +// if durationTo = '2023-02-27T00:00:00.000Z' then the license is still valid on 27.02.2023 +// and expires at '2023-02-28T00:00:00.000Z' +export function getLicenseExpirationDate(license: License): Date | undefined { + if (license.durationTo) { + return add(license.durationTo, { days: 1 }); + } + return undefined; +} + +export function isLicenseNotValidYet(license: License, currentDate = new Date()): boolean { + return license.durationFrom !== undefined && isBefore(currentDate, license.durationFrom); +} + +export function licenseExpiresWithinThirtyDays(license: License, currentDate = new Date()): boolean { + const expirationDate = getLicenseExpirationDate(license); + + return expirationDate !== undefined && isBefore(currentDate, expirationDate) && differenceInCalendarDays(expirationDate, currentDate) <= 30; +} + +export function hasLicenseExpired(license: License, currentDate = new Date()): boolean { + const expirationDate = getLicenseExpirationDate(license); + + return expirationDate !== undefined && isAfter(currentDate, expirationDate); +} + +export function isLicenseValid(license: License, currentDate = new Date()): boolean { + return !(isLicenseNotValidYet(license, currentDate) || hasLicenseExpired(license, currentDate)); +} + +function isExpiredLicenseBlockingEnabled(config: DamConfig): boolean { + return Boolean(config.enableLicenseFeature && config.blockFilesWithExpiredLicense); +} + +/** + * Whether a file must not be delivered by the public DAM routes because its license has expired. + * Requires both `enableLicenseFeature` and `blockFilesWithExpiredLicense` to be enabled. + */ +export function isFileBlockedByExpiredLicense({ + file, + config, + currentDate = new Date(), +}: { + file: FileInterface; + config: DamConfig; + currentDate?: Date; +}): boolean { + if (!isExpiredLicenseBlockingEnabled(config)) { + return false; + } + + return file.license !== undefined && hasLicenseExpired(file.license, currentDate); +} + +const publicMaxAge = 31536000; // 1 year for browsers +const publicSharedMaxAge = 86400; // 1 day for proxies/cdn's + +/** + * Cache-control header for the public DAM routes. + * + * When delivery of expired files is blocked, the cache lifetime is capped at the license's expiration date. + * Otherwise browsers and CDNs would keep serving the file long after its license has expired. + */ +export function createPublicCacheControlHeader({ + file, + config, + currentDate = new Date(), +}: { + file: FileInterface; + config: DamConfig; + currentDate?: Date; +}): string { + let maxAge = publicMaxAge; + let sharedMaxAge = publicSharedMaxAge; + + const expirationDate = isExpiredLicenseBlockingEnabled(config) && file.license ? getLicenseExpirationDate(file.license) : undefined; + if (expirationDate) { + const secondsUntilExpiration = Math.max(0, Math.floor((expirationDate.getTime() - currentDate.getTime()) / 1000)); + maxAge = Math.min(maxAge, secondsUntilExpiration); + sharedMaxAge = Math.min(sharedMaxAge, secondsUntilExpiration); + } + + return `max-age=${maxAge}, s-maxage=${sharedMaxAge}, public`; +} diff --git a/packages/api/cms-api/src/dam/images/images.controller.ts b/packages/api/cms-api/src/dam/images/images.controller.ts index 9dd56618f61..69881542897 100644 --- a/packages/api/cms-api/src/dam/images/images.controller.ts +++ b/packages/api/cms-api/src/dam/images/images.controller.ts @@ -32,6 +32,7 @@ import { DamConfig } from "../dam.config"; import { DAM_CONFIG } from "../dam.constants"; import { FileInterface } from "../files/entities/file.entity"; import { FilesService } from "../files/files.service"; +import { createPublicCacheControlHeader, isFileBlockedByExpiredLicense } from "../files/license.util"; import { DamScopeAccessControlService } from "../scope-access-control.service"; import { HashImageParams, ImageParams } from "./dto/image.params"; import { ImagesService } from "./images.service"; @@ -128,8 +129,12 @@ export const createImagesController = ({ damBasePath }: { damBasePath: string }) throw new BadRequestException("Content Hash mismatch!"); } + if (isFileBlockedByExpiredLicense({ file, config: this.config })) { + throw new NotFoundException(); + } + return this.pipeCroppedImage(file, params, accept, res, { - "cache-control": "max-age=31536000, s-maxage=86400, public", // Public cache, 1 year for browsers, 1 day for proxies/cdn's + "cache-control": createPublicCacheControlHeader({ file, config: this.config }), }); } @@ -149,8 +154,12 @@ export const createImagesController = ({ damBasePath }: { damBasePath: string }) throw new BadRequestException("Content Hash mismatch!"); } + if (isFileBlockedByExpiredLicense({ file, config: this.config })) { + throw new NotFoundException(); + } + return this.pipeCroppedImage(file, params, accept, res, { - "cache-control": "max-age=31536000, s-maxage=86400, public", // Public cache, 1 year for browsers, 1 day for proxies/cdn's + "cache-control": createPublicCacheControlHeader({ file, config: this.config }), }); }