Skip to content
Merged
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
8 changes: 4 additions & 4 deletions components/editor/ai/chat-image-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ export function ChatImagePreview({ name, src, className, alt, ...props }: ChatIm
return (
<Dialog>
<DialogTrigger asChild>
<div className="group/preview relative isolate size-full cursor-pointer overflow-hidden border">
<div className="group/preview relative isolate size-full cursor-pointer overflow-hidden rounded-lg border">
<Image
width={300}
height={300}
width={250}
height={250}
src={src}
className={cn(
"h-auto max-h-[300px] w-auto max-w-[300px] object-cover object-center",
"h-auto max-h-[250px] w-auto max-w-[250px] object-cover object-center",
className
)}
alt={alt || "Image preview"}
Expand Down
8 changes: 7 additions & 1 deletion components/editor/ai/drag-and-drop-image-uploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ export function DragAndDropImageUploader({
multiple: true,
noClick: true,
disabled,
accept: { "image/*": [] },
accept: {
"image/jpeg": [],
"image/jpg": [],
"image/png": [],
"image/webp": [],
"image/svg+xml": [],
},
});

return (
Expand Down
3 changes: 2 additions & 1 deletion components/editor/ai/image-uploader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { TooltipWrapper } from "@/components/tooltip-wrapper";
import { Button } from "@/components/ui/button";
import { MAX_IMAGE_FILE_SIZE, MAX_IMAGE_FILES } from "@/lib/constants";
import { cn } from "@/lib/utils";
import { ALLOWED_IMAGE_TYPES } from "@/utils/ai/image-upload";
import { ImagePlus } from "lucide-react";
import { ComponentProps } from "react";

Expand Down Expand Up @@ -32,7 +33,7 @@ export function ImageUploader({
multiple
max={MAX_IMAGE_FILES}
size={MAX_IMAGE_FILE_SIZE}
accept="image/*"
accept={ALLOWED_IMAGE_TYPES.join(",")}
className="hidden"
aria-label="Upload image for theme generation"
ref={fileInputRef}
Expand Down
7 changes: 2 additions & 5 deletions components/editor/ai/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,18 +152,15 @@ function UserMessage({

if (images.length === 1) {
return (
<div className="self-end overflow-hidden rounded-lg">
<div className="self-end">
<ChatImagePreview src={images[0].url} alt="Image preview" />
</div>
);
} else if (images.length > 1) {
return (
<div className="flex flex-row items-center justify-end gap-1 self-end">
{images.map((image, idx) => (
<div
key={idx}
className="aspect-square size-full max-w-32 flex-1 overflow-hidden rounded-lg"
>
<div key={idx} className="aspect-square size-full max-w-32 flex-1">
<ChatImagePreview
className="size-full object-cover"
src={image.url}
Expand Down
2 changes: 1 addition & 1 deletion components/editor/ai/uploaded-image-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function UploadedImagePreview({
}: ImagePreviewProps) {
if (isImageLoading) {
return (
<div className="bg-muted inset-0 flex size-14 items-center justify-center">
<div className="bg-muted flex size-14 items-center justify-center rounded-md border">
<Loader className="text-muted-foreground size-4 animate-spin" />
</div>
);
Expand Down
10 changes: 9 additions & 1 deletion hooks/use-image-upload-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ export const imageUploadReducer: Reducer<PromptImageWithLoading[], ImageUploadAc
case "REMOVE": {
return state.filter((_, i) => i !== action.payload.index);
}
case "REMOVE_BY_URL": {
return state.filter((image) => image.url !== action.payload.url);
}
case "CLEAR": {
return [];
}
Expand All @@ -40,7 +43,12 @@ export const createSyncedImageUploadReducer = (
return (state, action) => {
const newState = imageUploadReducer(state, action);
// Only sync user actions, not initialization
if (action.type === "UPDATE_URL" || action.type === "REMOVE" || action.type === "CLEAR") {
if (
action.type === "UPDATE_URL" ||
action.type === "REMOVE" ||
action.type === "REMOVE_BY_URL" ||
action.type === "CLEAR"
) {
setImagesDraft(newState.filter((img) => !img.loading).map(({ url }) => ({ url })));
}
// Note: INITIALIZE intentionally doesn't sync back to avoid circular updates
Expand Down
81 changes: 77 additions & 4 deletions hooks/use-image-upload.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import { useToast } from "@/components/ui/use-toast";
import { MAX_SVG_FILE_SIZE } from "@/lib/constants";
import { PromptImage } from "@/types/ai";
import {
ALLOWED_IMAGE_TYPES,
optimizeSvgContent,
validateSvgContent,
} from "@/utils/ai/image-upload";
import { useRef } from "react";

export type PromptImageWithLoading = PromptImage & { loading: boolean };

export type ImageUploadAction =
| { type: "ADD"; payload: { url: string; file: File }[] }
| { type: "REMOVE"; payload: { index: number } }
| { type: "REMOVE_BY_URL"; payload: { url: string } }
| { type: "CLEAR" }
| { type: "UPDATE_URL"; payload: { tempUrl: string; finalUrl: string } }
| { type: "INITIALIZE"; payload: { url: string }[] };
Expand Down Expand Up @@ -38,7 +45,14 @@ export function useImageUpload({ maxFiles, maxFileSize, images, dispatch }: UseI
}

const validFiles = fileArray.filter((file) => {
if (!file.type.startsWith("image/")) return false;
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
toast({
title: "Unsupported file type",
description: `"${file.name}" is not supported. Please use JPG, PNG, WebP, or SVG files.`,
});
return false;
}

if (file.size > maxFileSize) {
toast({
title: "File too large",
Expand All @@ -60,12 +74,71 @@ export function useImageUpload({ maxFiles, maxFileSize, images, dispatch }: UseI

filesWithTempUrls.forEach(({ url: tempUrl, file }) => {
const reader = new FileReader();
reader.onload = (e) => {
const finalUrl = e.target?.result as string;

const handleSuccess = (result: string) => {
let finalUrl: string;

if (file.type === "image/svg+xml") {
try {
const isValidSvg = validateSvgContent(result);
if (!isValidSvg) {
toast({
title: "Potentially unsafe SVG",
description: `"${file.name}" may contain unsafe content but will be processed anyway.`,
});
}
Comment on lines +83 to +89
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Security concern: Processing potentially unsafe SVGs.

The code validates SVG content but still processes files that fail validation (line 87: "will be processed anyway"). This could expose users to XSS attacks if malicious SVG content bypasses the validation.

Consider rejecting unsafe SVGs entirely:

 const isValidSvg = validateSvgContent(result);
 if (!isValidSvg) {
-  toast({
-    title: "Potentially unsafe SVG",
-    description: `"${file.name}" may contain unsafe content but will be processed anyway.`,
-  });
+  toast({
+    title: "Unsafe SVG rejected",
+    description: `"${file.name}" contains potentially unsafe content and cannot be uploaded.`,
+  });
+  handleError();
+  return;
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isValidSvg = validateSvgContent(result);
if (!isValidSvg) {
toast({
title: "Potentially unsafe SVG",
description: `"${file.name}" may contain unsafe content but will be processed anyway.`,
});
}
const isValidSvg = validateSvgContent(result);
if (!isValidSvg) {
toast({
title: "Unsafe SVG rejected",
description: `"${file.name}" contains potentially unsafe content and cannot be uploaded.`,
});
handleError();
return;
}
🤖 Prompt for AI Agents
In hooks/use-image-upload.ts around lines 83 to 89, the code currently shows a
warning toast for unsafe SVGs but proceeds to process them, risking XSS attacks.
Modify the logic to reject and stop processing any SVG files that fail the
validateSvgContent check, preventing unsafe SVGs from being handled further.


const optimizedSvg = optimizeSvgContent(result);
const encodedSvg = encodeURIComponent(optimizedSvg);

if (encodedSvg.length > MAX_SVG_FILE_SIZE) {
handleError();
return;
}
Comment on lines +94 to +97
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Incorrect size validation for SVG files.

The code checks the encoded string length against MAX_SVG_FILE_SIZE, but encodeURIComponent can significantly increase the size (up to 3x for certain characters). This means valid SVG files under 1MB could be rejected.

Consider checking the original file size instead:

-if (encodedSvg.length > MAX_SVG_FILE_SIZE) {
+// Check original file size before encoding
+if (file.size > MAX_SVG_FILE_SIZE) {
+  toast({
+    title: "SVG file too large",
+    description: `"${file.name}" exceeds the ${MAX_SVG_FILE_SIZE / 1024 / 1024}MB limit.`,
+  });
   handleError();
   return;
 }

Alternatively, if you need to limit the encoded size for API constraints, consider using a different constant name like MAX_ENCODED_SVG_SIZE with an appropriate value.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (encodedSvg.length > MAX_SVG_FILE_SIZE) {
handleError();
return;
}
// Check original file size before encoding
if (file.size > MAX_SVG_FILE_SIZE) {
toast({
title: "SVG file too large",
description: `"${file.name}" exceeds the ${MAX_SVG_FILE_SIZE / 1024 / 1024}MB limit.`,
});
handleError();
return;
}
🤖 Prompt for AI Agents
In hooks/use-image-upload.ts around lines 94 to 97, the size validation
incorrectly checks the length of the encoded SVG string against
MAX_SVG_FILE_SIZE, which can cause valid files to be rejected due to encoding
expansion. Fix this by validating the original SVG file size before encoding
instead of the encoded string length. If limiting encoded size is necessary,
define a separate constant like MAX_ENCODED_SVG_SIZE with an appropriate
threshold and use that for the encoded string length check.


finalUrl = `data:image/svg+xml,${encodedSvg}`;
} catch (error) {
handleError();
return;
}
} else {
finalUrl = result;
}

dispatch({ type: "UPDATE_URL", payload: { tempUrl, finalUrl } });
URL.revokeObjectURL(tempUrl);
};
reader.readAsDataURL(file);

const handleError = () => {
toast({
title: "File read error",
description: `Failed to read "${file.name}". Please try again.`,
});

dispatch({
type: "REMOVE_BY_URL",
payload: { url: tempUrl },
});
URL.revokeObjectURL(tempUrl);
};

reader.onload = (e) => {
const result = e.target?.result;
if (!result || typeof result !== "string") {
handleError();
return;
}
handleSuccess(result);
};

reader.onerror = handleError;
reader.onabort = handleError;

if (file.type === "image/svg+xml") {
reader.readAsText(file);
} else {
reader.readAsDataURL(file);
}
});
};

Expand Down
1 change: 1 addition & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export const AI_REQUEST_FREE_TIER_LIMIT = 5;

export const MAX_IMAGE_FILES = 3;
export const MAX_IMAGE_FILE_SIZE = 5 * 1024 * 1024; // 5MB
export const MAX_SVG_FILE_SIZE = 1 * 1024 * 1024; // 1MB

export const MAX_FREE_THEMES = 10;
11 changes: 6 additions & 5 deletions utils/ai/generate-theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,13 @@ import { z } from "zod";
export const SYSTEM_PROMPT = `# Role
You are tweakcn, an expert shadcn/ui theme generator.

# Image Analysis Instructions (when image provided)
- If one or more images are provided (with or without a text prompt), always analyze the image(s) and extract dominant color tokens, mood, border radius, and aesthetic to create a shadcn/ui theme based on them.
- If both images and a text prompt are provided, use the prompt as additional guidance.
- If only a text prompt is provided (no images), generate the theme based on the prompt.
- Consider color harmony, contrast, and visual hierarchy
# Image & SVG Analysis Instructions (when visual content is provided)
- If one or more images are provided (with or without a text prompt), always analyze the image(s) and extract dominant color tokens, mood, border radius, and shadows to create a shadcn/ui theme based on them
- If SVG markup is provided, analyze the SVG code to extract colors, styles, and visual elements for theme generation
- **Always match the colors,border radius and shadows of the source image(s) or SVG elements** as closely as possible
- If both visual content and a text prompt are provided, use the prompt as additional guidance
- Translate visual elements into appropriate theme tokens
- If only a text prompt is provided (no visual content), generate the theme based on the prompt

# Token Groups
- **Brand**: primary, secondary, accent, ring
Expand Down
40 changes: 40 additions & 0 deletions utils/ai/image-upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
export const ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/svg+xml",
];

export function validateSvgContent(svgText: string): boolean {
try {
const trimmed = svgText.trim();
if (!trimmed.toLowerCase().includes("<svg")) {
return false;
}

const dangerousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i, // onclick, onload, etc.
/<embed/i,
/<object/i,
/<iframe/i,
];

return !dangerousPatterns.some((pattern) => pattern.test(svgText));
} catch {
return false;
}
}

export function optimizeSvgContent(svgText: string): string {
try {
return svgText
.replace(/<!--[\s\S]*?-->/g, "") // Remove comments
.replace(/>\s+</g, "><") // Remove unnecessary whitespace
.trim();
} catch {
return svgText.trim();
}
}
39 changes: 30 additions & 9 deletions utils/ai/message-converter.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ChatMessage } from "@/types/ai";
import { buildPromptForAPI } from "@/utils/ai/ai-prompt";
import { CoreMessage, ImagePart, TextPart, UserContent } from "ai";
import { CoreMessage, TextPart, UserContent } from "ai";

export async function convertChatMessagesToCoreMessages(
messages: ChatMessage[]
Expand All @@ -12,15 +12,36 @@ export async function convertChatMessagesToCoreMessages(
const content: UserContent = [];
const { promptData } = message;

// Add image parts
if (promptData.images && promptData.images.length > 0) {
const imageParts = promptData.images.map(
(image): ImagePart => ({
type: "image",
image: image.url,
})
);
content.push(...imageParts);
promptData.images.forEach((image) => {
if (image.url.startsWith("data:image/svg+xml")) {
try {
const dataUrlPart = image.url.split(",")[1];
let svgMarkup: string;

if (image.url.includes("base64")) {
svgMarkup = atob(dataUrlPart);
} else {
svgMarkup = decodeURIComponent(dataUrlPart);
}
Comment on lines +19 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Verify data URL parsing robustness.

The current implementation assumes a specific data URL format and splits on the first comma. Consider adding validation to ensure the data URL has the expected structure before attempting to decode.


🏁 Script executed:

#!/bin/bash
# Check if there are any existing data URL parsing utilities in the codebase
rg -A 5 "data:.*," --type ts --type tsx

Length of output: 69


🏁 Script executed:

#!/bin/bash
# Retry searching for data URL usage in .ts and .tsx files without relying on rg file types
rg -A 5 "data:.*," -g "*.ts" -g "*.tsx"

Length of output: 5560


Add validation when parsing data URLs in utils/ai/message-converter.ts

Before splitting and decoding the data URL, ensure it has the expected structure to avoid runtime errors on malformed or unexpected values.

• File: utils/ai/message-converter.ts (lines 19–26)
• Replace blind .split(",")[1] and includes("base64") checks with explicit comma-indexing and header validation

Suggested diff:

- const dataUrlPart = image.url.split(",")[1];
+ const commaIndex = image.url.indexOf(",");
+ if (commaIndex === -1) {
+   throw new Error(`Invalid data URL: missing comma in ${image.url}`);
+ }
+ const header = image.url.slice(0, commaIndex);
+ const dataUrlPart = image.url.slice(commaIndex + 1);

 let svgMarkup: string;

- if (image.url.includes("base64")) {
-   svgMarkup = atob(dataUrlPart);
- } else {
-   svgMarkup = decodeURIComponent(dataUrlPart);
- }
+ if (header.includes(";base64")) {
+   svgMarkup = atob(dataUrlPart);
+ } else {
+   svgMarkup = decodeURIComponent(dataUrlPart);
+ }

• Optionally tighten validation with a RegExp such as
/^data:[^;]+(;base64)?,/
to verify the data: prefix and media type before decoding.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const dataUrlPart = image.url.split(",")[1];
let svgMarkup: string;
if (image.url.includes("base64")) {
svgMarkup = atob(dataUrlPart);
} else {
svgMarkup = decodeURIComponent(dataUrlPart);
}
const commaIndex = image.url.indexOf(",");
if (commaIndex === -1) {
throw new Error(`Invalid data URL: missing comma in ${image.url}`);
}
const header = image.url.slice(0, commaIndex);
const dataUrlPart = image.url.slice(commaIndex + 1);
let svgMarkup: string;
if (header.includes(";base64")) {
svgMarkup = atob(dataUrlPart);
} else {
svgMarkup = decodeURIComponent(dataUrlPart);
}
🤖 Prompt for AI Agents
In utils/ai/message-converter.ts around lines 19 to 26, the current code blindly
splits the image URL by a comma and checks for "base64" without validating the
data URL format, which can cause runtime errors. Add validation to ensure the
URL starts with "data:" and contains a comma separating the header and data
parts. Use a regular expression like /^data:[^;]+(;base64)?,/ to verify the
structure before splitting and decoding. Only proceed with decoding if the
validation passes; otherwise, handle the error or skip processing to avoid
exceptions.


content.push({
type: "text",
text: `Here is an SVG image for analysis:\n\`\`\`svg\n${svgMarkup}\n\`\`\``,
});
} catch (error) {
content.push({
type: "image",
image: image.url,
});
}
} else {
content.push({
type: "image",
image: image.url,
});
}
});
}

// Add text part
Expand Down