Skip to content

Commit dcdf13d

Browse files
committed
feat: improved filename / mime type detection from asset URLs
This was specifically implemented to support signed S3 URLs, which include content-disposition and content-type in their search params.
1 parent f8d396c commit dcdf13d

File tree

3 files changed

+115
-49
lines changed

3 files changed

+115
-49
lines changed

apps/builder/app/builder/shared/assets/asset-utils.ts

Lines changed: 105 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Asset, FontAsset, ImageAsset } from "@webstudio-is/sdk";
22
import { nanoid } from "nanoid";
33
import type { UploadingFileData } from "~/shared/nano-states";
44

5-
const extensionToMime = new Map([
5+
const imageExtensionToMime = new Map([
66
[".gif", "image/gif"],
77
[".ico", "image/x-icon"],
88
[".jpeg", "image/jpeg"],
@@ -12,42 +12,119 @@ const extensionToMime = new Map([
1212
[".webp", "image/webp"],
1313
] as const);
1414

15-
const extensions = [...extensionToMime.keys()];
15+
const imageExtensions = [...imageExtensionToMime.keys()];
1616

17-
export const imageMimeTypes = [...extensionToMime.values()];
17+
export const imageMimeTypes = [...imageExtensionToMime.values()];
1818

19-
export const getImageNameAndType = (fileName: string) => {
20-
const extension = extensions.find((ext) => fileName.endsWith(ext));
19+
export type ImageMimeType = (typeof imageMimeTypes)[number];
20+
export type ImageExtension = (typeof imageExtensions)[number];
2121

22-
if (extension == null) {
23-
return;
22+
export function getImageExtensionForMimeType(
23+
mimeType: ImageMimeType
24+
): ImageExtension;
25+
26+
export function getImageExtensionForMimeType(
27+
mimeType: string
28+
): ImageExtension | undefined;
29+
30+
export function getImageExtensionForMimeType(mimeType: string) {
31+
const index = imageMimeTypes.indexOf(mimeType as any);
32+
return index > -1 ? imageExtensions[index] : undefined;
33+
}
34+
35+
export function getImageNameAndType(
36+
url: string | URL,
37+
defaultExtension: (typeof imageExtensions)[number]
38+
): [fileName: string, mimeType: string];
39+
40+
export function getImageNameAndType(
41+
url: string | URL,
42+
defaultExtension?: (typeof imageExtensions)[number]
43+
): [fileName: string | undefined, mimeType: string | undefined];
44+
45+
export function getImageNameAndType(
46+
url: string | URL,
47+
defaultExtension?: (typeof imageExtensions)[number]
48+
): [fileName: string | undefined, mimeType: string | undefined] {
49+
let extension: (typeof imageExtensions)[number] | undefined;
50+
51+
if (typeof url === "string") {
52+
extension =
53+
imageExtensions.find((ext) => url.endsWith(ext)) ?? defaultExtension;
54+
55+
return extension
56+
? [url, imageExtensionToMime.get(extension)]
57+
: [undefined, undefined];
2458
}
2559

26-
return [extensionToMime.get(extension)!, fileName] as const;
27-
};
60+
const basename = url.pathname.split("/").at(-1) ?? "";
61+
const contentDispositionKey = /\bcontent-disposition\b/i;
62+
const contentTypeKey = /\bcontent-type\b/i;
63+
64+
let fileName: string | undefined;
65+
let mimeType: string | undefined;
66+
extension = imageExtensions.find((ext) => {
67+
let foundInSearchParams = false;
68+
69+
// Check every search param in case a filename and/or mime type is specified.
70+
for (const key of url.searchParams.keys()) {
71+
const value = url.searchParams.get(key)!;
72+
if (!fileName && contentDispositionKey.test(key)) {
73+
const fileNameMatch = value.match(/\bfilename=(?:"([^"]+)"|([^;]+))/i);
74+
if (fileNameMatch) {
75+
fileName = fileNameMatch[1] ?? fileNameMatch[2] ?? "";
76+
}
77+
} else if (!mimeType && contentTypeKey.test(key)) {
78+
const mimeTypeMatch = value.match(/\b(image\/[\w-+]+)/i);
79+
if (mimeTypeMatch) {
80+
mimeType = mimeTypeMatch[1];
81+
}
82+
} else if (!foundInSearchParams && value.endsWith(ext)) {
83+
foundInSearchParams = true;
84+
}
85+
}
86+
87+
if (basename.endsWith(ext)) {
88+
if (!fileName?.endsWith(ext)) {
89+
fileName = basename;
90+
}
91+
return true;
92+
}
93+
94+
return Boolean(
95+
foundInSearchParams ||
96+
fileName?.endsWith(ext) ||
97+
mimeType === imageExtensionToMime.get(ext)
98+
);
99+
});
28100

29-
const extractImageNameAndMimeTypeFromUrl = (url: URL) => {
30-
const nameFromPath = url.pathname
31-
.split("/")
32-
.map(getImageNameAndType)
33-
.filter(Boolean)[0];
101+
extension ??= defaultExtension;
34102

35-
if (nameFromPath != null) {
36-
return nameFromPath;
103+
// Trust the extension over the mime type.
104+
const impliedMimeType = extension && imageExtensionToMime.get(extension);
105+
if (impliedMimeType) {
106+
mimeType = impliedMimeType;
37107
}
38108

39-
const nameFromSearchParams = [...url.searchParams.values()]
40-
.map(getImageNameAndType)
41-
.filter(Boolean)[0];
109+
return mimeType
110+
? [fileName ?? `${nanoid()}.${extension}`, mimeType]
111+
: [undefined, undefined];
112+
}
42113

43-
if (nameFromSearchParams != null) {
44-
return nameFromSearchParams;
114+
export const getImageName = (file: File | URL) => {
115+
if (file instanceof File) {
116+
return file.name;
45117
}
46118

47-
// Any image format is suitable
48-
const FALLBACK_URL_TYPE = "image/png";
119+
return getImageNameAndType(file)[0];
120+
};
121+
122+
export const getImageType = (file: File | URL | string) => {
123+
if (file instanceof File) {
124+
return file.type;
125+
}
49126

50-
return [FALLBACK_URL_TYPE, `${nanoid()}.png`] as const;
127+
return getImageNameAndType(file)[1];
51128
};
52129

53130
const bufferToHex = (buffer: ArrayBuffer) => {
@@ -78,28 +155,13 @@ export const getSha256HashOfFile = async (file: File) => {
78155
return bufferToHex(hashBuffer);
79156
};
80157

81-
export const getMimeType = (file: File | URL) => {
82-
if (file instanceof File) {
83-
return file.type;
84-
}
85-
86-
return extractImageNameAndMimeTypeFromUrl(file)[0];
87-
};
88-
89-
export const getFileName = (file: File | URL) => {
90-
if (file instanceof File) {
91-
return file.name;
92-
}
93-
94-
return extractImageNameAndMimeTypeFromUrl(file)[1];
95-
};
96-
97158
export const uploadingFileDataToAsset = (
98159
fileData: UploadingFileData
99160
): Asset => {
100-
const mimeType = getMimeType(
101-
fileData.source === "file" ? fileData.file : new URL(fileData.url)
102-
);
161+
const mimeType =
162+
getImageType(
163+
fileData.source === "file" ? fileData.file : new URL(fileData.url)
164+
) ?? "image/png";
103165
const format = mimeType.split("/")[1];
104166

105167
if (mimeType.startsWith("image/")) {

apps/builder/app/builder/shared/assets/use-assets.tsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,17 @@ import {
2222
} from "~/shared/nano-states";
2323
import { serverSyncStore } from "~/shared/sync";
2424
import {
25-
getFileName,
26-
getMimeType,
25+
getImageExtensionForMimeType,
26+
getImageName,
27+
getImageType,
2728
getSha256Hash,
2829
getSha256HashOfFile,
2930
uploadingFileDataToAsset,
3031
} from "./asset-utils";
3132
import { Image, wsImageLoader } from "@webstudio-is/image";
3233
import invariant from "tiny-invariant";
3334
import { fetch } from "~/shared/fetch.client";
35+
import { nanoid } from "nanoid";
3436

3537
export const deleteAssets = (assetIds: Asset["id"][]) => {
3638
serverSyncStore.createTransaction([$assets], (assets) => {
@@ -159,8 +161,10 @@ const uploadAsset = async ({
159161
onError: (error: string) => void;
160162
}) => {
161163
try {
162-
const mimeType = getMimeType(fileOrUrl);
163-
const fileName = getFileName(fileOrUrl);
164+
const mimeType = getImageType(fileOrUrl) ?? "image/png";
165+
const fileName =
166+
getImageName(fileOrUrl) ??
167+
`${nanoid()}${getImageExtensionForMimeType(mimeType) ?? ".png"}`;
164168

165169
const metaFormData = new FormData();
166170
metaFormData.append("projectId", projectId);

apps/builder/app/routes/cgi.image.$.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createReadableStreamFromReadable } from "@remix-run/node";
55
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
66
import { wsImageLoader } from "@webstudio-is/image";
77
import env from "~/env/env.server";
8-
import { getImageNameAndType } from "~/builder/shared/assets/asset-utils";
8+
import { getImageType } from "~/builder/shared/assets/asset-utils";
99
import { fileUploadPath } from "~/shared/asset-client";
1010

1111
const ImageParams = z.object({
@@ -114,7 +114,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
114114
});
115115
}
116116

117-
const [contentType] = getImageNameAndType(name) ?? ["image/png"];
117+
const contentType = getImageType(name) ?? "image/png";
118118

119119
return new Response(
120120
createReadableStreamFromReadable(createReadStream(filePath)),

0 commit comments

Comments
 (0)