diff --git a/.changeset/dam-file-url-missing-extension.md b/.changeset/dam-file-url-missing-extension.md new file mode 100644 index 00000000000..3de32f24da2 --- /dev/null +++ b/.changeset/dam-file-url-missing-extension.md @@ -0,0 +1,5 @@ +--- +"@dextinity/cms-api": patch +--- + +Fix DAM file URLs (both inline "open in new tab" and download links) missing the file extension, which caused browsers to save downloaded files without their extension (e.g. `.pdf`). Downloads now also include the original filename via the `Content-Disposition` header. 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..42a7d7b050c 100644 --- a/packages/api/cms-api/src/dam/files/files.controller.ts +++ b/packages/api/cms-api/src/dam/files/files.controller.ts @@ -239,7 +239,7 @@ export function createFilesController({ Scope: PassedScope, damBasePath }: { Sco throw new ForbiddenException(); } - res.setHeader("Content-Disposition", "attachment"); + res.setHeader("Content-Disposition", `attachment; filename="${this.contentDispositionFilename(file.name)}"`); return this.streamFile(file, res, { range, overrideHeaders: { "cache-control": "max-age=31536000, private" } }); // Local caches only (1 year) } @@ -264,7 +264,7 @@ export function createFilesController({ Scope: PassedScope, damBasePath }: { Sco throw new BadRequestException("Content Hash mismatch!"); } - res.setHeader("Content-Disposition", "attachment"); + res.setHeader("Content-Disposition", `attachment; filename="${this.contentDispositionFilename(file.name)}"`); 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 } @@ -301,6 +301,13 @@ export function createFilesController({ Scope: PassedScope, damBasePath }: { Sco return hash === this.filesService.createHash(fileParams); } + // Strips quotes/backslash plus all ASCII control characters (e.g. NUL), which would otherwise make + // `res.setHeader` throw `ERR_INVALID_CHAR` below and fail the download. + private contentDispositionFilename(filename: string): string { + // eslint-disable-next-line no-control-regex + return filename.replace(/["\\\x00-\x1F\x7F]/g, ""); + } + private async streamFile( file: FileInterface, res: Response, 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 1306fd9d9ea..7da6e7a4169 100644 --- a/packages/api/cms-api/src/dam/files/files.service.ts +++ b/packages/api/cms-api/src/dam/files/files.service.ts @@ -5,7 +5,7 @@ import { createHmac } from "crypto"; import exifr from "exifr"; import { createReadStream } from "fs"; import * as hasha from "hasha"; -import { basename, extname, parse } from "path"; +import { basename, extname } from "path"; import probe from "probe-image-size"; import * as rimraf from "rimraf"; @@ -611,7 +611,7 @@ export class FilesService { } async createFileUrl(file: FileInterface, { previewDamUrls = false }: { previewDamUrls?: boolean }): Promise { - const filename = parse(file.name).name; + const filename = file.name; const baseUrl = [`/${this.config.basePath}/files`]; @@ -642,7 +642,7 @@ export class FilesService { } async createFileDownloadUrl(file: FileInterface, { previewDamUrls = false }: { previewDamUrls?: boolean }): Promise { - const filename = parse(file.name).name; + const filename = file.name; const baseUrl = [`/dam/files/download`];