database driven image uploader and storage service model

This commit is contained in:
2026-06-22 19:29:06 +00:00
parent 76894061c5
commit 3b61a131c1
31 changed files with 433 additions and 149 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -46
View File
@@ -1,9 +1,6 @@
"""Station config router: read/update the singleton station branding config."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from fastapi import APIRouter, Depends, HTTPException, status
from app.auth import get_current_admin_user
from app.database import get_session
@@ -15,9 +12,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
UPLOAD_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads")
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
@router.get("", response_model=StationConfigResponse)
async def get_station_config(session: AsyncSession = Depends(get_session)):
@@ -54,42 +48,3 @@ async def update_station_config(
await session.commit()
await session.refresh(config)
return config
@router.post("/upload")
async def upload_image(
file: UploadFile,
current_user: User = Depends(get_current_admin_user),
):
"""Upload an image file for station branding. Admin only.
Saves the file to the uploads/ directory with a UUID filename.
Returns the relative URL path.
"""
# Validate MIME type
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only image files are allowed",
)
# Read and validate size
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File size exceeds 5 MB limit",
)
# Ensure upload directory exists
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Generate unique filename preserving extension
ext = os.path.splitext(file.filename or "upload")[1] if file.filename else ".bin"
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(content)
return {"url": f"uploads/{filename}"}
+90
View File
@@ -0,0 +1,90 @@
"""Blob storage router: upload and retrieve binary assets in the database."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, UploadFile, status
from app.auth import get_current_admin_user
from app.database import get_session
from app.models import StorageBlob
from app.user_models import User
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
router = APIRouter()
ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/webp", "image/avif"}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
# Magic byte signatures → MIME type
MAGIC_BYTES = {
b'\x89PNG\r\n\x1a\n': 'image/png',
b'\xff\xd8\xff': 'image/jpeg',
b'\x00\x00\x00\x1cftypavif': 'image/avif',
b'\x00\x00\x00\x20ftypwebp': 'image/webp',
}
@router.post("/upload")
async def upload_blob(
file: UploadFile,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_admin_user),
):
"""Upload an image blob. Admin only.
Validates magic bytes, stores binary in PostgreSQL, returns a URL path.
"""
# Read and validate size
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File size exceeds 5 MB limit",
)
# Validate magic bytes
detected_type = None
for magic, mime_type in MAGIC_BYTES.items():
if content.startswith(magic):
detected_type = mime_type
break
if detected_type is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File type not supported. Only PNG, JPEG, WebP, and AVIF are allowed.",
)
# Generate unique blob_id and insert
blob_id = uuid.uuid4().hex
blob = StorageBlob(
blob_id=blob_id,
content_type=detected_type,
size=len(content),
data=content,
)
session.add(blob)
await session.commit()
return {"url": f"/api/storage/{blob_id}"}
@router.get("/{blob_id}")
async def get_blob(blob_id: str, session: AsyncSession = Depends(get_session)):
"""Retrieve a stored blob by blob_id. Public endpoint."""
result = await session.execute(
select(StorageBlob).where(StorageBlob.blob_id == blob_id)
)
blob = result.scalar_one_or_none()
if blob is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Blob not found",
)
return Response(
content=blob.data,
media_type=blob.content_type,
headers={"Content-Length": str(blob.size)},
)
-59
View File
@@ -1,59 +0,0 @@
"""Shared image upload router — admin-only, reused across all forms."""
import os
import uuid
from fastapi import APIRouter, Depends, HTTPException, UploadFile, status
from app.auth import get_current_admin_user
from app.user_models import User
router = APIRouter()
UPLOAD_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "uploads",
)
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
@router.post("/image")
async def upload_image(
file: UploadFile,
current_user: User = Depends(get_current_admin_user),
):
"""Upload an image file. Admin only.
Saves the file to the uploads/ directory with a UUID filename.
Returns the relative URL path.
"""
# Validate MIME type
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only image files are allowed",
)
# Read and validate size
content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="File size exceeds 5 MB limit",
)
# Ensure upload directory exists
os.makedirs(UPLOAD_DIR, exist_ok=True)
# Generate unique filename preserving extension
ext = (
os.path.splitext(file.filename or "upload")[1]
if file.filename
else ".bin"
)
filename = f"{uuid.uuid4().hex}{ext}"
filepath = os.path.join(UPLOAD_DIR, filename)
with open(filepath, "wb") as f:
f.write(content)
return {"url": f"uploads/{filename}"}
+2 -14
View File
@@ -1,16 +1,14 @@
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import func, select
from app.config import settings
from app.database import async_session, engine
from app.models import Base, Show, StationConfig, HistoryEntry, TeamMember, CommunityHighlight
from app.user_models import User
from app.api import events, tiers, auth, station_config, history, team, community, shows, upload
from app.api import events, tiers, auth, station_config, history, team, community, shows, storage
async def _ensure_station_config(session):
@@ -68,10 +66,6 @@ async def lifespan(app: FastAPI):
await session.commit()
print(f" → Bootstrap admin created: {settings.ADMIN_USERNAME}")
# Ensure uploads directory exists
uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads")
os.makedirs(uploads_dir, exist_ok=True)
yield
@@ -99,13 +93,7 @@ app.include_router(history.router, prefix="/api/history", tags=["history"])
app.include_router(team.router, prefix="/api/team", tags=["team"])
app.include_router(community.router, prefix="/api/community", tags=["community"])
app.include_router(shows.router, prefix="/api/shows", tags=["shows"])
app.include_router(upload.router, prefix="/api/upload", tags=["upload"])
# Serve uploaded files (dev only — Nginx handles this in production)
if settings.ENV != "production":
uploads_dir = os.path.join(os.path.dirname(os.path.dirname(__file__)), "uploads")
if os.path.exists(uploads_dir):
app.mount("/uploads", StaticFiles(directory=uploads_dir), name="uploads")
app.include_router(storage.router, prefix="/api/storage", tags=["storage"])
# Dev-only admin router (disabled in production)
if settings.ENV != "production":
+15
View File
@@ -1,6 +1,10 @@
from datetime import datetime, timezone
from sqlalchemy import (
Column,
DateTime,
Integer,
LargeBinary,
String,
Text,
Numeric,
@@ -133,3 +137,14 @@ class CommunityHighlight(Base):
description = Column(Text, nullable=False)
display_order = Column(Integer, nullable=False)
active = Column(Boolean, default=True)
class StorageBlob(Base):
__tablename__ = "storage_blobs"
id = Column(Integer, primary_key=True, autoincrement=True)
blob_id = Column(String(32), unique=True, nullable=False, index=True)
content_type = Column(String(100), nullable=False)
size = Column(Integer, nullable=False)
data = Column(LargeBinary, nullable=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc))