Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/dam-block-expired-license-delivery.md
Original file line number Diff line number Diff line change
@@ -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,
},
});
```
5 changes: 5 additions & 0 deletions packages/api/cms-api/src/dam/dam.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
30 changes: 6 additions & 24 deletions packages/api/cms-api/src/dam/files/file-licenses.resolver.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
16 changes: 14 additions & 2 deletions packages/api/cms-api/src/dam/files/files.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;

Expand Down Expand Up @@ -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()
Expand All @@ -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 }),
},
});
}
Expand Down
118 changes: 118 additions & 0 deletions packages/api/cms-api/src/dam/files/license.util.spec.ts
Original file line number Diff line number Diff line change
@@ -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<License>): FileInterface {
return { license: license as License | undefined } as FileInterface;
}

function createConfig(config: Partial<DamConfig>): 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");
});
});
89 changes: 89 additions & 0 deletions packages/api/cms-api/src/dam/files/license.util.ts
Original file line number Diff line number Diff line change
@@ -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`;
}
13 changes: 11 additions & 2 deletions packages/api/cms-api/src/dam/images/images.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 }),
});
}

Expand All @@ -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 }),
});
}

Expand Down
Loading