diff --git a/backend/src/routers/images.py b/backend/src/routers/images.py index 207bb381..d8c067d2 100644 --- a/backend/src/routers/images.py +++ b/backend/src/routers/images.py @@ -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 @@ -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") diff --git a/frontend/src/api/services/ImagesService.ts b/frontend/src/api/services/ImagesService.ts index 94358d4e..6112b0fe 100644 --- a/frontend/src/api/services/ImagesService.ts +++ b/frontend/src/api/services/ImagesService.ts @@ -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> { + return __request(OpenAPI, { + method: "DELETE", + url: "/image/{image_id}", + path: { + image_id: imageId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } } diff --git a/frontend/src/components/ImageUploadSection.tsx b/frontend/src/components/ImageUploadSection.tsx new file mode 100644 index 00000000..9f15fbf6 --- /dev/null +++ b/frontend/src/components/ImageUploadSection.tsx @@ -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(null); + const [uploadError, setUploadError] = useState(""); + const [isUploading, setIsUploading] = useState(false); + + const uploadToMinIO = async (file: File): Promise => { + 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, + ) => { + 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) => { + e.preventDefault(); + e.currentTarget.classList.add("bg-green-100"); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.currentTarget.classList.remove("bg-green-100"); + }; + + const handleDrop = (e: React.DragEvent) => { + 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; + handleFileSelect(event); + } + }; + + return ( +
+
image url or path:
+ +
+ {/* URL Input */} + setTempImage(e.target.value)} + placeholder="Enter image URL or upload a file" + disabled={isUploading} + /> + + {/* Divider */} +
+
+ or +
+
+ + {/* Upload Area */} +
!isUploading && fileInputRef.current?.click()} + > + +
+ {isUploading ? ( + <> +
⏳ Uploading...
+
Please wait
+ + ) : ( + <> +
+ 📁 Click to upload or drag and drop +
+
+ PNG, JPG, GIF up to 10MB +
+ + )} +
+
+ + {/* Error Message */} + {uploadError && ( +
+ ❌ {uploadError} +
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/BookEditor.tsx b/frontend/src/pages/BookEditor.tsx index b03a5309..368534b6 100644 --- a/frontend/src/pages/BookEditor.tsx +++ b/frontend/src/pages/BookEditor.tsx @@ -8,7 +8,7 @@ import { ErrorBoundary } from "react-error-boundary"; import { BookPreview } from "../components/ActivityBookList"; import Editor from "@monaco-editor/react"; import { useTheme } from "../context/ThemeContext"; -import Split from "react-split"; +import { ImageUploadSection } from "../components/ImageUploadSection"; interface PropsFormProps { tempProps: string; @@ -725,15 +725,13 @@ function BookImageEditor({ {/* Content area - Takes remaining space */}
- {page.image.includes("/") || page.image === "Image" ? ( -
-
image url or path:
- setTempImage(e.target.value)} - /> -
+ {page.image.includes("/") || + page.image === "Image" || + page.image === "" ? ( + ) : ( <> {/* Tab Navigation - Fixed */} @@ -926,84 +924,73 @@ function PageEditor({ return (
- -
-
-
-
-
-
+
+
+
+
+ + Error, try adjusting the props +
+ } > - - Error, try adjusting the props -
- } - > - {}} - /> - -
+ {}} + /> +
+
-
-
- {page.content && page.content.length > 0 && ( - - )} -
+
+
+ {page.content && page.content.length > 0 && ( + + )}
-
-
- -
+
+ +
+
+
- +
); }