sushilideaclan01 commited on
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
Files changed (2) hide show
  1. Dockerfile +56 -8
  2. main.py +52 -66
Dockerfile CHANGED
@@ -1,12 +1,36 @@
1
- # Use Python 3.11 slim image
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 port (Hugging Face Spaces uses port 7860 by default, but we'll use 8000)
25
- EXPOSE 8000
 
26
 
27
  # Set environment variables
28
  ENV PYTHONUNBUFFERED=1
29
  ENV PORT=8000
 
 
 
 
 
 
 
 
 
 
30
 
31
- # Run the application
32
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
 
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
- @app.get("/admin/storage/stats")
1152
- async def get_storage_stats(username: str = Depends(get_current_user)):
1153
- """
1154
- Get storage statistics for locally saved images.
1155
-
1156
- Requires authentication.
1157
  """
1158
- stats = cleanup_service.get_storage_stats()
1159
- return {
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
- Manually trigger image cleanup.
 
 
 
 
1174
 
1175
- Args:
1176
- dry_run: If True, only report what would be deleted without actually deleting
1177
-
1178
- Requires authentication.
1179
- """
1180
- result = cleanup_service.cleanup_old_images(dry_run=dry_run)
1181
- return {
1182
- "success": True,
1183
- "dry_run": dry_run,
1184
- "result": result,
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