Url attachments (#1965)
Browse files* Add custom scrollbar class to Modal component
Applied the 'scrollbar-custom' class to the Modal's main container to enable custom scrollbar styling. This improves the appearance and consistency of scrollbars within modals.
* Add support for loading attachments from URLs
Implements the ability to load file attachments via URL parameters using a new utility (loadAttachmentsFromUrls) and a server-side proxy endpoint (/api/fetch-url) to securely fetch remote files. Updates chat and model pages to handle 'attachments' query parameters, injects file content into user messages, and improves multimodal message preparation. Also includes minor UI adjustments for file display and refactors Plausible analytics script initialization.
* Block redirects in fetch-url API endpoint
- src/lib/components/Modal.svelte +2 -2
- src/lib/components/chat/UploadedFile.svelte +8 -6
- src/lib/server/endpoints/openai/endpointOai.ts +62 -17
- src/lib/utils/loadAttachmentsFromUrls.ts +104 -0
- src/routes/+layout.svelte +1 -1
- src/routes/+page.svelte +21 -1
- src/routes/api/fetch-url/+server.ts +98 -0
- src/routes/models/[...model]/+page.svelte +21 -1
|
@@ -78,7 +78,7 @@
|
|
| 78 |
bind:this={modalEl}
|
| 79 |
onkeydown={handleKeydown}
|
| 80 |
class={[
|
| 81 |
-
"relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200",
|
| 82 |
width,
|
| 83 |
]}
|
| 84 |
>
|
|
@@ -97,7 +97,7 @@
|
|
| 97 |
onkeydown={handleKeydown}
|
| 98 |
in:fly={{ y: 100 }}
|
| 99 |
class={[
|
| 100 |
-
"relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200",
|
| 101 |
width,
|
| 102 |
]}
|
| 103 |
>
|
|
|
|
| 78 |
bind:this={modalEl}
|
| 79 |
onkeydown={handleKeydown}
|
| 80 |
class={[
|
| 81 |
+
"scrollbar-custom relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200",
|
| 82 |
width,
|
| 83 |
]}
|
| 84 |
>
|
|
|
|
| 97 |
onkeydown={handleKeydown}
|
| 98 |
in:fly={{ y: 100 }}
|
| 99 |
class={[
|
| 100 |
+
"scrollbar-custom relative mx-auto max-h-[95dvh] max-w-[90dvw] overflow-y-auto overflow-x-hidden rounded-2xl bg-white shadow-2xl outline-none dark:bg-gray-800 dark:text-gray-200",
|
| 101 |
width,
|
| 102 |
]}
|
| 103 |
>
|
|
@@ -73,8 +73,11 @@
|
|
| 73 |
/>
|
| 74 |
{/if}
|
| 75 |
{:else if isPlainText(file.mime)}
|
| 76 |
-
<div class="relative flex h-full w-full flex-col gap-
|
| 77 |
-
<
|
|
|
|
|
|
|
|
|
|
| 78 |
{#if file.mime === "application/vnd.chatui.clipboard"}
|
| 79 |
<p class="text-sm text-gray-500">
|
| 80 |
If you prefer to inject clipboard content directly in the chat, you can disable this
|
|
@@ -95,7 +98,7 @@
|
|
| 95 |
</div>
|
| 96 |
{:then result}
|
| 97 |
<pre
|
| 98 |
-
class="w-full whitespace-pre-wrap break-words pt-0 text-
|
| 99 |
class:font-sans={file.mime === "text/plain" ||
|
| 100 |
file.mime === "application/vnd.chatui.clipboard"}
|
| 101 |
class:font-mono={file.mime !== "text/plain" &&
|
|
@@ -103,7 +106,7 @@
|
|
| 103 |
{/await}
|
| 104 |
{:else}
|
| 105 |
<pre
|
| 106 |
-
class="w-full whitespace-pre-wrap break-words pt-0 text-
|
| 107 |
class:font-sans={file.mime === "text/plain" ||
|
| 108 |
file.mime === "application/vnd.chatui.clipboard"}
|
| 109 |
class:font-mono={file.mime !== "text/plain" &&
|
|
@@ -124,7 +127,6 @@
|
|
| 124 |
showModal = true;
|
| 125 |
}
|
| 126 |
}}
|
| 127 |
-
class="mt-4"
|
| 128 |
class:clickable={isClickable}
|
| 129 |
role="button"
|
| 130 |
tabindex="0"
|
|
@@ -161,7 +163,7 @@
|
|
| 161 |
</div>
|
| 162 |
{:else if isPlainText(file.mime)}
|
| 163 |
<div
|
| 164 |
-
class="flex h-14 w-
|
| 165 |
class:file-hoverable={isClickable}
|
| 166 |
>
|
| 167 |
<div
|
|
|
|
| 73 |
/>
|
| 74 |
{/if}
|
| 75 |
{:else if isPlainText(file.mime)}
|
| 76 |
+
<div class="relative flex h-full w-full flex-col gap-2 p-4">
|
| 77 |
+
<div class="flex items-center gap-1">
|
| 78 |
+
<CarbonDocument />
|
| 79 |
+
<h3 class="text-lg font-semibold">{file.name}</h3>
|
| 80 |
+
</div>
|
| 81 |
{#if file.mime === "application/vnd.chatui.clipboard"}
|
| 82 |
<p class="text-sm text-gray-500">
|
| 83 |
If you prefer to inject clipboard content directly in the chat, you can disable this
|
|
|
|
| 98 |
</div>
|
| 99 |
{:then result}
|
| 100 |
<pre
|
| 101 |
+
class="w-full whitespace-pre-wrap break-words pt-0 text-xs"
|
| 102 |
class:font-sans={file.mime === "text/plain" ||
|
| 103 |
file.mime === "application/vnd.chatui.clipboard"}
|
| 104 |
class:font-mono={file.mime !== "text/plain" &&
|
|
|
|
| 106 |
{/await}
|
| 107 |
{:else}
|
| 108 |
<pre
|
| 109 |
+
class="w-full whitespace-pre-wrap break-words pt-0 text-xs"
|
| 110 |
class:font-sans={file.mime === "text/plain" ||
|
| 111 |
file.mime === "application/vnd.chatui.clipboard"}
|
| 112 |
class:font-mono={file.mime !== "text/plain" &&
|
|
|
|
| 127 |
showModal = true;
|
| 128 |
}
|
| 129 |
}}
|
|
|
|
| 130 |
class:clickable={isClickable}
|
| 131 |
role="button"
|
| 132 |
tabindex="0"
|
|
|
|
| 163 |
</div>
|
| 164 |
{:else if isPlainText(file.mime)}
|
| 165 |
<div
|
| 166 |
+
class="flex h-14 w-64 items-center gap-2 overflow-hidden rounded-xl border border-gray-200 bg-white p-2 dark:border-gray-800 dark:bg-gray-900 2xl:w-72"
|
| 167 |
class:file-hoverable={isClickable}
|
| 168 |
>
|
| 169 |
<div
|
|
@@ -246,12 +246,27 @@ async function prepareMessages(
|
|
| 246 |
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam[]> {
|
| 247 |
return Promise.all(
|
| 248 |
messages.map(async (message) => {
|
| 249 |
-
if (message.from === "user" &&
|
| 250 |
-
const imageParts = await prepareFiles(
|
| 251 |
-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
return { role: message.from, content: parts };
|
| 254 |
}
|
|
|
|
|
|
|
|
|
|
| 255 |
}
|
| 256 |
return { role: message.from, content: message.content };
|
| 257 |
})
|
|
@@ -260,18 +275,48 @@ async function prepareMessages(
|
|
| 260 |
|
| 261 |
async function prepareFiles(
|
| 262 |
imageProcessor: ReturnType<typeof makeImageProcessor>,
|
| 263 |
-
files: MessageFile[]
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
);
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
}
|
|
|
|
| 246 |
): Promise<OpenAI.Chat.Completions.ChatCompletionMessageParam[]> {
|
| 247 |
return Promise.all(
|
| 248 |
messages.map(async (message) => {
|
| 249 |
+
if (message.from === "user" && message.files && message.files.length > 0) {
|
| 250 |
+
const { imageParts, textContent } = await prepareFiles(
|
| 251 |
+
imageProcessor,
|
| 252 |
+
message.files,
|
| 253 |
+
isMultimodal
|
| 254 |
+
);
|
| 255 |
+
|
| 256 |
+
// If we have text files, prepend their content to the message
|
| 257 |
+
let messageText = message.content;
|
| 258 |
+
if (textContent.length > 0) {
|
| 259 |
+
messageText = textContent + "\n\n" + message.content;
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
// If we have images and multimodal is enabled, use structured content
|
| 263 |
+
if (imageParts.length > 0 && isMultimodal) {
|
| 264 |
+
const parts = [{ type: "text" as const, text: messageText }, ...imageParts];
|
| 265 |
return { role: message.from, content: parts };
|
| 266 |
}
|
| 267 |
+
|
| 268 |
+
// Otherwise just use the text (possibly with injected file content)
|
| 269 |
+
return { role: message.from, content: messageText };
|
| 270 |
}
|
| 271 |
return { role: message.from, content: message.content };
|
| 272 |
})
|
|
|
|
| 275 |
|
| 276 |
async function prepareFiles(
|
| 277 |
imageProcessor: ReturnType<typeof makeImageProcessor>,
|
| 278 |
+
files: MessageFile[],
|
| 279 |
+
isMultimodal: boolean
|
| 280 |
+
): Promise<{
|
| 281 |
+
imageParts: OpenAI.Chat.Completions.ChatCompletionContentPartImage[];
|
| 282 |
+
textContent: string;
|
| 283 |
+
}> {
|
| 284 |
+
// Separate image and text files
|
| 285 |
+
const imageFiles = files.filter((file) => file.mime.startsWith("image/"));
|
| 286 |
+
const textFiles = files.filter(
|
| 287 |
+
(file) =>
|
| 288 |
+
file.mime.startsWith("text/") ||
|
| 289 |
+
file.mime === "application/json" ||
|
| 290 |
+
file.mime === "application/xml" ||
|
| 291 |
+
file.mime === "application/csv"
|
| 292 |
);
|
| 293 |
+
|
| 294 |
+
// Process images if multimodal is enabled
|
| 295 |
+
let imageParts: OpenAI.Chat.Completions.ChatCompletionContentPartImage[] = [];
|
| 296 |
+
if (isMultimodal && imageFiles.length > 0) {
|
| 297 |
+
const processedFiles = await Promise.all(imageFiles.map(imageProcessor));
|
| 298 |
+
imageParts = processedFiles.map((file) => ({
|
| 299 |
+
type: "image_url" as const,
|
| 300 |
+
image_url: {
|
| 301 |
+
url: `data:${file.mime};base64,${file.image.toString("base64")}`,
|
| 302 |
+
// Improves compatibility with some OpenAI-compatible servers
|
| 303 |
+
// that expect an explicit detail setting.
|
| 304 |
+
detail: "auto",
|
| 305 |
+
},
|
| 306 |
+
}));
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
// Process text files - inject their content
|
| 310 |
+
let textContent = "";
|
| 311 |
+
if (textFiles.length > 0) {
|
| 312 |
+
const textParts = await Promise.all(
|
| 313 |
+
textFiles.map(async (file) => {
|
| 314 |
+
const content = Buffer.from(file.value, "base64").toString("utf-8");
|
| 315 |
+
return `<document name="${file.name}" type="${file.mime}">\n${content}\n</document>`;
|
| 316 |
+
})
|
| 317 |
+
);
|
| 318 |
+
textContent = textParts.join("\n\n");
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
return { imageParts, textContent };
|
| 322 |
}
|
|
@@ -0,0 +1,104 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { base } from "$app/paths";
|
| 2 |
+
|
| 3 |
+
export interface AttachmentLoadResult {
|
| 4 |
+
files: File[];
|
| 5 |
+
errors: string[];
|
| 6 |
+
}
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Parse attachment URLs from query parameters
|
| 10 |
+
* Supports both comma-separated (?attachments=url1,url2) and multiple params (?attachments=url1&attachments=url2)
|
| 11 |
+
*/
|
| 12 |
+
function parseAttachmentUrls(searchParams: URLSearchParams): string[] {
|
| 13 |
+
const urls: string[] = [];
|
| 14 |
+
|
| 15 |
+
// Get all 'attachments' parameters
|
| 16 |
+
const attachmentParams = searchParams.getAll("attachments");
|
| 17 |
+
|
| 18 |
+
for (const param of attachmentParams) {
|
| 19 |
+
// Split by comma in case multiple URLs are in one param
|
| 20 |
+
const splitUrls = param.split(",").map((url) => url.trim());
|
| 21 |
+
urls.push(...splitUrls);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
// Filter out empty strings
|
| 25 |
+
return urls.filter((url) => url.length > 0);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Extract filename from URL or Content-Disposition header
|
| 30 |
+
*/
|
| 31 |
+
function extractFilename(url: string, contentDisposition?: string | null): string {
|
| 32 |
+
// Try to get filename from Content-Disposition header
|
| 33 |
+
if (contentDisposition) {
|
| 34 |
+
const match = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
|
| 35 |
+
if (match && match[1]) {
|
| 36 |
+
return match[1].replace(/['"]/g, "");
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Fallback: extract from URL
|
| 41 |
+
try {
|
| 42 |
+
const urlObj = new URL(url);
|
| 43 |
+
const pathname = urlObj.pathname;
|
| 44 |
+
const segments = pathname.split("/");
|
| 45 |
+
const lastSegment = segments[segments.length - 1];
|
| 46 |
+
|
| 47 |
+
if (lastSegment && lastSegment.length > 0) {
|
| 48 |
+
return decodeURIComponent(lastSegment);
|
| 49 |
+
}
|
| 50 |
+
} catch {
|
| 51 |
+
// Invalid URL, fall through to default
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
return "attachment";
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Load files from remote URLs via server-side proxy
|
| 59 |
+
*/
|
| 60 |
+
export async function loadAttachmentsFromUrls(
|
| 61 |
+
searchParams: URLSearchParams
|
| 62 |
+
): Promise<AttachmentLoadResult> {
|
| 63 |
+
const urls = parseAttachmentUrls(searchParams);
|
| 64 |
+
|
| 65 |
+
if (urls.length === 0) {
|
| 66 |
+
return { files: [], errors: [] };
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const files: File[] = [];
|
| 70 |
+
const errors: string[] = [];
|
| 71 |
+
|
| 72 |
+
await Promise.all(
|
| 73 |
+
urls.map(async (url) => {
|
| 74 |
+
try {
|
| 75 |
+
// Fetch via our proxy endpoint to bypass CORS
|
| 76 |
+
const proxyUrl = `${base}/api/fetch-url?${new URLSearchParams({ url })}`;
|
| 77 |
+
const response = await fetch(proxyUrl);
|
| 78 |
+
|
| 79 |
+
if (!response.ok) {
|
| 80 |
+
const errorText = await response.text();
|
| 81 |
+
errors.push(`Failed to fetch ${url}: ${errorText}`);
|
| 82 |
+
return;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const blob = await response.blob();
|
| 86 |
+
const contentDisposition = response.headers.get("content-disposition");
|
| 87 |
+
const filename = extractFilename(url, contentDisposition);
|
| 88 |
+
|
| 89 |
+
// Create File object
|
| 90 |
+
const file = new File([blob], filename, {
|
| 91 |
+
type: blob.type || "application/octet-stream",
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
files.push(file);
|
| 95 |
+
} catch (err) {
|
| 96 |
+
const message = err instanceof Error ? err.message : "Unknown error";
|
| 97 |
+
errors.push(`Failed to load ${url}: ${message}`);
|
| 98 |
+
console.error(`Error loading attachment from ${url}:`, err);
|
| 99 |
+
}
|
| 100 |
+
})
|
| 101 |
+
);
|
| 102 |
+
|
| 103 |
+
return { files, errors };
|
| 104 |
+
}
|
|
@@ -57,7 +57,7 @@
|
|
| 57 |
}, 5000);
|
| 58 |
}
|
| 59 |
|
| 60 |
-
|
| 61 |
publicConfig.isHuggingChat &&
|
| 62 |
Boolean(page.params?.id) &&
|
| 63 |
page.route.id?.startsWith("/conversation/")
|
|
|
|
| 57 |
}, 5000);
|
| 58 |
}
|
| 59 |
|
| 60 |
+
let canShare = $derived(
|
| 61 |
publicConfig.isHuggingChat &&
|
| 62 |
Boolean(page.params?.id) &&
|
| 63 |
page.route.id?.startsWith("/conversation/")
|
|
@@ -14,6 +14,7 @@
|
|
| 14 |
import { sanitizeUrlParam } from "$lib/utils/urlParams";
|
| 15 |
import { onMount, tick } from "svelte";
|
| 16 |
import { loading } from "$lib/stores/loading.js";
|
|
|
|
| 17 |
|
| 18 |
let { data } = $props();
|
| 19 |
|
|
@@ -75,8 +76,27 @@
|
|
| 75 |
}
|
| 76 |
}
|
| 77 |
|
| 78 |
-
onMount(() => {
|
| 79 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
const query = sanitizeUrlParam(page.url.searchParams.get("q"));
|
| 81 |
if (query) {
|
| 82 |
void createConversation(query);
|
|
|
|
| 14 |
import { sanitizeUrlParam } from "$lib/utils/urlParams";
|
| 15 |
import { onMount, tick } from "svelte";
|
| 16 |
import { loading } from "$lib/stores/loading.js";
|
| 17 |
+
import { loadAttachmentsFromUrls } from "$lib/utils/loadAttachmentsFromUrls";
|
| 18 |
|
| 19 |
let { data } = $props();
|
| 20 |
|
|
|
|
| 76 |
}
|
| 77 |
}
|
| 78 |
|
| 79 |
+
onMount(async () => {
|
| 80 |
try {
|
| 81 |
+
// Handle attachments parameter first
|
| 82 |
+
if (page.url.searchParams.has("attachments")) {
|
| 83 |
+
const result = await loadAttachmentsFromUrls(page.url.searchParams);
|
| 84 |
+
files = result.files;
|
| 85 |
+
|
| 86 |
+
// Show errors if any
|
| 87 |
+
if (result.errors.length > 0) {
|
| 88 |
+
console.error("Failed to load some attachments:", result.errors);
|
| 89 |
+
error.set(
|
| 90 |
+
`Failed to load ${result.errors.length} attachment(s). Check console for details.`
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
// Clean up URL
|
| 95 |
+
const url = new URL(page.url);
|
| 96 |
+
url.searchParams.delete("attachments");
|
| 97 |
+
history.replaceState({}, "", url);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
const query = sanitizeUrlParam(page.url.searchParams.get("q"));
|
| 101 |
if (query) {
|
| 102 |
void createConversation(query);
|
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { error } from "@sveltejs/kit";
|
| 2 |
+
|
| 3 |
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
| 4 |
+
const FETCH_TIMEOUT = 30000; // 30 seconds
|
| 5 |
+
|
| 6 |
+
// Validate URL safety - HTTPS only
|
| 7 |
+
function isValidUrl(urlString: string): boolean {
|
| 8 |
+
try {
|
| 9 |
+
const url = new URL(urlString);
|
| 10 |
+
// Only allow HTTPS protocol
|
| 11 |
+
if (url.protocol !== "https:") {
|
| 12 |
+
return false;
|
| 13 |
+
}
|
| 14 |
+
// Prevent localhost/private IPs (basic check)
|
| 15 |
+
const hostname = url.hostname.toLowerCase();
|
| 16 |
+
if (
|
| 17 |
+
hostname === "localhost" ||
|
| 18 |
+
hostname.startsWith("127.") ||
|
| 19 |
+
hostname.startsWith("192.168.") ||
|
| 20 |
+
hostname.startsWith("10.") ||
|
| 21 |
+
hostname.startsWith("172.16.") ||
|
| 22 |
+
hostname === "[::1]" ||
|
| 23 |
+
hostname === "0.0.0.0"
|
| 24 |
+
) {
|
| 25 |
+
return false;
|
| 26 |
+
}
|
| 27 |
+
return true;
|
| 28 |
+
} catch {
|
| 29 |
+
return false;
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export async function GET({ url, fetch }) {
|
| 34 |
+
const targetUrl = url.searchParams.get("url");
|
| 35 |
+
|
| 36 |
+
if (!targetUrl) {
|
| 37 |
+
throw error(400, "Missing 'url' parameter");
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
if (!isValidUrl(targetUrl)) {
|
| 41 |
+
throw error(400, "Invalid or unsafe URL (only HTTPS is supported)");
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
try {
|
| 45 |
+
// Fetch with timeout
|
| 46 |
+
const controller = new AbortController();
|
| 47 |
+
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
| 48 |
+
|
| 49 |
+
const response = await fetch(targetUrl, {
|
| 50 |
+
signal: controller.signal,
|
| 51 |
+
redirect: "error", // Block all redirects
|
| 52 |
+
headers: {
|
| 53 |
+
"User-Agent": "HuggingChat-Attachment-Fetcher/1.0",
|
| 54 |
+
},
|
| 55 |
+
}).finally(() => clearTimeout(timeoutId));
|
| 56 |
+
|
| 57 |
+
if (!response.ok) {
|
| 58 |
+
throw error(response.status, `Failed to fetch: ${response.statusText}`);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
// Check content length if available
|
| 62 |
+
const contentLength = response.headers.get("content-length");
|
| 63 |
+
if (contentLength && parseInt(contentLength) > MAX_FILE_SIZE) {
|
| 64 |
+
throw error(413, "File too large (max 10MB)");
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
// Stream the response back
|
| 68 |
+
const contentType = response.headers.get("content-type") || "application/octet-stream";
|
| 69 |
+
const contentDisposition = response.headers.get("content-disposition");
|
| 70 |
+
|
| 71 |
+
const headers: HeadersInit = {
|
| 72 |
+
"Content-Type": contentType,
|
| 73 |
+
"Cache-Control": "public, max-age=3600",
|
| 74 |
+
};
|
| 75 |
+
|
| 76 |
+
if (contentDisposition) {
|
| 77 |
+
headers["Content-Disposition"] = contentDisposition;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Get the body as array buffer to check size
|
| 81 |
+
const arrayBuffer = await response.arrayBuffer();
|
| 82 |
+
|
| 83 |
+
if (arrayBuffer.byteLength > MAX_FILE_SIZE) {
|
| 84 |
+
throw error(413, "File too large (max 10MB)");
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
return new Response(arrayBuffer, { headers });
|
| 88 |
+
} catch (err) {
|
| 89 |
+
if (err instanceof Error) {
|
| 90 |
+
if (err.name === "AbortError") {
|
| 91 |
+
throw error(504, "Request timeout");
|
| 92 |
+
}
|
| 93 |
+
console.error("Error fetching URL:", err);
|
| 94 |
+
throw error(500, `Failed to fetch URL: ${err.message}`);
|
| 95 |
+
}
|
| 96 |
+
throw error(500, "Failed to fetch URL");
|
| 97 |
+
}
|
| 98 |
+
}
|
|
@@ -11,6 +11,7 @@
|
|
| 11 |
import { ERROR_MESSAGES, error } from "$lib/stores/errors";
|
| 12 |
import { pendingMessage } from "$lib/stores/pendingMessage";
|
| 13 |
import { sanitizeUrlParam } from "$lib/utils/urlParams";
|
|
|
|
| 14 |
|
| 15 |
let { data } = $props();
|
| 16 |
|
|
@@ -61,8 +62,27 @@
|
|
| 61 |
}
|
| 62 |
}
|
| 63 |
|
| 64 |
-
onMount(() => {
|
| 65 |
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
const query = sanitizeUrlParam(page.url.searchParams.get("q"));
|
| 67 |
if (query) {
|
| 68 |
void createConversation(query);
|
|
|
|
| 11 |
import { ERROR_MESSAGES, error } from "$lib/stores/errors";
|
| 12 |
import { pendingMessage } from "$lib/stores/pendingMessage";
|
| 13 |
import { sanitizeUrlParam } from "$lib/utils/urlParams";
|
| 14 |
+
import { loadAttachmentsFromUrls } from "$lib/utils/loadAttachmentsFromUrls";
|
| 15 |
|
| 16 |
let { data } = $props();
|
| 17 |
|
|
|
|
| 62 |
}
|
| 63 |
}
|
| 64 |
|
| 65 |
+
onMount(async () => {
|
| 66 |
try {
|
| 67 |
+
// Handle attachments parameter first
|
| 68 |
+
if (page.url.searchParams.has("attachments")) {
|
| 69 |
+
const result = await loadAttachmentsFromUrls(page.url.searchParams);
|
| 70 |
+
files = result.files;
|
| 71 |
+
|
| 72 |
+
// Show errors if any
|
| 73 |
+
if (result.errors.length > 0) {
|
| 74 |
+
console.error("Failed to load some attachments:", result.errors);
|
| 75 |
+
error.set(
|
| 76 |
+
`Failed to load ${result.errors.length} attachment(s). Check console for details.`
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Clean up URL
|
| 81 |
+
const url = new URL(page.url);
|
| 82 |
+
url.searchParams.delete("attachments");
|
| 83 |
+
history.replaceState({}, "", url);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
const query = sanitizeUrlParam(page.url.searchParams.get("q"));
|
| 87 |
if (query) {
|
| 88 |
void createConversation(query);
|