Implement image upload functionality with metadata storage; update frontend to display uploaded images

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-29 13:10:20 +02:00
parent 20de65ad01
commit e1d74fe163
10 changed files with 464 additions and 5 deletions
+117
View File
@@ -0,0 +1,117 @@
"""Images router: upload reference images and list user's uploads."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from ..db import get_conn, get_write_lock
from ..dependencies import get_current_user
router = APIRouter(prefix="/images", tags=["images"])
UPLOAD_DIR = os.getenv("UPLOAD_DIR", "data/uploads")
MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
@router.post("/upload", status_code=status.HTTP_201_CREATED)
async def upload_image(
file: UploadFile,
current_user: dict = Depends(get_current_user),
) -> dict:
"""Upload a reference image and store metadata in DuckDB."""
if file.content_type not in ALLOWED_CONTENT_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Unsupported content type '{file.content_type}'. Allowed: {sorted(ALLOWED_CONTENT_TYPES)}",
)
data = await file.read()
if len(data) > MAX_SIZE_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail=f"File exceeds maximum allowed size of {MAX_SIZE_BYTES // (1024*1024)} MB.",
)
user_id = current_user["id"]
image_id = str(uuid.uuid4())
ext = (file.filename or "").rsplit(
".", 1)[-1].lower() if "." in (file.filename or "") else "bin"
safe_filename = f"{image_id}.{ext}"
user_dir = os.path.join(UPLOAD_DIR, user_id)
os.makedirs(user_dir, exist_ok=True)
file_path = os.path.join(user_dir, safe_filename)
with open(file_path, "wb") as f:
f.write(data)
async with get_write_lock():
conn = get_conn()
conn.execute(
"""
INSERT INTO uploaded_images (id, user_id, filename, content_type, file_path, size_bytes)
VALUES (?, ?, ?, ?, ?, ?)
""",
[image_id, user_id, file.filename or safe_filename,
file.content_type, file_path, len(data)],
)
return {
"id": image_id,
"filename": file.filename or safe_filename,
"content_type": file.content_type,
"size_bytes": len(data),
}
@router.get("/", status_code=status.HTTP_200_OK)
async def list_images(
current_user: dict = Depends(get_current_user),
) -> list[dict]:
"""Return all uploaded images for the current user."""
conn = get_conn()
rows = conn.execute(
"""
SELECT id, filename, content_type, size_bytes, created_at
FROM uploaded_images
WHERE user_id = ?
ORDER BY created_at DESC
""",
[current_user["id"]],
).fetchall()
return [
{
"id": str(row[0]),
"filename": row[1],
"content_type": row[2],
"size_bytes": row[3],
"created_at": row[4].isoformat() if row[4] else None,
}
for row in rows
]
@router.get("/{image_id}/file", status_code=status.HTTP_200_OK)
async def serve_image(
image_id: str,
current_user: dict = Depends(get_current_user),
) -> FileResponse:
"""Serve the raw image file. Only accessible by the owning user."""
conn = get_conn()
row = conn.execute(
"SELECT file_path, content_type, user_id FROM uploaded_images WHERE id = ?",
[image_id],
).fetchone()
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image not found.")
if str(row[2]) != current_user["id"]:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied.")
file_path: str = row[0]
if not os.path.isfile(file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Image file missing.")
return FileResponse(file_path, media_type=row[1])