Commit
·
b7334a4
1
Parent(s):
267f464
Implement multi-stage Docker build for Next.js frontend and Python backend; add frontend proxy in FastAPI to handle requests, and remove old cleanup task functionality.
Browse files- Dockerfile +56 -8
- main.py +52 -66
Dockerfile
CHANGED
|
@@ -1,12 +1,36 @@
|
|
| 1 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
FROM python:3.11-slim
|
| 3 |
|
| 4 |
# Set working directory
|
| 5 |
WORKDIR /app
|
| 6 |
|
| 7 |
-
# Install system dependencies
|
| 8 |
RUN apt-get update && apt-get install -y \
|
| 9 |
gcc \
|
|
|
|
|
|
|
|
|
|
| 10 |
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
|
| 12 |
# Copy requirements first for better caching
|
|
@@ -15,18 +39,42 @@ COPY requirements.txt .
|
|
| 15 |
# Install Python dependencies
|
| 16 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
|
| 18 |
-
# Copy application code
|
| 19 |
-
COPY . .
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
# Create output directory for generated images
|
| 22 |
RUN mkdir -p assets/generated
|
| 23 |
|
| 24 |
-
# Expose
|
| 25 |
-
|
|
|
|
| 26 |
|
| 27 |
# Set environment variables
|
| 28 |
ENV PYTHONUNBUFFERED=1
|
| 29 |
ENV PORT=8000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
-
# Run
|
| 32 |
-
CMD ["
|
|
|
|
| 1 |
+
# Multi-stage build: Frontend + Backend
|
| 2 |
+
# Stage 1: Build Next.js frontend
|
| 3 |
+
FROM node:20-alpine AS frontend-builder
|
| 4 |
+
|
| 5 |
+
WORKDIR /app/frontend
|
| 6 |
+
|
| 7 |
+
# Copy frontend package files
|
| 8 |
+
COPY frontend/package*.json ./
|
| 9 |
+
|
| 10 |
+
# Install frontend dependencies
|
| 11 |
+
RUN npm ci
|
| 12 |
+
|
| 13 |
+
# Copy frontend source code
|
| 14 |
+
COPY frontend/ .
|
| 15 |
+
|
| 16 |
+
# Build Next.js app with standalone output
|
| 17 |
+
# When NEXT_PUBLIC_API_URL is empty, frontend will use relative URLs (same domain)
|
| 18 |
+
ENV NEXT_PUBLIC_API_URL=""
|
| 19 |
+
ENV NODE_ENV=production
|
| 20 |
+
RUN npm run build
|
| 21 |
+
|
| 22 |
+
# Stage 2: Python backend with frontend
|
| 23 |
FROM python:3.11-slim
|
| 24 |
|
| 25 |
# Set working directory
|
| 26 |
WORKDIR /app
|
| 27 |
|
| 28 |
+
# Install system dependencies including Node.js for running Next.js
|
| 29 |
RUN apt-get update && apt-get install -y \
|
| 30 |
gcc \
|
| 31 |
+
curl \
|
| 32 |
+
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
| 33 |
+
&& apt-get install -y nodejs \
|
| 34 |
&& rm -rf /var/lib/apt/lists/*
|
| 35 |
|
| 36 |
# Copy requirements first for better caching
|
|
|
|
| 39 |
# Install Python dependencies
|
| 40 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 41 |
|
| 42 |
+
# Copy backend application code
|
| 43 |
+
COPY main.py .
|
| 44 |
+
COPY config.py .
|
| 45 |
+
COPY services/ ./services/
|
| 46 |
+
COPY data/ ./data/
|
| 47 |
+
|
| 48 |
+
# Copy frontend build from previous stage
|
| 49 |
+
COPY --from=frontend-builder /app/frontend ./frontend
|
| 50 |
+
|
| 51 |
+
# Install frontend production dependencies (needed for standalone)
|
| 52 |
+
WORKDIR /app/frontend
|
| 53 |
+
RUN npm ci --only=production
|
| 54 |
+
|
| 55 |
+
# Go back to app root
|
| 56 |
+
WORKDIR /app
|
| 57 |
|
| 58 |
# Create output directory for generated images
|
| 59 |
RUN mkdir -p assets/generated
|
| 60 |
|
| 61 |
+
# Expose ports (Next.js on 3000, FastAPI on 8000)
|
| 62 |
+
# Note: Hugging Face Spaces will route to port 8000, so we'll proxy Next.js through FastAPI
|
| 63 |
+
EXPOSE 8000 3000
|
| 64 |
|
| 65 |
# Set environment variables
|
| 66 |
ENV PYTHONUNBUFFERED=1
|
| 67 |
ENV PORT=8000
|
| 68 |
+
ENV NODE_ENV=production
|
| 69 |
+
|
| 70 |
+
# Create startup script to run both services
|
| 71 |
+
RUN echo '#!/bin/bash\n\
|
| 72 |
+
set -e\n\
|
| 73 |
+
# Start Next.js server in background\n\
|
| 74 |
+
cd /app/frontend && PORT=3000 node server.js &\n\
|
| 75 |
+
# Start FastAPI server (foreground)\n\
|
| 76 |
+
cd /app && uvicorn main:app --host 0.0.0.0 --port 8000\n\
|
| 77 |
+
' > /app/start.sh && chmod +x /app/start.sh
|
| 78 |
|
| 79 |
+
# Run both services
|
| 80 |
+
CMD ["/app/start.sh"]
|
main.py
CHANGED
|
@@ -8,12 +8,13 @@ from contextlib import asynccontextmanager
|
|
| 8 |
from fastapi import FastAPI, HTTPException, Request, Response, Depends
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
from fastapi.staticfiles import StaticFiles
|
| 11 |
-
from fastapi.responses import FileResponse
|
| 12 |
from pydantic import BaseModel, Field
|
| 13 |
from typing import Optional, List, Literal, Any, Dict
|
| 14 |
import os
|
| 15 |
-
import asyncio
|
| 16 |
from starlette.middleware.gzip import GZipMiddleware
|
|
|
|
|
|
|
| 17 |
|
| 18 |
from services.generator import ad_generator
|
| 19 |
from services.matrix import matrix_service
|
|
@@ -22,49 +23,18 @@ from services.correction import correction_service
|
|
| 22 |
from services.image import image_service
|
| 23 |
from services.auth import auth_service
|
| 24 |
from services.auth_dependency import get_current_user
|
| 25 |
-
from services.image_cleanup import cleanup_service
|
| 26 |
from config import settings
|
| 27 |
|
| 28 |
|
| 29 |
-
async def cleanup_task():
|
| 30 |
-
"""Background task to periodically clean up old images."""
|
| 31 |
-
while True:
|
| 32 |
-
try:
|
| 33 |
-
# Run cleanup every hour
|
| 34 |
-
await asyncio.sleep(3600) # 1 hour
|
| 35 |
-
|
| 36 |
-
# Only run cleanup in production if local storage is enabled
|
| 37 |
-
if settings.environment.lower() == "production" and settings.save_images_locally:
|
| 38 |
-
print("Running scheduled image cleanup...")
|
| 39 |
-
stats = cleanup_service.cleanup_old_images(dry_run=False)
|
| 40 |
-
print(f"Cleanup completed: {stats['deleted']} images deleted, {stats['total_size_mb']} MB freed")
|
| 41 |
-
except Exception as e:
|
| 42 |
-
print(f"Error in cleanup task: {e}")
|
| 43 |
-
|
| 44 |
-
|
| 45 |
@asynccontextmanager
|
| 46 |
async def lifespan(app: FastAPI):
|
| 47 |
"""Startup and shutdown events."""
|
| 48 |
# Startup: Connect to database
|
| 49 |
print("Starting Ad Generator Lite...")
|
| 50 |
await db_service.connect()
|
| 51 |
-
|
| 52 |
-
# Start background cleanup task if in production with local storage enabled
|
| 53 |
-
cleanup_task_handle = None
|
| 54 |
-
if settings.environment.lower() == "production" and settings.save_images_locally:
|
| 55 |
-
print(f"Starting image cleanup task (retention: {settings.local_image_retention_hours} hours)")
|
| 56 |
-
cleanup_task_handle = asyncio.create_task(cleanup_task())
|
| 57 |
-
|
| 58 |
yield
|
| 59 |
-
|
| 60 |
-
# Shutdown: Cancel cleanup task and disconnect from database
|
| 61 |
print("Shutting down...")
|
| 62 |
-
if cleanup_task_handle:
|
| 63 |
-
cleanup_task_handle.cancel()
|
| 64 |
-
try:
|
| 65 |
-
await cleanup_task_handle
|
| 66 |
-
except asyncio.CancelledError:
|
| 67 |
-
pass
|
| 68 |
await db_service.disconnect()
|
| 69 |
|
| 70 |
|
|
@@ -1148,41 +1118,57 @@ async def delete_stored_ad(ad_id: str, username: str = Depends(get_current_user)
|
|
| 1148 |
return {"success": True, "deleted_id": ad_id}
|
| 1149 |
|
| 1150 |
|
| 1151 |
-
|
| 1152 |
-
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
|
| 1156 |
-
Requires authentication.
|
| 1157 |
"""
|
| 1158 |
-
|
| 1159 |
-
|
| 1160 |
-
"stats": stats,
|
| 1161 |
-
"retention_hours": settings.local_image_retention_hours,
|
| 1162 |
-
"environment": settings.environment,
|
| 1163 |
-
"save_images_locally": settings.save_images_locally,
|
| 1164 |
-
}
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
@app.post("/admin/storage/cleanup")
|
| 1168 |
-
async def trigger_cleanup(
|
| 1169 |
-
dry_run: bool = False,
|
| 1170 |
-
username: str = Depends(get_current_user)
|
| 1171 |
-
):
|
| 1172 |
"""
|
| 1173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1174 |
|
| 1175 |
-
|
| 1176 |
-
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1186 |
|
| 1187 |
|
| 1188 |
# Run with: uvicorn main:app --reload
|
|
|
|
| 8 |
from fastapi import FastAPI, HTTPException, Request, Response, Depends
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
from fastapi.staticfiles import StaticFiles
|
| 11 |
+
from fastapi.responses import FileResponse, StreamingResponse
|
| 12 |
from pydantic import BaseModel, Field
|
| 13 |
from typing import Optional, List, Literal, Any, Dict
|
| 14 |
import os
|
|
|
|
| 15 |
from starlette.middleware.gzip import GZipMiddleware
|
| 16 |
+
import httpx
|
| 17 |
+
from starlette.requests import Request as StarletteRequest
|
| 18 |
|
| 19 |
from services.generator import ad_generator
|
| 20 |
from services.matrix import matrix_service
|
|
|
|
| 23 |
from services.image import image_service
|
| 24 |
from services.auth import auth_service
|
| 25 |
from services.auth_dependency import get_current_user
|
|
|
|
| 26 |
from config import settings
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
@asynccontextmanager
|
| 30 |
async def lifespan(app: FastAPI):
|
| 31 |
"""Startup and shutdown events."""
|
| 32 |
# Startup: Connect to database
|
| 33 |
print("Starting Ad Generator Lite...")
|
| 34 |
await db_service.connect()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
yield
|
| 36 |
+
# Shutdown: Disconnect from database
|
|
|
|
| 37 |
print("Shutting down...")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
await db_service.disconnect()
|
| 39 |
|
| 40 |
|
|
|
|
| 1118 |
return {"success": True, "deleted_id": ad_id}
|
| 1119 |
|
| 1120 |
|
| 1121 |
+
# Frontend proxy - forward non-API requests to Next.js
|
| 1122 |
+
# This must be LAST so it doesn't intercept API routes
|
| 1123 |
+
@app.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
|
| 1124 |
+
async def frontend_proxy(path: str, request: StarletteRequest):
|
|
|
|
|
|
|
| 1125 |
"""
|
| 1126 |
+
Proxy frontend requests to Next.js server.
|
| 1127 |
+
Only proxies if the path doesn't start with API prefixes.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1128 |
"""
|
| 1129 |
+
# API routes that should be handled by FastAPI
|
| 1130 |
+
api_prefixes = [
|
| 1131 |
+
"/api", "/auth", "/generate", "/matrix", "/extensive",
|
| 1132 |
+
"/db", "/strategies", "/health", "/images", "/image"
|
| 1133 |
+
]
|
| 1134 |
|
| 1135 |
+
# Check if this is an API route
|
| 1136 |
+
if any(path.startswith(prefix) for prefix in api_prefixes):
|
| 1137 |
+
# Let FastAPI handle it (will 404 if route doesn't exist)
|
| 1138 |
+
raise HTTPException(status_code=404, detail="API endpoint not found")
|
| 1139 |
+
|
| 1140 |
+
# Proxy to Next.js server running on port 3000
|
| 1141 |
+
try:
|
| 1142 |
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
| 1143 |
+
# Forward the request to Next.js
|
| 1144 |
+
nextjs_url = f"http://localhost:3000/{path}"
|
| 1145 |
+
|
| 1146 |
+
# Forward query parameters
|
| 1147 |
+
if request.url.query:
|
| 1148 |
+
nextjs_url += f"?{request.url.query}"
|
| 1149 |
+
|
| 1150 |
+
# Forward request
|
| 1151 |
+
response = await client.request(
|
| 1152 |
+
method=request.method,
|
| 1153 |
+
url=nextjs_url,
|
| 1154 |
+
headers={k: v for k, v in request.headers.items() if k.lower() not in ["host", "content-length"]},
|
| 1155 |
+
content=await request.body() if request.method in ["POST", "PUT", "PATCH"] else None,
|
| 1156 |
+
follow_redirects=True,
|
| 1157 |
+
)
|
| 1158 |
+
|
| 1159 |
+
# Return response
|
| 1160 |
+
return StreamingResponse(
|
| 1161 |
+
response.iter_bytes(),
|
| 1162 |
+
status_code=response.status_code,
|
| 1163 |
+
headers=dict(response.headers),
|
| 1164 |
+
media_type=response.headers.get("content-type"),
|
| 1165 |
+
)
|
| 1166 |
+
except httpx.RequestError:
|
| 1167 |
+
# If Next.js is not running, return a helpful error
|
| 1168 |
+
raise HTTPException(
|
| 1169 |
+
status_code=503,
|
| 1170 |
+
detail="Frontend server is not available. Please ensure Next.js is running on port 3000."
|
| 1171 |
+
)
|
| 1172 |
|
| 1173 |
|
| 1174 |
# Run with: uvicorn main:app --reload
|