Skip to content

Commit 40efd2f

Browse files
gary149coyotte508
andauthored
Forward original MIME (#1994)
* Preserve original content-type in fetch-url API The API now returns the content-type from the fetched response instead of always using text/plain. This allows clients to receive the correct content type for the requested resource. * Improve MIME type handling for URL file fetches Adds a utility for safer MIME type inference and selection, using file extensions and forwarded headers. Updates file fetch logic in UrlFetchModal and loadAttachmentsFromUrls to use the new helper, improving filename extraction and MIME type accuracy. The API endpoint now always returns 'text/plain' for safety, exposing the original content type in a custom header. * Update src/lib/components/chat/UrlFetchModal.svelte Co-authored-by: Eliott C. <[email protected]> * Update src/lib/utils/loadAttachmentsFromUrls.ts Co-authored-by: Eliott C. <[email protected]> --------- Co-authored-by: Eliott C. <[email protected]>
1 parent 86f0810 commit 40efd2f

File tree

4 files changed

+100
-16
lines changed

4 files changed

+100
-16
lines changed

src/lib/components/chat/UrlFetchModal.svelte

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import Modal from "../Modal.svelte";
33
import { base } from "$app/paths";
44
import { tick } from "svelte";
5+
import { pickSafeMime } from "$lib/utils/mime";
56
67
interface Props {
78
open?: boolean;
@@ -81,23 +82,36 @@
8182
const txt = await res.text();
8283
throw new Error(txt || `Failed to fetch (${res.status})`);
8384
}
85+
const forwardedType =
86+
res.headers.get("x-forwarded-content-type");
8487
const blob = await res.blob();
88+
const mimeType = pickSafeMime(forwardedType, blob.type, trimmed);
8589
// Optional client-side mime filter (same wildcard semantics as dropzone)
86-
if (acceptMimeTypes.length > 0 && blob.type && !matchesAllowed(blob.type, acceptMimeTypes)) {
90+
if (acceptMimeTypes.length > 0 && mimeType && !matchesAllowed(mimeType, acceptMimeTypes)) {
8791
throw new Error("File type not allowed.");
8892
}
8993
const disp = res.headers.get("content-disposition");
90-
let filename = "attachment";
91-
const match = disp?.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
92-
if (match && match[1]) filename = match[1].replace(/['"]/g, "");
93-
else {
94+
const filename = (() => {
95+
const filenameStar = disp?.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
96+
if (filenameStar) {
97+
const cleaned = filenameStar.trim().replace(/['"]/g, "");
98+
try {
99+
return decodeURIComponent(cleaned);
100+
} catch {
101+
return cleaned;
102+
}
103+
}
104+
const filenameMatch = disp?.match(/filename="?([^";]+)"?/i)?.[1];
105+
if (filenameMatch) return filenameMatch.trim();
94106
try {
95107
const u = new URL(trimmed);
96108
const last = u.pathname.split("/").pop() || "attachment";
97-
filename = decodeURIComponent(last);
98-
} catch {}
99-
}
100-
const file = new File([blob], filename, { type: blob.type || "application/octet-stream" });
109+
return decodeURIComponent(last);
110+
} catch {
111+
return "attachment";
112+
}
113+
})();
114+
const file = new File([blob], filename, { type: mimeType });
101115
onfiles?.([file]);
102116
close();
103117
} catch (e) {

src/lib/utils/loadAttachmentsFromUrls.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { base } from "$app/paths";
2+
import { pickSafeMime } from "$lib/utils/mime";
23

34
export interface AttachmentLoadResult {
45
files: File[];
@@ -31,10 +32,18 @@ function parseAttachmentUrls(searchParams: URLSearchParams): string[] {
3132
function extractFilename(url: string, contentDisposition?: string | null): string {
3233
// Try to get filename from Content-Disposition header
3334
if (contentDisposition) {
34-
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
35-
if (match && match[1]) {
36-
return match[1].replace(/['"]/g, "");
35+
const filenameStar = contentDisposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
36+
if (filenameStar) {
37+
const cleaned = filenameStar.trim().replace(/['"]/g, "");
38+
try {
39+
return decodeURIComponent(cleaned);
40+
} catch {
41+
return cleaned;
42+
}
3743
}
44+
45+
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
46+
if (match && match[1]) return match[1].replace(/['"]/g, "");
3847
}
3948

4049
// Fallback: extract from URL
@@ -82,13 +91,16 @@ export async function loadAttachmentsFromUrls(
8291
return;
8392
}
8493

94+
const forwardedType =
95+
response.headers.get("x-forwarded-content-type");
8596
const blob = await response.blob();
97+
const mimeType = pickSafeMime(forwardedType, blob.type, url);
8698
const contentDisposition = response.headers.get("content-disposition");
8799
const filename = extractFilename(url, contentDisposition);
88100

89101
// Create File object
90102
const file = new File([blob], filename, {
91-
type: blob.type || "application/octet-stream",
103+
type: mimeType,
92104
});
93105

94106
files.push(file);

src/lib/utils/mime.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Lightweight MIME helpers to avoid new dependencies.
2+
3+
const EXTENSION_TO_MIME: Record<string, string> = {
4+
png: "image/png",
5+
jpg: "image/jpeg",
6+
jpe: "image/jpeg",
7+
jpeg: "image/jpeg",
8+
gif: "image/gif",
9+
webp: "image/webp",
10+
svg: "image/svg+xml",
11+
pdf: "application/pdf",
12+
txt: "text/plain",
13+
csv: "text/csv",
14+
json: "application/json",
15+
mp3: "audio/mpeg",
16+
wav: "audio/wav",
17+
ogg: "audio/ogg",
18+
mp4: "video/mp4",
19+
mov: "video/quicktime",
20+
webm: "video/webm",
21+
zip: "application/zip",
22+
gz: "application/gzip",
23+
tgz: "application/gzip",
24+
tar: "application/x-tar",
25+
html: "text/html",
26+
htm: "text/html",
27+
md: "text/markdown",
28+
};
29+
30+
export function guessMimeFromUrl(url: string): string | undefined {
31+
try {
32+
const pathname = new URL(url).pathname;
33+
const ext = pathname.split(".").pop()?.toLowerCase();
34+
if (ext && EXTENSION_TO_MIME[ext]) return EXTENSION_TO_MIME[ext];
35+
} catch {
36+
/* ignore */
37+
}
38+
return undefined;
39+
}
40+
41+
export function pickSafeMime(
42+
forwardedType: string | null,
43+
blobType: string | undefined,
44+
url: string
45+
): string {
46+
const inferred = guessMimeFromUrl(url);
47+
if (forwardedType) return forwardedType;
48+
if (
49+
inferred &&
50+
(!blobType || blobType === "application/octet-stream" || blobType.startsWith("text/plain"))
51+
) {
52+
return inferred;
53+
}
54+
if (blobType) return blobType;
55+
return inferred || "application/octet-stream";
56+
}

src/routes/api/fetch-url/+server.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,12 +51,14 @@ export async function GET({ url }) {
5151
}
5252

5353
// Stream the response back
54-
// Always return as text/plain to prevent any HTML/JS execution
55-
const contentType = "text/plain; charset=utf-8";
54+
const originalContentType = response.headers.get("content-type") || "application/octet-stream";
55+
// Send as text/plain for safety; expose the original type via secondary header
56+
const safeContentType = "text/plain; charset=utf-8";
5657
const contentDisposition = response.headers.get("content-disposition");
5758

5859
const headers: HeadersInit = {
59-
"Content-Type": contentType,
60+
"Content-Type": safeContentType,
61+
"X-Forwarded-Content-Type": originalContentType,
6062
"Cache-Control": "public, max-age=3600",
6163
...(contentDisposition ? { "Content-Disposition": contentDisposition } : {}),
6264
...SECURITY_HEADERS,

0 commit comments

Comments
 (0)