Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e1d74fe163 | |||
| 20de65ad01 | |||
| 3224d16197 | |||
| 8871f136d4 |
@@ -46,3 +46,4 @@ Thumbs.db
|
|||||||
# instructions
|
# instructions
|
||||||
.github/instructions/
|
.github/instructions/
|
||||||
backend/data/
|
backend/data/
|
||||||
|
data/
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|||||||
+3
-1
@@ -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
|
||||||
@@ -22,7 +23,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="AI Allucanget Biz API",
|
title="All You Can GET AI Biz API",
|
||||||
description="Multi-modal AI generation API powered by openrouter.ai",
|
description="Multi-modal AI generation API powered by openrouter.ai",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
@@ -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"])
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -20,7 +20,7 @@ def _headers() -> dict[str, str]:
|
|||||||
"Authorization": f"Bearer {_api_key()}",
|
"Authorization": f"Bearer {_api_key()}",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"HTTP-Referer": os.getenv("APP_URL", "https://ai.allucanget.biz"),
|
"HTTP-Referer": os.getenv("APP_URL", "https://ai.allucanget.biz"),
|
||||||
"X-Title": os.getenv("APP_NAME", "AI Allucanget"),
|
"X-Title": os.getenv("APP_NAME", "All You Can GET AI"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
anyio
|
anyio
|
||||||
bcrypt
|
bcrypt==4.0.1
|
||||||
blinker
|
blinker
|
||||||
certifi
|
certifi
|
||||||
cryptography
|
cryptography
|
||||||
@@ -13,8 +13,9 @@ httpx
|
|||||||
Jinja2
|
Jinja2
|
||||||
MarkupSafe
|
MarkupSafe
|
||||||
packaging
|
packaging
|
||||||
passlib
|
passlib==1.7.4
|
||||||
pydantic
|
pydantic
|
||||||
python-dotenv
|
python-dotenv
|
||||||
python-jose
|
python-jose
|
||||||
|
python-multipart
|
||||||
uvicorn
|
uvicorn
|
||||||
@@ -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
|
||||||
@@ -7,7 +7,7 @@ services:
|
|||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- APP_URL=${APP_URL:-https://ai.allucanget.biz}
|
- APP_URL=${APP_URL:-https://ai.allucanget.biz}
|
||||||
- APP_NAME=${APP_NAME:-AI Allucanget}
|
- APP_NAME=${APP_NAME:-All You Can GET AI}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-https://ai.allucanget.biz}
|
- CORS_ORIGINS=${CORS_ORIGINS:-https://ai.allucanget.biz}
|
||||||
volumes:
|
volumes:
|
||||||
- app-data:/app/data
|
- app-data:/app/data
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@ services:
|
|||||||
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
|
||||||
- JWT_SECRET=${JWT_SECRET}
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
- APP_URL=${APP_URL:-http://localhost}
|
- APP_URL=${APP_URL:-http://localhost}
|
||||||
- APP_NAME=${APP_NAME:-AI Allucanget}
|
- APP_NAME=${APP_NAME:-All You Can GET AI}
|
||||||
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:12016}
|
- CORS_ORIGINS=${CORS_ORIGINS:-http://localhost:12016}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Describes the relevant requirements and the driving forces that software archite
|
|||||||
|
|
||||||
## Requirements Overview
|
## Requirements Overview
|
||||||
|
|
||||||
**Project name**: AI Allucanget Biz
|
**Project name**: All You Can GET AI Biz
|
||||||
**Purpose**: Provide AI‑powered text, image, and video generation services via a web application.
|
**Purpose**: Provide AI‑powered text, image, and video generation services via a web application.
|
||||||
|
|
||||||
Users can choose between different AI models for:
|
Users can choose between different AI models for:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Architecture Documentation
|
# Architecture Documentation
|
||||||
|
|
||||||
This file is the entry point for the architecture documentation of **AI Allucanget Biz**.
|
This file is the entry point for the architecture documentation of **All You Can GET AI Biz**.
|
||||||
|
|
||||||
The documentation follows the [arc42 template](https://arc42.org/overview) and is split into 12 section files, each covering a specific aspect of the architecture. Read the sections in order for a full picture, or jump directly to the section most relevant to you.
|
The documentation follows the [arc42 template](https://arc42.org/overview) and is split into 12 section files, each covering a specific aspect of the architecture. Read the sections in order for a full picture, or jump directly to the section most relevant to you.
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ Add these in Coolify before first deploy:
|
|||||||
| `OPENROUTER_API_KEY` | `backend` | `sk-or-v1-...` |
|
| `OPENROUTER_API_KEY` | `backend` | `sk-or-v1-...` |
|
||||||
| `JWT_SECRET` | `backend` | `openssl rand -hex 32` |
|
| `JWT_SECRET` | `backend` | `openssl rand -hex 32` |
|
||||||
| `APP_URL` | `backend` | `https://ai.allucanget.biz` |
|
| `APP_URL` | `backend` | `https://ai.allucanget.biz` |
|
||||||
| `APP_NAME` | `backend` | `AI Allucanget` |
|
| `APP_NAME` | `backend` | `All You Can GET AI` |
|
||||||
| `CORS_ORIGINS` | `backend` | `https://ai.allucanget.biz` |
|
| `CORS_ORIGINS` | `backend` | `https://ai.allucanget.biz` |
|
||||||
| `FLASK_SECRET_KEY` | `frontend` | `openssl rand -hex 32` |
|
| `FLASK_SECRET_KEY` | `frontend` | `openssl rand -hex 32` |
|
||||||
| `BACKEND_URL` | `frontend` | `http://backend:12015` |
|
| `BACKEND_URL` | `frontend` | `http://backend:12015` |
|
||||||
@@ -123,7 +123,7 @@ Add these as **Runtime** environment variables in Coolify:
|
|||||||
| `OPENROUTER_API_KEY` | OpenRouter API key for AI generation | `sk-or-v1-...` |
|
| `OPENROUTER_API_KEY` | OpenRouter API key for AI generation | `sk-or-v1-...` |
|
||||||
| `JWT_SECRET` | Secret key for JWT token signing | Generate with `openssl rand -hex 32` |
|
| `JWT_SECRET` | Secret key for JWT token signing | Generate with `openssl rand -hex 32` |
|
||||||
| `APP_URL` | Public URL of the backend | `https://api.ai.allucanget.biz` |
|
| `APP_URL` | Public URL of the backend | `https://api.ai.allucanget.biz` |
|
||||||
| `APP_NAME` | Application name | `AI Allucanget` |
|
| `APP_NAME` | Application name | `All You Can GET AI` |
|
||||||
| `CORS_ORIGINS` | Comma-separated allowed origins | `https://ai.allucanget.biz` |
|
| `CORS_ORIGINS` | Comma-separated allowed origins | `https://ai.allucanget.biz` |
|
||||||
|
|
||||||
## Step 2: Create Frontend Service
|
## Step 2: Create Frontend Service
|
||||||
@@ -259,11 +259,11 @@ If you want to persist DuckDB data:
|
|||||||
All required environment variables:
|
All required environment variables:
|
||||||
|
|
||||||
| Variable | Service | Required |
|
| Variable | Service | Required |
|
||||||
| -------------------- | -------- | -------------------------------- |
|
| -------------------- | -------- | ------------------------------------- |
|
||||||
| `OPENROUTER_API_KEY` | Backend | Yes |
|
| `OPENROUTER_API_KEY` | Backend | Yes |
|
||||||
| `JWT_SECRET` | Backend | Yes |
|
| `JWT_SECRET` | Backend | Yes |
|
||||||
| `APP_URL` | Backend | Yes |
|
| `APP_URL` | Backend | Yes |
|
||||||
| `APP_NAME` | Backend | No (defaults to "AI Allucanget") |
|
| `APP_NAME` | Backend | No (defaults to "All You Can GET AI") |
|
||||||
| `CORS_ORIGINS` | Backend | Yes |
|
| `CORS_ORIGINS` | Backend | Yes |
|
||||||
| `FLASK_SECRET_KEY` | Frontend | Yes |
|
| `FLASK_SECRET_KEY` | Frontend | Yes |
|
||||||
| `BACKEND_URL` | Frontend | Yes |
|
| `BACKEND_URL` | Frontend | Yes |
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ The `.env` file in the project root is automatically loaded by Docker Compose. R
|
|||||||
| `BACKEND_URL` | Internal URL for frontend to reach backend | `http://backend:12015` (Docker) or `http://localhost:12015` (local dev) |
|
| `BACKEND_URL` | Internal URL for frontend to reach backend | `http://backend:12015` (Docker) or `http://localhost:12015` (local dev) |
|
||||||
| `CORS_ORIGINS` | Allowed CORS origins for backend | `http://localhost:12016` (local) or `https://ai.allucanget.biz` (production) |
|
| `CORS_ORIGINS` | Allowed CORS origins for backend | `http://localhost:12016` (local) or `https://ai.allucanget.biz` (production) |
|
||||||
| `APP_URL` | Public URL of the backend | `http://localhost` or `https://ai.allucanget.biz` |
|
| `APP_URL` | Public URL of the backend | `http://localhost` or `https://ai.allucanget.biz` |
|
||||||
| `APP_NAME` | Application name | `AI Allucanget` |
|
| `APP_NAME` | Application name | `All You Can GET AI` |
|
||||||
|
|
||||||
### Volume Mounts
|
### Volume Mounts
|
||||||
|
|
||||||
|
|||||||
+31
-1
@@ -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(),
|
||||||
|
|||||||
@@ -18,6 +18,28 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Image upload preview ───────────────────────────────
|
||||||
|
const imageInput = document.getElementById("reference_image");
|
||||||
|
const imagePreviewWrap = document.getElementById("image-upload-preview");
|
||||||
|
const imagePreview = document.getElementById("image-upload-preview-img");
|
||||||
|
const imageFilename = document.getElementById("image-upload-filename");
|
||||||
|
|
||||||
|
if (imageInput && imagePreviewWrap && imagePreview && imageFilename) {
|
||||||
|
imageInput.addEventListener("change", () => {
|
||||||
|
const file = imageInput.files && imageInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
imagePreviewWrap.hidden = true;
|
||||||
|
imagePreview.removeAttribute("src");
|
||||||
|
imageFilename.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
imagePreview.src = URL.createObjectURL(file);
|
||||||
|
imageFilename.textContent = file.name;
|
||||||
|
imagePreviewWrap.hidden = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Generate dropdown tabs ─────────────────────────────
|
// ── Generate dropdown tabs ─────────────────────────────
|
||||||
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
document.querySelectorAll(".tab-btn").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
|
|||||||
@@ -359,6 +359,29 @@ pre {
|
|||||||
margin-top: 0.5rem;
|
margin-top: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.image-upload-preview {
|
||||||
|
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;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Admin — AI Allucanget{% endblock %}
|
{% extends "base.html" %} {% block title %}Admin — All You Can GET AI{% endblock
|
||||||
{% block content %}
|
%} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Admin Dashboard</h1>
|
<h1>Admin Dashboard</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{% block title %}AI Allucanget{% endblock %}</title>
|
<title>{% block title %}All You Can GET AI{% endblock %}</title>
|
||||||
<link
|
<link
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="{{ url_for('static', filename='style.css') }}"
|
href="{{ url_for('static', filename='style.css') }}"
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<nav>
|
<nav>
|
||||||
<a href="{{ url_for('index') }}" class="brand">AI Allucanget</a>
|
<a href="{{ url_for('index') }}" class="brand">All You Can GET AI</a>
|
||||||
|
|
||||||
<button class="hamburger" aria-label="Open menu">
|
<button class="hamburger" aria-label="Open menu">
|
||||||
<span></span><span></span><span></span>
|
<span></span><span></span><span></span>
|
||||||
@@ -22,14 +22,9 @@
|
|||||||
{% if session.get('access_token') %}
|
{% if session.get('access_token') %}
|
||||||
<a href="{{ url_for('dashboard') }}">Dashboard</a>
|
<a href="{{ url_for('dashboard') }}">Dashboard</a>
|
||||||
|
|
||||||
<div class="nav-dropdown">
|
<a href="{{ url_for('generate_text') }}">Generate Text</a>
|
||||||
<a href="{{ url_for('generate_text') }}">Generate ▾</a>
|
<a href="{{ url_for('generate_image') }}">Generate Image</a>
|
||||||
<div class="nav-dropdown-menu">
|
<a href="{{ url_for('generate_video') }}">Generate Video</a>
|
||||||
<a href="{{ url_for('generate_text') }}">Text</a>
|
|
||||||
<a href="{{ url_for('generate_image') }}">Image</a>
|
|
||||||
<a href="{{ url_for('generate_video') }}">Video</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="{{ url_for('profile') }}">Profile</a>
|
<a href="{{ url_for('profile') }}">Profile</a>
|
||||||
{% if session.get('user_role') == 'admin' %}
|
{% if session.get('user_role') == 'admin' %}
|
||||||
|
|||||||
@@ -1,9 +1,28 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Dashboard — All You Can GET AI{%
|
||||||
{% block title %}Dashboard — AI Allucanget{% endblock %}
|
endblock %} {% block content %}
|
||||||
{% block content %}
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Welcome{% if user.get('email') %}, {{ user.email }}{% endif %}</h1>
|
<h1>Welcome{% if user.get('email') %}, {{ user.email }}{% endif %}</h1>
|
||||||
<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 }} — {{ (img.size_bytes / 1024) | round(1) }} KB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %} {% endblock %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Generate — AI Allucanget{% endblock
|
{% extends "base.html" %} {% block title %}Generate — All You Can GET AI{%
|
||||||
%} {% block content %}
|
endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Generate</h1>
|
<h1>Generate</h1>
|
||||||
<p class="text-muted">
|
<p class="text-muted">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Image Generation — AI Allucanget{% endblock %}
|
{% block title %}Image Generation — All You Can GET AI{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Image Generation</h1>
|
<h1>Image Generation</h1>
|
||||||
<form method="post">
|
<form method="post" enctype="multipart/form-data">
|
||||||
<label for="model">Model</label>
|
<label for="model">Model</label>
|
||||||
<input id="model" name="model" type="text" required
|
<input id="model" name="model" type="text" required
|
||||||
placeholder="e.g. openai/dall-e-3"
|
placeholder="e.g. openai/dall-e-3"
|
||||||
@@ -49,6 +49,21 @@
|
|||||||
<option value="4" {% if request.form.get('n')=='4' %}selected{% endif %}>4</option>
|
<option value="4" {% if request.form.get('n')=='4' %}selected{% endif %}>4</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<label for="reference_image">Reference image (optional)</label>
|
||||||
|
<input
|
||||||
|
id="reference_image"
|
||||||
|
name="reference_image"
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||||
|
>
|
||||||
|
<p class="text-muted mt-1" id="reference-image-help">
|
||||||
|
Upload image for visual reference in upcoming image-to-image flow.
|
||||||
|
</p>
|
||||||
|
<div class="image-upload-preview" id="image-upload-preview" hidden>
|
||||||
|
<p class="text-muted" id="image-upload-filename"></p>
|
||||||
|
<img id="image-upload-preview-img" alt="Uploaded reference image preview" class="generated-image">
|
||||||
|
</div>
|
||||||
|
|
||||||
<button type="submit">Generate image</button>
|
<button type="submit">Generate image</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Text Generation — AI Allucanget{%
|
{% extends "base.html" %} {% block title %}Text Generation — All You Can GET
|
||||||
endblock %} {% block content %}
|
AI{% endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Text Generation</h1>
|
<h1>Text Generation</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Video Generation — AI Allucanget{%
|
{% extends "base.html" %} {% block title %}Video Generation — All You Can GET
|
||||||
endblock %} {% block content %}
|
AI{% endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Video Generation</h1>
|
<h1>Video Generation</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Log in — All You Can GET AI{%
|
||||||
{% block title %}Log in — AI Allucanget{% endblock %}
|
endblock %} {% block content %}
|
||||||
{% block content %}
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Log in</h1>
|
<h1>Log in</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input id="email" name="email" type="email" required autofocus>
|
<input id="email" name="email" type="email" required autofocus />
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input id="password" name="password" type="password" required>
|
<input id="password" name="password" type="password" required />
|
||||||
|
|
||||||
<button type="submit">Log in</button>
|
<button type="submit">Log in</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{% extends "base.html" %} {% block title %}Profile — AI Allucanget{% endblock %}
|
{% extends "base.html" %} {% block title %}Profile — All You Can GET AI{%
|
||||||
{% block content %}
|
endblock %} {% block content %}
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Your Profile</h1>
|
<h1>Your Profile</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %} {% block title %}Register — All You Can GET AI{%
|
||||||
{% block title %}Register — AI Allucanget{% endblock %}
|
endblock %} {% block content %}
|
||||||
{% block content %}
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Create account</h1>
|
<h1>Create account</h1>
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<label for="email">Email</label>
|
<label for="email">Email</label>
|
||||||
<input id="email" name="email" type="email" required autofocus>
|
<input id="email" name="email" type="email" required autofocus />
|
||||||
|
|
||||||
<label for="password">Password</label>
|
<label for="password">Password</label>
|
||||||
<input id="password" name="password" type="password" required minlength="8">
|
<input
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
minlength="8"
|
||||||
|
/>
|
||||||
|
|
||||||
<button type="submit">Register</button>
|
<button type="submit">Register</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -196,6 +197,7 @@ def test_generate_image_page_renders(client):
|
|||||||
resp = client.get("/generate/image")
|
resp = client.get("/generate/image")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert b"Image Generation" in resp.data
|
assert b"Image Generation" in resp.data
|
||||||
|
assert b"reference_image" in resp.data
|
||||||
|
|
||||||
|
|
||||||
def test_generate_image_success(client):
|
def test_generate_image_success(client):
|
||||||
@@ -417,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
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
anyio
|
anyio
|
||||||
bcrypt
|
bcrypt==4.0.1
|
||||||
blinker
|
blinker
|
||||||
certifi
|
certifi
|
||||||
cffi
|
cffi
|
||||||
@@ -20,7 +20,7 @@ itsdangerous
|
|||||||
Jinja2
|
Jinja2
|
||||||
MarkupSafe
|
MarkupSafe
|
||||||
packaging
|
packaging
|
||||||
passlib
|
passlib==1.7.4
|
||||||
pluggy
|
pluggy
|
||||||
pyasn1
|
pyasn1
|
||||||
pycparser
|
pycparser
|
||||||
|
|||||||
Reference in New Issue
Block a user