feat: add image upload validation with 2MB limit and max 5 files (#101)

- Add 2MB file size limit with client and server-side validation
- Add max 5 files limit per upload
- Add sonner toast library for better error notifications
- Create ErrorToast component with keyboard accessibility
- Batch multiple validation errors into single toast
- Validate file size in all upload methods (input, paste, drag-drop)
- Add server-side validation in /api/chat endpoint
This commit is contained in:
Dayuan Jiang
2025-12-05 19:30:50 +09:00
committed by GitHub
parent 33471d5b3a
commit e0c5d966e3
7 changed files with 193 additions and 27 deletions

View File

@@ -6,6 +6,36 @@ import { z } from "zod";
export const maxDuration = 300;
// File upload limits (must match client-side)
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_FILES = 5;
// Helper function to validate file parts in messages
function validateFileParts(messages: any[]): { valid: boolean; error?: string } {
const lastMessage = messages[messages.length - 1];
const fileParts = lastMessage?.parts?.filter((p: any) => p.type === 'file') || [];
if (fileParts.length > MAX_FILES) {
return { valid: false, error: `Too many files. Maximum ${MAX_FILES} allowed.` };
}
for (const filePart of fileParts) {
// Data URLs format: data:image/png;base64,<data>
// Base64 increases size by ~33%, so we check the decoded size
if (filePart.url && filePart.url.startsWith('data:')) {
const base64Data = filePart.url.split(',')[1];
if (base64Data) {
const sizeInBytes = Math.ceil((base64Data.length * 3) / 4);
if (sizeInBytes > MAX_FILE_SIZE) {
return { valid: false, error: `File exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit.` };
}
}
}
}
return { valid: true };
}
// Helper function to check if diagram is minimal/empty
function isMinimalDiagram(xml: string): boolean {
const stripped = xml.replace(/\s/g, '');
@@ -33,6 +63,13 @@ function createCachedStreamResponse(xml: string): Response {
async function handleChatRequest(req: Request): Promise<Response> {
const { messages, xml } = await req.json();
// === FILE VALIDATION START ===
const fileValidation = validateFileParts(messages);
if (!fileValidation.valid) {
return Response.json({ error: fileValidation.error }, { status: 400 });
}
// === FILE VALIDATION END ===
// === CACHE CHECK START ===
const isFirstMessage = messages.length === 1;
const isEmptyDiagram = !xml || xml.trim() === '' || isMinimalDiagram(xml);

View File

@@ -96,7 +96,6 @@ export default function RootLayout({
className={`${plusJakarta.variable} ${jetbrainsMono.variable} antialiased`}
>
<DiagramProvider>{children}</DiagramProvider>
<Analytics />
</body>
{process.env.NEXT_PUBLIC_GA_ID && (

View File

@@ -12,12 +12,78 @@ import {
Image as ImageIcon,
History,
Download,
Paperclip,
} from "lucide-react";
import { toast } from "sonner";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { FilePreviewList } from "./file-preview-list";
import { useDiagram } from "@/contexts/diagram-context";
import { HistoryDialog } from "@/components/history-dialog";
import { ErrorToast } from "@/components/error-toast";
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
const MAX_FILES = 5;
function formatFileSize(bytes: number): string {
const mb = bytes / 1024 / 1024;
if (mb < 0.01) return `${(bytes / 1024).toFixed(0)}KB`;
return `${mb.toFixed(2)}MB`;
}
function showErrorToast(message: React.ReactNode) {
toast.custom(
(t) => <ErrorToast message={message} onDismiss={() => toast.dismiss(t)} />,
{ duration: 5000 }
);
}
interface ValidationResult {
validFiles: File[];
errors: string[];
}
function validateFiles(newFiles: File[], existingCount: number): ValidationResult {
const errors: string[] = [];
const validFiles: File[] = [];
const availableSlots = MAX_FILES - existingCount;
if (availableSlots <= 0) {
errors.push(`Maximum ${MAX_FILES} files allowed`);
return { validFiles, errors };
}
for (const file of newFiles) {
if (validFiles.length >= availableSlots) {
errors.push(`Only ${availableSlots} more file(s) allowed`);
break;
}
if (file.size > MAX_FILE_SIZE) {
errors.push(`"${file.name}" is ${formatFileSize(file.size)} (exceeds 2MB)`);
} else {
validFiles.push(file);
}
}
return { validFiles, errors };
}
function showValidationErrors(errors: string[]) {
if (errors.length === 0) return;
if (errors.length === 1) {
showErrorToast(<span className="text-muted-foreground">{errors[0]}</span>);
} else {
showErrorToast(
<div className="flex flex-col gap-1">
<span className="font-medium">{errors.length} files rejected:</span>
<ul className="text-muted-foreground text-xs list-disc list-inside">
{errors.slice(0, 3).map((err, i) => <li key={i}>{err}</li>)}
{errors.length > 3 && <li>...and {errors.length - 3} more</li>}
</ul>
</div>
);
}
}
interface ChatInputProps {
input: string;
@@ -52,11 +118,8 @@ export function ChatInput({
const [showSaveDialog, setShowSaveDialog] = useState(false);
// Allow retry when there's an error (even if status is still "streaming" or "submitted")
const isDisabled = (status === "streaming" || status === "submitted") && !error;
useEffect(() => {
console.log('[ChatInput] Status changed to:', status, '| Input disabled:', isDisabled);
}, [status, isDisabled]);
const isDisabled =
(status === "streaming" || status === "submitted") && !error;
const adjustTextareaHeight = useCallback(() => {
const textarea = textareaRef.current;
@@ -89,23 +152,20 @@ export function ChatInput({
);
if (imageItems.length > 0) {
const imageFiles = await Promise.all(
imageItems.map(async (item) => {
const imageFiles = (await Promise.all(
imageItems.map(async (item, index) => {
const file = item.getAsFile();
if (!file) return null;
return new File(
[file],
`pasted-image-${Date.now()}.${file.type.split("/")[1]}`,
{
type: file.type,
}
`pasted-image-${Date.now()}-${index}.${file.type.split("/")[1]}`,
{ type: file.type }
);
})
);
)).filter((f): f is File => f !== null);
const validFiles = imageFiles.filter(
(file): file is File => file !== null
);
const { validFiles, errors } = validateFiles(imageFiles, files.length);
showValidationErrors(errors);
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
}
@@ -114,7 +174,15 @@ export function ChatInput({
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newFiles = Array.from(e.target.files || []);
onFileChange([...files, ...newFiles]);
const { validFiles, errors } = validateFiles(newFiles, files.length);
showValidationErrors(errors);
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
}
// Reset input so same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const handleRemoveFile = (fileToRemove: File) => {
@@ -148,13 +216,14 @@ export function ChatInput({
if (isDisabled) return;
const droppedFiles = e.dataTransfer.files;
const imageFiles = Array.from(droppedFiles).filter((file) =>
file.type.startsWith("image/")
);
if (imageFiles.length > 0) {
onFileChange([...files, ...imageFiles]);
const { validFiles, errors } = validateFiles(imageFiles, files.length);
showValidationErrors(errors);
if (validFiles.length > 0) {
onFileChange([...files, ...validFiles]);
}
};
@@ -178,7 +247,10 @@ export function ChatInput({
{/* File previews */}
{files.length > 0 && (
<div className="mb-3">
<FilePreviewList files={files} onRemoveFile={handleRemoveFile} />
<FilePreviewList
files={files}
onRemoveFile={handleRemoveFile}
/>
</div>
)}
@@ -252,8 +324,12 @@ export function ChatInput({
<SaveDialog
open={showSaveDialog}
onOpenChange={setShowSaveDialog}
onSave={(filename, format) => saveDiagramToFile(filename, format)}
defaultFilename={`diagram-${new Date().toISOString().slice(0, 10)}`}
onSave={(filename, format) =>
saveDiagramToFile(filename, format)
}
defaultFilename={`diagram-${new Date()
.toISOString()
.slice(0, 10)}`}
/>
<ButtonWithTooltip
@@ -285,7 +361,9 @@ export function ChatInput({
disabled={isDisabled || !input.trim()}
size="sm"
className="h-8 px-4 rounded-xl font-medium shadow-sm"
aria-label={isDisabled ? "Sending..." : "Send message"}
aria-label={
isDisabled ? "Sending..." : "Send message"
}
>
{isDisabled ? (
<Loader2 className="h-4 w-4 animate-spin" />
@@ -299,7 +377,6 @@ export function ChatInput({
</div>
</div>
</div>
</form>
);
}

View File

@@ -15,6 +15,7 @@ import { ChatMessageDisplay } from "./chat-message-display";
import { useDiagram } from "@/contexts/diagram-context";
import { replaceNodes, formatXML, validateMxCellStructure } from "@/lib/utils";
import { ButtonWithTooltip } from "@/components/button-with-tooltip";
import { Toaster } from "sonner";
interface ChatPanelProps {
isVisible: boolean;
@@ -451,7 +452,8 @@ Please retry with an adjusted search pattern or use display_diagram if retries a
// Full view
return (
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30">
<div className="h-full flex flex-col bg-card shadow-soft animate-slide-in-right rounded-xl border border-border/30 relative">
<Toaster position="bottom-center" richColors style={{ position: "absolute" }} />
{/* Header */}
<header className="px-5 py-4 border-b border-border/50">
<div className="flex items-center justify-between">

View File

@@ -0,0 +1,39 @@
"use client";
import React from "react";
interface ErrorToastProps {
message: React.ReactNode;
onDismiss: () => void;
}
export function ErrorToast({ message, onDismiss }: ErrorToastProps) {
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " " || e.key === "Escape") {
e.preventDefault();
onDismiss();
}
};
return (
<div
role="alert"
aria-live="polite"
tabIndex={0}
onClick={onDismiss}
onKeyDown={handleKeyDown}
className="flex items-center gap-3 bg-card border border-border/50 px-4 py-3 rounded-xl shadow-sm cursor-pointer hover:bg-muted/50 focus:outline-none focus:ring-2 focus:ring-primary/50 transition-colors"
>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-destructive/10 flex-shrink-0">
<svg className="w-4 h-4 text-destructive" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fillRule="evenodd"
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
clipRule="evenodd"
/>
</svg>
</div>
<span className="text-sm text-foreground">{message}</span>
</div>
);
}

11
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"react-drawio": "^1.0.3",
"react-icons": "^5.5.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12"
@@ -10322,6 +10323,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/sonner": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
"license": "MIT",
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",

View File

@@ -42,6 +42,7 @@
"react-drawio": "^1.0.3",
"react-icons": "^5.5.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.1.12"