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
118 changes: 107 additions & 11 deletions backend/src/routers/images.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from io import BytesIO
from typing import Annotated
import uuid
import imghdr
from pathlib import Path

from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from minio import Minio
Expand All @@ -15,49 +18,142 @@
access_key=settings.MINIO_ROOT_USER,
secret_key=settings.MINIO_ROOT_PASSWORD,
secure=False,
) # Secure=False indicates the connection is not TLS/SSL
)

# Allowed image formats
ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"}
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB


def validate_image(contents: bytes, filename: str) -> tuple[bool, str]:
"""Validate that the file is actually an image"""
# Check file extension
extension = Path(filename).suffix.lower()
if extension not in ALLOWED_EXTENSIONS:
return (
False,
f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}",
)

# Check file size
if len(contents) > MAX_FILE_SIZE:
return False, f"File size exceeds {MAX_FILE_SIZE / (1024*1024)}MB limit"

# Verify file content matches extension (for common formats)
if extension != ".svg": # SVG can't be validated with imghdr
image_type = imghdr.what(None, contents)
if image_type is None:
return False, "File content does not appear to be a valid image"

# Map imghdr types to extensions
type_map = {"jpeg": [".jpg", ".jpeg"], "png": [".png"], "gif": [".gif"]}
if image_type in type_map:
if extension not in type_map[image_type]:
return (
False,
f"File extension {extension} does not match content type {image_type}",
)

return True, ""


@image_router.post("/images", tags=["images"])
async def upload_image(
user: Annotated[User, Depends(get_user)], image: UploadFile = File(...)
) -> Image:

try:
bucket_name = settings.MINIO_DEFAULT_BUCKET
name = image.filename or f"image-{image.content_type}"
# Read file contents
contents = await image.read()

# Validate image
is_valid, error_msg = validate_image(contents, image.filename or "")
if not is_valid:
raise HTTPException(status_code=400, detail=error_msg)

# Generate secure filename with UUID
original_extension = Path(image.filename or "").suffix.lower()
secure_filename = f"{uuid.uuid4()}{original_extension}"

# Upload to MinIO
bucket_name = settings.MINIO_DEFAULT_BUCKET
temp_file = BytesIO(contents)

client.put_object(
bucket_name=bucket_name,
object_name=name,
object_name=secure_filename,
data=temp_file,
length=len(contents),
content_type=image.content_type or "application/octet-stream",
)

temp_file.close()

# Generate URL
endpoint = settings.MINIO_ENDPOINT
image_url = f"{endpoint}/{bucket_name}/{name}"
image_url = f"http://{endpoint}/{bucket_name}/{secure_filename}"

# Store in database with original filename for reference
image_obj = await db.image.create(
{
"name": name,
"name": image.filename or secure_filename,
"image_url": image_url,
}
)

return image_obj

except HTTPException:
raise
except Exception as e:
print(e)
raise HTTPException(status_code=400, detail="Invalid image data")
print(f"Upload error: {e}")
raise HTTPException(status_code=500, detail="Failed to upload image")


@image_router.get("/image/{image_id}", tags=["images"])
async def get_image(image_id: int) -> Image:
async def get_image(
# Require authentication
image_id: int,
user: Annotated[User, Depends(get_user)],
) -> Image:
try:
image = await db.image.find_unique(where={"id": image_id})
if image is None:
raise HTTPException(status_code=404, detail="Image not found")
return image
except HTTPException:
raise
except Exception as e:
print(e)
print(f"Get image error: {e}")
raise HTTPException(status_code=500, detail="Internal server error")


@image_router.delete("/image/{image_id}", tags=["images"])
async def delete_image(image_id: int, user: Annotated[User, Depends(get_user)]) -> dict:
"""Delete an image from both database and MinIO"""
try:
# Get image record
image = await db.image.find_unique(where={"id": image_id})
if image is None:
raise HTTPException(status_code=404, detail="Image not found")

# Extract filename from URL
filename = image.image_url.split("/")[-1]
bucket_name = settings.MINIO_DEFAULT_BUCKET

# Delete from MinIO
try:
client.remove_object(bucket_name, filename)
except Exception as e:
print(f"MinIO deletion error: {e}")
# Continue even if MinIO deletion fails

# Delete from database
await db.image.delete(where={"id": image_id})

return {"message": "Image deleted successfully"}

except HTTPException:
raise
except Exception as e:
print(f"Delete error: {e}")
raise HTTPException(status_code=500, detail="Failed to delete image")
21 changes: 21 additions & 0 deletions frontend/src/api/services/ImagesService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,25 @@ export class ImagesService {
},
});
}
/**
* Delete Image
* Delete an image from both database and MinIO
* @param imageId
* @returns any Successful Response
* @throws ApiError
*/
public static deleteImageImageImageIdDelete(
imageId: number,
): CancelablePromise<Record<string, any>> {
return __request(OpenAPI, {
method: "DELETE",
url: "/image/{image_id}",
path: {
image_id: imageId,
},
errors: {
422: `Validation Error`,
},
});
}
}
200 changes: 200 additions & 0 deletions frontend/src/components/ImageUploadSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { useRef, useState } from "react";
import { ImagesService, Body_upload_image_images_post } from "../api";

export function ImageUploadSection({
tempImage,
setTempImage,
}: {
tempImage: string;
setTempImage: (value: string) => void;
}) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [uploadError, setUploadError] = useState<string>("");
const [isUploading, setIsUploading] = useState(false);

const uploadToMinIO = async (file: File): Promise<string> => {
try {
console.log("=== Upload Debug Info ===");
console.log("File name:", file.name);
console.log("File type:", file.type);
console.log("File size:", file.size);
console.log("File lastModified:", new Date(file.lastModified));

// Verify file is readable
const fileContent = await file.arrayBuffer();
console.log("File content length:", fileContent.byteLength);

// Create the form data object matching the generated type
const formData: Body_upload_image_images_post = {
image: file,
};

console.log("Sending request...");

// Use the generated ImagesService
const result = await ImagesService.uploadImageImagesPost(formData);

console.log("Upload successful:", result);

if (!result.image_url) {
throw new Error("No image URL returned from server");
}

return result.image_url;
} catch (error: any) {
console.error("=== Upload Error ===");
console.error("Error object:", error);
console.error("Error status:", error.status);
console.error("Error body:", error.body);
console.error("Error message:", error.message);

// Handle validation errors
if (error.status === 422) {
const detail = error.body?.detail || "Invalid image data";
throw new Error(`Validation error: ${JSON.stringify(detail)}`);
}

if (error.status === 400) {
throw new Error(error.body?.detail || "Bad request - invalid image");
}

// Handle API errors
if (error.body?.detail) {
throw new Error(error.body.detail);
}

if (error instanceof Error) {
throw new Error(`Failed to upload: ${error.message}`);
}

throw new Error("Failed to upload to MinIO");
}
};

const handleFileSelect = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;

// Validate file type
if (!file.type.startsWith("image/")) {
setUploadError("Please select a valid image file");
return;
}

// Validate file size (e.g., 10MB limit)
const maxSize = 10 * 1024 * 1024; // 10MB
if (file.size > maxSize) {
setUploadError("File size must be less than 10MB");
return;
}

setUploadError("");
setIsUploading(true);

try {
const minioUrl = await uploadToMinIO(file);
setTempImage(minioUrl);
} catch (error) {
setUploadError(error instanceof Error ? error.message : "Upload failed");
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = ""; // Reset file input
}
}
};

const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.classList.add("bg-green-100");
};

const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.currentTarget.classList.remove("bg-green-100");
};

const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.classList.remove("bg-green-100");

const file = e.dataTransfer.files?.[0];
if (file) {
const event = {
target: { files: [file] },
} as unknown as React.ChangeEvent<HTMLInputElement>;
handleFileSelect(event);
}
};

return (
<div className="flex flex-col w-full min-w-0 gap-3">
<div>image url or path:</div>

<div className="flex flex-col gap-2 w-full">
{/* URL Input */}
<input
className="w-full h-15 border-2 p-2 rounded-xl border-primary-green focus:outline-none min-w-0"
value={tempImage}
onChange={(e) => setTempImage(e.target.value)}
placeholder="Enter image URL or upload a file"
disabled={isUploading}
/>

{/* Divider */}
<div className="flex items-center gap-2">
<div className="flex-1 border-t border-gray-300"></div>
<span className="text-gray-500 text-sm">or</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>

{/* Upload Area */}
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`w-full border-2 border-dashed border-primary-green rounded-xl p-4 text-center cursor-pointer transition-colors min-w-0 ${
isUploading
? "bg-gray-100 cursor-not-allowed opacity-60"
: "hover:bg-green-50"
}`}
onClick={() => !isUploading && fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
disabled={isUploading}
className="hidden"
/>
<div className="text-gray-600">
{isUploading ? (
<>
<div className="text-sm mb-1">⏳ Uploading...</div>
<div className="text-xs text-gray-500">Please wait</div>
</>
) : (
<>
<div className="text-sm mb-1">
📁 Click to upload or drag and drop
</div>
<div className="text-xs text-gray-500">
PNG, JPG, GIF up to 10MB
</div>
</>
)}
</div>
</div>

{/* Error Message */}
{uploadError && (
<div className="text-red-500 text-sm p-2 bg-red-50 rounded">
❌ {uploadError}
</div>
)}
</div>
</div>
);
}
Loading