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
+1
View File
@@ -46,3 +46,4 @@ Thumbs.db
# instructions # instructions
.github/instructions/ .github/instructions/
backend/data/ backend/data/
data/
+11
View File
@@ -64,3 +64,14 @@ def _run_migrations(conn: duckdb.DuckDBPyConnection) -> None:
revoked BOOLEAN DEFAULT false revoked BOOLEAN DEFAULT false
) )
""") """)
conn.execute("""
CREATE TABLE IF NOT EXISTS uploaded_images (
id UUID DEFAULT uuid() PRIMARY KEY,
user_id UUID NOT NULL,
filename VARCHAR NOT NULL,
content_type VARCHAR NOT NULL,
file_path VARCHAR NOT NULL,
size_bytes BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT now()
)
""")
+2
View File
@@ -3,6 +3,7 @@ from .routers import users as users_router
from .routers import admin as admin_router from .routers import admin as admin_router
from .routers import ai as ai_router from .routers import ai as ai_router
from .routers import generate as generate_router from .routers import generate as generate_router
from .routers import images as images_router
from .db import close_db, init_db from .db import close_db, init_db
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
@@ -41,6 +42,7 @@ app.include_router(users_router.router)
app.include_router(admin_router.router) app.include_router(admin_router.router)
app.include_router(ai_router.router) app.include_router(ai_router.router)
app.include_router(generate_router.router) app.include_router(generate_router.router)
app.include_router(images_router.router)
@app.get("/health", tags=["health"]) @app.get("/health", tags=["health"])
+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])
+1
View File
@@ -17,4 +17,5 @@ passlib==1.7.4
pydantic pydantic
python-dotenv python-dotenv
python-jose python-jose
python-multipart
uvicorn uvicorn
+184
View File
@@ -0,0 +1,184 @@
"""Tests for image upload and retrieval endpoints."""
import io
import os
import pytest
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from app.main import app
from app import db as db_module
os.environ.setdefault("JWT_SECRET", "test-secret-key-for-testing-only")
# Use a temp dir so file I/O works without polluting project data/
os.environ.setdefault("UPLOAD_DIR", "/tmp/test_uploads")
@pytest.fixture(autouse=True)
def fresh_db():
db_module._conn = None
db_module.init_db(":memory:")
yield
db_module.close_db()
db_module._conn = None
@pytest_asyncio.fixture
async def client(fresh_db):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
async def _user_token(client) -> str:
await client.post("/auth/register", json={"email": "user@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "user@example.com", "password": "secret123"})
return resp.json()["access_token"]
async def _other_token(client) -> str:
await client.post("/auth/register", json={"email": "other@example.com", "password": "secret123"})
resp = await client.post("/auth/login", json={"email": "other@example.com", "password": "secret123"})
return resp.json()["access_token"]
def _png_bytes() -> bytes:
"""Minimal valid 1x1 PNG."""
return (
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01"
b"\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx\x9cc\xf8\x0f\x00"
b"\x00\x01\x01\x00\x05\x18\xd4n\x00\x00\x00\x00IEND\xaeB`\x82"
)
# ---------------------------------------------------------------------------
# POST /images/upload
# ---------------------------------------------------------------------------
async def test_upload_image_success(client):
token = await _user_token(client)
resp = await client.post(
"/images/upload",
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201
data = resp.json()
assert data["filename"] == "test.png"
assert data["content_type"] == "image/png"
assert "id" in data
assert data["size_bytes"] > 0
async def test_upload_image_unauthenticated(client):
resp = await client.post(
"/images/upload",
files={"file": ("test.png", io.BytesIO(_png_bytes()), "image/png")},
)
assert resp.status_code == 401
async def test_upload_image_unsupported_type(client):
token = await _user_token(client)
resp = await client.post(
"/images/upload",
files={"file": ("doc.pdf", io.BytesIO(
b"%PDF-fake"), "application/pdf")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 415
async def test_upload_image_too_large(client, monkeypatch):
import app.routers.images as images_mod
monkeypatch.setattr(images_mod, "MAX_SIZE_BYTES", 5)
token = await _user_token(client)
resp = await client.post(
"/images/upload",
files={"file": ("big.png", io.BytesIO(b"x" * 10), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 413
# ---------------------------------------------------------------------------
# GET /images/
# ---------------------------------------------------------------------------
async def test_list_images_empty(client):
token = await _user_token(client)
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
assert resp.json() == []
async def test_list_images_returns_own_only(client):
token = await _user_token(client)
other = await _other_token(client)
# Upload one image as user, one as other
for tok, name in [(token, "mine.png"), (other, "theirs.png")]:
await client.post(
"/images/upload",
files={"file": (name, io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {tok}"},
)
resp = await client.get("/images/", headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 200
items = resp.json()
assert len(items) == 1
assert items[0]["filename"] == "mine.png"
async def test_list_images_unauthenticated(client):
resp = await client.get("/images/")
assert resp.status_code == 401
# ---------------------------------------------------------------------------
# GET /images/{id}/file
# ---------------------------------------------------------------------------
async def test_serve_image_success(client):
token = await _user_token(client)
up = await client.post(
"/images/upload",
files={"file": ("pixel.png", io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = up.json()["id"]
resp = await client.get(
f"/images/{image_id}/file",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("image/png")
assert resp.content == _png_bytes()
async def test_serve_image_wrong_user(client):
token = await _user_token(client)
other = await _other_token(client)
up = await client.post(
"/images/upload",
files={"file": ("secret.png", io.BytesIO(_png_bytes()), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
image_id = up.json()["id"]
resp = await client.get(
f"/images/{image_id}/file",
headers={"Authorization": f"Bearer {other}"},
)
assert resp.status_code == 403
async def test_serve_image_not_found(client):
token = await _user_token(client)
resp = await client.get(
"/images/00000000-0000-0000-0000-000000000000/file",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
+31 -1
View File
@@ -4,6 +4,7 @@ import functools
import httpx import httpx
from flask import ( from flask import (
Flask, Flask,
Response,
flash, flash,
jsonify, jsonify,
redirect, redirect,
@@ -121,11 +122,27 @@ def dashboard():
token = session["access_token"] token = session["access_token"]
resp = _api("GET", "/users/me", token=token) resp = _api("GET", "/users/me", token=token)
user = resp.json() if resp.status_code == 200 else {} user = resp.json() if resp.status_code == 200 else {}
return render_template("dashboard.html", user=user) img_resp = _api("GET", "/images/", token=token)
images = img_resp.json() if img_resp.status_code == 200 else []
return render_template("dashboard.html", user=user, images=images)
# ── Generate ────────────────────────────────────────────────────────────── # ── Generate ──────────────────────────────────────────────────────────────
@app.get("/images/<image_id>/file")
@login_required
def serve_uploaded_image(image_id: str):
resp = _api("GET", f"/images/{image_id}/file",
token=session["access_token"])
if resp.status_code != 200:
return Response("Not found", status=404)
return Response(
resp.content,
status=200,
content_type=resp.headers.get("content-type", "image/jpeg"),
)
@app.get("/generate") @app.get("/generate")
@login_required @login_required
def generate(): def generate():
@@ -153,6 +170,19 @@ def generate_text():
def generate_image(): def generate_image():
result = error = None result = error = None
if request.method == "POST": if request.method == "POST":
# Upload reference image if provided
ref_file = request.files.get("reference_image")
if ref_file and ref_file.filename:
up_resp = _api(
"POST", "/images/upload",
token=session["access_token"],
files={"file": (ref_file.filename,
ref_file.stream, ref_file.content_type)},
)
if up_resp.status_code not in (200, 201):
error = up_resp.json().get("detail", "Image upload failed.")
return render_template("generate_image.html", result=result, error=error)
resp = _api("POST", "/generate/image", token=session["access_token"], json={ resp = _api("POST", "/generate/image", token=session["access_token"], json={
"model": request.form.get("model", "").strip(), "model": request.form.get("model", "").strip(),
"prompt": request.form.get("prompt", "").strip(), "prompt": request.form.get("prompt", "").strip(),
+19
View File
@@ -363,6 +363,25 @@ pre {
margin-top: 0.75rem; margin-top: 0.75rem;
} }
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 1rem;
margin-top: 0.75rem;
}
.image-grid-item {
display: flex;
flex-direction: column;
align-items: center;
}
.image-grid-item .generated-image {
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
}
/* ─── Admin table ──────────────────────────────────────── */ /* ─── Admin table ──────────────────────────────────────── */
.stats-grid { .stats-grid {
display: grid; display: grid;
+21 -1
View File
@@ -5,4 +5,24 @@ endblock %} {% block content %}
<p>Role: <strong>{{ user.get('role', 'user') }}</strong></p> <p>Role: <strong>{{ user.get('role', 'user') }}</strong></p>
<a href="{{ url_for('generate') }}" class="btn">Start generating</a> <a href="{{ url_for('generate') }}" class="btn">Start generating</a>
</div> </div>
{% endblock %}
{% if images %}
<div class="card mt-2">
<h2>Uploaded reference images</h2>
<div class="image-grid">
{% for img in images %}
<div class="image-grid-item">
<img
src="{{ url_for('serve_uploaded_image', image_id=img.id) }}"
alt="{{ img.filename }}"
class="generated-image"
loading="lazy"
/>
<p class="text-muted" style="font-size: 0.75rem; margin-top: 0.25rem">
{{ img.filename }} &mdash; {{ (img.size_bytes / 1024) | round(1) }} KB
</p>
</div>
{% endfor %}
</div>
</div>
{% endif %} {% endblock %}
+77 -3
View File
@@ -148,9 +148,10 @@ def test_dashboard_requires_login(client):
def test_dashboard_renders_user_info(client): def test_dashboard_renders_user_info(client):
_set_auth(client) _set_auth(client)
mock = _mock_response( me_mock = _mock_response(
200, {"id": "1", "email": "u@example.com", "role": "user"}) 200, {"id": "1", "email": "u@example.com", "role": "user"})
with patch("frontend.app.main.httpx.request", return_value=mock): images_mock = _mock_response(200, [])
with patch("frontend.app.main.httpx.request", side_effect=[me_mock, images_mock]):
resp = client.get("/dashboard") resp = client.get("/dashboard")
assert resp.status_code == 200 assert resp.status_code == 200
assert b"u@example.com" in resp.data assert b"u@example.com" in resp.data
@@ -418,4 +419,77 @@ def test_video_generate_renders_polling_ui(client):
}) })
assert resp.status_code == 200 assert resp.status_code == 200
assert b"video-poll-status" in resp.data assert b"video-poll-status" in resp.data
assert b"openrouter.ai/api/v1/videos/v1" in resp.data
# ---------------------------------------------------------------------------
# Image upload — frontend proxy + dashboard
# ---------------------------------------------------------------------------
def test_dashboard_shows_uploaded_images(client):
_set_auth(client)
me_mock = _mock_response(
200, {"id": "1", "email": "u@example.com", "role": "user"})
images_mock = _mock_response(200, [
{"id": "img-1", "filename": "cat.png", "content_type": "image/png",
"size_bytes": 1024, "created_at": "2026-04-29T10:00:00"},
])
with patch("frontend.app.main.httpx.request", side_effect=[me_mock, images_mock]):
resp = client.get("/dashboard")
assert resp.status_code == 200
assert b"cat.png" in resp.data
assert b"img-1" in resp.data
def test_dashboard_no_images_section_when_empty(client):
_set_auth(client)
me_mock = _mock_response(
200, {"id": "1", "email": "u@example.com", "role": "user"})
images_mock = _mock_response(200, [])
with patch("frontend.app.main.httpx.request", side_effect=[me_mock, images_mock]):
resp = client.get("/dashboard")
assert resp.status_code == 200
assert b"Uploaded reference images" not in resp.data
def test_serve_uploaded_image_proxy(client):
_set_auth(client)
img_bytes = b"\x89PNG\r\n\x1a\n"
mock = MagicMock()
mock.status_code = 200
mock.content = img_bytes
mock.headers = {"content-type": "image/png"}
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.get("/images/img-1/file")
assert resp.status_code == 200
assert resp.content_type == "image/png"
assert resp.data == img_bytes
def test_serve_uploaded_image_requires_login(client):
resp = client.get("/images/img-1/file")
assert resp.status_code == 302
assert "/login" in resp.headers["Location"]
def test_serve_uploaded_image_not_found_proxied(client):
_set_auth(client)
mock = _mock_response(404, {"detail": "Image not found."})
mock.content = b""
with patch("frontend.app.main.httpx.request", return_value=mock):
resp = client.get("/images/bad-id/file")
assert resp.status_code == 404
def test_generate_image_uploads_reference_then_generates(client):
_set_auth(client)
gen_mock = _mock_response(200, {
"id": "g2", "model": "openai/dall-e-3",
"images": [{"url": "https://example.com/out.png", "revised_prompt": None, "b64_json": None}]
})
# No file field → upload branch skipped; only generate call is made
with patch("frontend.app.main.httpx.request", return_value=gen_mock):
resp = client.post("/generate/image", data={
"model": "openai/dall-e-3", "prompt": "A cat", "n": "1", "size": "1024x1024",
}, content_type="multipart/form-data")
assert resp.status_code == 200
assert b"example.com/out.png" in resp.data