Skip to content

Commit 665a719

Browse files
committed
refactor: deduplicate helpers, fix security issues, add dev tooling
- Extract shared MIME types, base64 reading, and HTML response building into app/utils/image.py (replaces 6 duplicated blocks) - Extract shared get_service() into app/api/dependencies.py - Fix timing-unsafe token comparison with secrets.compare_digest - Add SSRF protection on external URL fetches (block private/loopback IPs) - Fix CORS: allow_credentials=False with wildcard origins - Add asyncio.Lock to model registry to prevent concurrent duplicate loads - Add COPY pyproject.toml to Dockerfile so version reads work in containers - Bump Dockerfile to python:3.13-slim - Add mise.toml, .venv support, split requirements into runtime/dev - Add 18 new tests for image utils and SSRF protection
1 parent 8733774 commit 665a719

15 files changed

Lines changed: 400 additions & 220 deletions

Dockerfile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM python:3.11-slim
1+
FROM python:3.13-slim
22

33
WORKDIR /app
44

@@ -7,6 +7,7 @@ COPY requirements.txt .
77
RUN pip install --no-cache-dir -r requirements.txt
88

99
# Copy application
10+
COPY pyproject.toml .
1011
COPY app/ ./app/
1112

1213
# Create storage directory

app/api/dependencies.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from app.core.config import settings
2+
from app.services.gemini_service import get_gemini_service
3+
from app.services.litellm_service import get_litellm_service
4+
from app.services.openai_service import get_openai_service
5+
6+
7+
def get_service(provider: str):
8+
"""Get service instance based on provider."""
9+
if provider == "litellm":
10+
if not settings.litellm_available:
11+
raise ValueError("LiteLLM not configured. Set LITELLM_BASE_URL")
12+
return get_litellm_service()
13+
elif provider == "openai":
14+
if not settings.openai_available:
15+
raise ValueError("OpenAI not configured. Set OPENAI_API_KEY")
16+
return get_openai_service()
17+
elif provider == "gemini":
18+
if not settings.gemini_available:
19+
raise ValueError("Gemini not configured. Set GEMINI_API_KEY")
20+
return get_gemini_service()
21+
else:
22+
raise ValueError(f"Unknown provider: {provider}")

app/api/routes/edit.py

Lines changed: 17 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,16 @@
1-
import base64
2-
import contextlib
31
import logging
4-
from pathlib import Path
52
from typing import Annotated, Literal
63

74
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
8-
from fastapi.responses import HTMLResponse
95

6+
from app.api.dependencies import get_service
107
from app.core.config import settings
118
from app.core.security import verify_token
129
from app.schemas.requests import ImageEditRequest
1310
from app.schemas.responses import ImageResponse
14-
from app.services.gemini_service import get_gemini_service
15-
from app.services.litellm_service import get_litellm_service
1611
from app.services.model_registry import model_registry
17-
from app.services.openai_service import get_openai_service
1812
from app.services.storage_service import storage_service
13+
from app.utils.image import build_openwebui_html_response, read_image_as_base64
1914

2015
logger = logging.getLogger(__name__)
2116

@@ -86,7 +81,7 @@ async def edit_image(
8681

8782
# Get service based on provider
8883
try:
89-
service = _get_service(provider)
84+
service = get_service(provider)
9085
except ValueError as e:
9186
raise HTTPException(status_code=400, detail=str(e)) from None
9287

@@ -118,18 +113,10 @@ async def edit_image(
118113

119114
# Handle response format
120115
if response_format == "base64":
121-
image_filename = urls[0].split("/")[-1]
122-
image_path = Path(settings.STORAGE_PATH) / image_filename
123-
124-
if not image_path.exists():
125-
raise HTTPException(status_code=500, detail="Edited image file not found")
126-
127-
with open(image_path, "rb") as f:
128-
image_data = base64.b64encode(f.read()).decode("utf-8")
129-
130-
ext = image_path.suffix.lower()
131-
mime_types = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
132-
mime_type = mime_types.get(ext, "image/png")
116+
try:
117+
image_data, mime_type = await read_image_as_base64(urls[0], cleanup=False)
118+
except FileNotFoundError:
119+
raise HTTPException(status_code=500, detail="Edited image file not found") from None
133120

134121
return ImageResponse(
135122
image_base64=image_data,
@@ -201,7 +188,7 @@ async def edit_image_json(
201188

202189
# Get service based on provider
203190
try:
204-
service = _get_service(request.provider)
191+
service = get_service(request.provider)
205192
except ValueError as e:
206193
raise HTTPException(status_code=400, detail=str(e)) from None
207194

@@ -222,47 +209,18 @@ async def edit_image_json(
222209

223210
# OpenWebUI mode: return HTML with embedded image for iframe display
224211
if settings.OPENWEBUI_MODE:
225-
image_filename = urls[0].split("/")[-1]
226-
image_path = Path(settings.STORAGE_PATH) / image_filename
227-
228-
if not image_path.exists():
229-
raise HTTPException(status_code=500, detail="Edited image file not found")
230-
231-
with open(image_path, "rb") as f:
232-
image_data = base64.b64encode(f.read()).decode("utf-8")
233-
234-
# Clean up local file if not saving locally
235-
if not settings.SAVE_IMAGES_LOCALLY:
236-
with contextlib.suppress(Exception):
237-
image_path.unlink()
238-
239-
ext = image_path.suffix.lower()
240-
mime_types = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
241-
mime_type = mime_types.get(ext, "image/png")
242-
243-
html = f'<img src="data:{mime_type};base64,{image_data}" style="max-width:100%; height:auto;">'
244-
return HTMLResponse(content=html, headers={"Content-Disposition": "inline"})
212+
try:
213+
image_data, mime_type = await read_image_as_base64(urls[0])
214+
except FileNotFoundError:
215+
raise HTTPException(status_code=500, detail="Edited image file not found") from None
216+
return build_openwebui_html_response(image_data, mime_type)
245217

246218
# Return based on MARKDOWN_EMBED_IMAGES setting
247219
if settings.MARKDOWN_EMBED_IMAGES:
248-
# Return markdown with embedded base64 data URI
249-
image_filename = urls[0].split("/")[-1]
250-
image_path = Path(settings.STORAGE_PATH) / image_filename
251-
252-
if not image_path.exists():
253-
raise HTTPException(status_code=500, detail="Edited image file not found")
254-
255-
with open(image_path, "rb") as f:
256-
image_data = base64.b64encode(f.read()).decode("utf-8")
257-
258-
# Clean up local file if not saving locally
259-
if not settings.SAVE_IMAGES_LOCALLY:
260-
with contextlib.suppress(Exception):
261-
image_path.unlink()
262-
263-
ext = image_path.suffix.lower()
264-
mime_types = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
265-
mime_type = mime_types.get(ext, "image/png")
220+
try:
221+
image_data, mime_type = await read_image_as_base64(urls[0])
222+
except FileNotFoundError:
223+
raise HTTPException(status_code=500, detail="Edited image file not found") from None
266224

267225
markdown = f"![Edited image](data:{mime_type};base64,{image_data})"
268226
return ImageResponse(
@@ -294,24 +252,6 @@ async def edit_image_json(
294252
)
295253

296254

297-
def _get_service(provider: str):
298-
"""Get service instance based on provider."""
299-
if provider == "litellm":
300-
if not settings.litellm_available:
301-
raise ValueError("LiteLLM not configured. Set LITELLM_BASE_URL")
302-
return get_litellm_service()
303-
elif provider == "openai":
304-
if not settings.openai_available:
305-
raise ValueError("OpenAI not configured. Set OPENAI_API_KEY")
306-
return get_openai_service()
307-
elif provider == "gemini":
308-
if not settings.gemini_available:
309-
raise ValueError("Gemini not configured. Set GEMINI_API_KEY")
310-
return get_gemini_service()
311-
else:
312-
raise ValueError(f"Unknown provider: {provider}")
313-
314-
315255
def _get_default_edit_model(provider: str) -> str:
316256
"""Get default model for editing based on provider."""
317257
# Check if DEFAULT_MODEL supports editing

app/api/routes/generate.py

Lines changed: 20 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
1-
import base64
2-
import contextlib
31
import logging
4-
from pathlib import Path
52

63
from fastapi import APIRouter, Depends, HTTPException
74
from fastapi.responses import HTMLResponse, StreamingResponse
85

6+
from app.api.dependencies import get_service
97
from app.core.config import settings
108
from app.core.security import verify_token
119
from app.schemas.requests import ImageRequest
1210
from app.schemas.responses import ImageResponse
13-
from app.services.gemini_service import get_gemini_service
14-
from app.services.litellm_service import get_litellm_service
1511
from app.services.model_registry import model_registry
16-
from app.services.openai_service import get_openai_service
12+
from app.utils.image import build_openwebui_html_response, read_image_as_base64
1713
from app.utils.sse import generate_with_progress
1814

1915
logger = logging.getLogger(__name__)
@@ -49,7 +45,7 @@ async def generate_image(request: ImageRequest, _: None = Depends(verify_token))
4945

5046
# Get service based on provider
5147
try:
52-
service = _get_service(request.provider)
48+
service = get_service(request.provider)
5349
except ValueError as e:
5450
raise HTTPException(status_code=400, detail=str(e)) from None
5551

@@ -72,48 +68,18 @@ async def generate_image(request: ImageRequest, _: None = Depends(verify_token))
7268

7369
# OpenWebUI mode: return HTML with embedded image for iframe display
7470
if settings.OPENWEBUI_MODE:
75-
image_filename = urls[0].split("/")[-1]
76-
image_path = Path(settings.STORAGE_PATH) / image_filename
77-
78-
if not image_path.exists():
79-
raise HTTPException(status_code=500, detail="Generated image file not found")
80-
81-
with open(image_path, "rb") as f:
82-
image_data = base64.b64encode(f.read()).decode("utf-8")
83-
84-
# Clean up local file if not saving locally
85-
if not settings.SAVE_IMAGES_LOCALLY:
86-
with contextlib.suppress(Exception):
87-
image_path.unlink()
88-
89-
ext = image_path.suffix.lower()
90-
mime_types = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
91-
mime_type = mime_types.get(ext, "image/png")
92-
93-
html = f'<img src="data:{mime_type};base64,{image_data}" style="max-width:100%; height:auto;">'
94-
return HTMLResponse(content=html, headers={"Content-Disposition": "inline"})
71+
try:
72+
image_data, mime_type = await read_image_as_base64(urls[0])
73+
except FileNotFoundError:
74+
raise HTTPException(status_code=500, detail="Generated image file not found") from None
75+
return build_openwebui_html_response(image_data, mime_type)
9576

9677
# Handle response format
9778
if request.response_format == "base64":
98-
# Extract filename from URL and read file
99-
image_filename = urls[0].split("/")[-1]
100-
image_path = Path(settings.STORAGE_PATH) / image_filename
101-
102-
if not image_path.exists():
103-
raise HTTPException(status_code=500, detail="Generated image file not found")
104-
105-
with open(image_path, "rb") as f:
106-
image_data = base64.b64encode(f.read()).decode("utf-8")
107-
108-
# Clean up local file if not saving locally
109-
if not settings.SAVE_IMAGES_LOCALLY:
110-
with contextlib.suppress(Exception):
111-
image_path.unlink()
112-
113-
# Determine mime type from extension
114-
ext = image_path.suffix.lower()
115-
mime_types = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
116-
mime_type = mime_types.get(ext, "image/png")
79+
try:
80+
image_data, mime_type = await read_image_as_base64(urls[0])
81+
except FileNotFoundError:
82+
raise HTTPException(status_code=500, detail="Generated image file not found") from None
11783

11884
return ImageResponse(
11985
image_base64=image_data,
@@ -130,25 +96,12 @@ async def generate_image(request: ImageRequest, _: None = Depends(verify_token))
13096

13197
if request.response_format == "markdown":
13298
if settings.MARKDOWN_EMBED_IMAGES:
133-
# Return markdown with embedded base64 data URI
134-
# Open WebUI's ENABLE_CHAT_RESPONSE_BASE64_IMAGE_URL_CONVERSION will convert this
135-
image_filename = urls[0].split("/")[-1]
136-
image_path = Path(settings.STORAGE_PATH) / image_filename
137-
138-
if not image_path.exists():
139-
raise HTTPException(status_code=500, detail="Generated image file not found")
140-
141-
with open(image_path, "rb") as f:
142-
image_data = base64.b64encode(f.read()).decode("utf-8")
143-
144-
# Clean up local file if not saving locally
145-
if not settings.SAVE_IMAGES_LOCALLY:
146-
with contextlib.suppress(Exception):
147-
image_path.unlink()
148-
149-
ext = image_path.suffix.lower()
150-
mime_types = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
151-
mime_type = mime_types.get(ext, "image/png")
99+
try:
100+
image_data, mime_type = await read_image_as_base64(urls[0])
101+
except FileNotFoundError:
102+
raise HTTPException(
103+
status_code=500, detail="Generated image file not found"
104+
) from None
152105

153106
markdown = f"![Generated image](data:{mime_type};base64,{image_data})"
154107
return ImageResponse(
@@ -214,7 +167,7 @@ async def generate_image_stream(request: ImageRequest, _: None = Depends(verify_
214167

215168
# Get service based on provider
216169
try:
217-
service = _get_service(request.provider)
170+
service = get_service(request.provider)
218171
except ValueError as e:
219172
raise HTTPException(status_code=400, detail=str(e)) from None
220173

@@ -260,7 +213,7 @@ async def generate_image_preview(request: ImageRequest, _: None = Depends(verify
260213

261214
# Get service based on provider
262215
try:
263-
service = _get_service(request.provider)
216+
service = get_service(request.provider)
264217
except ValueError as e:
265218
raise HTTPException(status_code=400, detail=str(e)) from None
266219

@@ -345,26 +298,6 @@ async def generate_image_preview(request: ImageRequest, _: None = Depends(verify
345298
return HTMLResponse(content=html_content, headers={"Content-Disposition": "inline"})
346299

347300

348-
def _get_service(provider: str):
349-
"""
350-
Get service instance based on provider.
351-
"""
352-
if provider == "litellm":
353-
if not settings.litellm_available:
354-
raise ValueError("LiteLLM not configured. Set LITELLM_BASE_URL")
355-
return get_litellm_service()
356-
elif provider == "openai":
357-
if not settings.openai_available:
358-
raise ValueError("OpenAI not configured. Set OPENAI_API_KEY")
359-
return get_openai_service()
360-
elif provider == "gemini":
361-
if not settings.gemini_available:
362-
raise ValueError("Gemini not configured. Set GEMINI_API_KEY")
363-
return get_gemini_service()
364-
else:
365-
raise ValueError(f"Unknown provider: {provider}")
366-
367-
368301
def _get_default_model(provider: str) -> str:
369302
"""
370303
Get default model for provider based on available models.

app/core/security.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import secrets
12
from typing import Annotated
23

34
from fastapi import Depends, HTTPException, status
@@ -28,7 +29,7 @@ async def verify_token(
2829
headers={"WWW-Authenticate": "Bearer"},
2930
)
3031

31-
if credentials.credentials != settings.API_BEARER_TOKEN:
32+
if not secrets.compare_digest(credentials.credentials, settings.API_BEARER_TOKEN):
3233
raise HTTPException(
3334
status_code=status.HTTP_401_UNAUTHORIZED,
3435
detail="Invalid authentication token",

app/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ async def lifespan(app: FastAPI):
6565
app.add_middleware(
6666
CORSMiddleware,
6767
allow_origins=["*"],
68-
allow_credentials=True,
68+
allow_credentials=False,
6969
allow_methods=["*"],
7070
allow_headers=["*"],
7171
)

0 commit comments

Comments
 (0)