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))
+3 -3
View File
@@ -2,7 +2,7 @@
<div class="container">
<!-- Intro -->
<div class="about-intro">
<img [src]="config().hero_icon_url" alt="" class="about-flower-decor" aria-hidden="true">
<img [src]="resolveImageUrl(config().hero_icon_url)" alt="" class="about-flower-decor" aria-hidden="true">
<h2>About <span class="text-accent">{{ config().name_primary }} {{ config().name_secondary }}</span></h2>
<p class="section-intro">
For nearly four decades, {{ config().name_primary }} {{ config().name_secondary }} has been the voice of our
@@ -56,7 +56,7 @@
<div class="team-card">
<div class="team-photo">
@if (member.photo_url) {
<img [src]="member.photo_url" [alt]="member.name">
<img [src]="resolveImageUrl(member.photo_url)" [alt]="member.name">
} @else {
<span>{{ member.name.charAt(0) }}</span>
}
@@ -76,7 +76,7 @@
<!-- Donation CTA -->
<div class="about-cta">
<div class="about-flower-divider" aria-hidden="true">
<img [src]="config().logo_url" alt="">
<img [src]="resolveImageUrl(config().logo_url)" alt="">
</div>
<h3>Become a Member</h3>
<p class="section-intro">
+5
View File
@@ -10,6 +10,7 @@ import { CommunityHighlightService } from '../services/community-highlight.servi
import { HistoryEntry } from '../interfaces/history-entry';
import { TeamMember } from '../interfaces/team-member';
import { CommunityHighlight } from '../interfaces/community-highlight';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-about',
@@ -29,6 +30,10 @@ export class AboutComponent {
readonly teamMembers$ = new BehaviorSubject<TeamMember[]>([]);
readonly communityHighlights$ = new BehaviorSubject<CommunityHighlight[]>([]);
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
constructor() {
this.load();
}
+1 -1
View File
@@ -91,7 +91,7 @@
<div class="form-actions">
<button type="button" class="btn btn-cancel" (click)="onClose()">Cancel</button>
<button type="submit" class="btn btn-save" [disabled]="loading">
<button type="submit" class="btn btn-save" [disabled]="loading || uploading">
{{ loading ? 'Saving...' : (id ? 'Update' : 'Create') }}
</button>
</div>
+9 -5
View File
@@ -1,4 +1,4 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
@@ -6,7 +6,7 @@ import { firstValueFrom } from 'rxjs';
import { Show } from '../interfaces/show';
import { ShowService, ShowScheduleCreate, ShowCreatePayload, ShowUpdatePayload } from '../services/show.service';
import { UploadService } from '../services/upload.service';
import { getAppConfig } from '../services/app-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-admin-show-form',
@@ -16,6 +16,7 @@ import { getAppConfig } from '../services/app-config.service';
styleUrl: './admin-show-form.component.scss',
})
export class AdminShowFormComponent implements OnChanges {
private cdr = inject(ChangeDetectorRef);
private showService = inject(ShowService);
private uploadService = inject(UploadService);
@@ -117,15 +118,19 @@ export class AdminShowFormComponent implements OnChanges {
const file = input.files[0];
if (!file.type.startsWith('image/')) {
this.error = 'Only image files are allowed.';
this.cdr.detectChanges();
return;
}
if (file.size > 5 * 1024 * 1024) {
this.error = 'File size exceeds 5 MB limit.';
this.cdr.detectChanges();
return;
}
this.uploading = true;
this.error = '';
this.success = '';
this.cdr.detectChanges();
try {
const url = await this.uploadService.uploadImage(file);
@@ -135,14 +140,13 @@ export class AdminShowFormComponent implements OnChanges {
this.error = this.formatError(err);
} finally {
this.uploading = false;
this.cdr.detectChanges();
}
}
/** Resolve a relative uploads/… path to an absolute URL for preview. */
getPreviewUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${getAppConfig().apiBaseUrl}/${url}`;
return resolveImageUrl(url);
}
async onSubmit(): Promise<void> {
@@ -1,4 +1,4 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
@@ -14,6 +14,7 @@ import { StationConfigService } from '../services/station-config.service';
styleUrl: './admin-station-form.component.scss',
})
export class AdminStationFormComponent implements OnChanges {
private cdr = inject(ChangeDetectorRef);
private stationConfigService = inject(StationConfigService);
@Input() editItem: StationConfig | null = null;
@@ -98,15 +99,19 @@ export class AdminStationFormComponent implements OnChanges {
const file = input.files[0];
if (!file.type.startsWith('image/')) {
this.error = 'Only image files are allowed.';
this.cdr.detectChanges();
return;
}
if (file.size > 5 * 1024 * 1024) {
this.error = 'File size exceeds 5 MB limit.';
this.cdr.detectChanges();
return;
}
this.loading = true;
this.error = '';
this.success = '';
this.cdr.detectChanges();
try {
const url = await this.stationConfigService.uploadImage(file);
@@ -116,6 +121,7 @@ export class AdminStationFormComponent implements OnChanges {
this.error = this.formatError(err);
} finally {
this.loading = false;
this.cdr.detectChanges();
}
}
+9 -5
View File
@@ -1,4 +1,4 @@
import { Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { ChangeDetectorRef, Component, EventEmitter, inject, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { firstValueFrom } from 'rxjs';
@@ -6,7 +6,7 @@ import { firstValueFrom } from 'rxjs';
import { TeamMember } from '../interfaces/team-member';
import { TeamMemberService } from '../services/team-member.service';
import { UploadService } from '../services/upload.service';
import { getAppConfig } from '../services/app-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-admin-team-form',
@@ -16,6 +16,7 @@ import { getAppConfig } from '../services/app-config.service';
styleUrl: './admin-team-form.component.scss',
})
export class AdminTeamFormComponent implements OnChanges {
private cdr = inject(ChangeDetectorRef);
private teamMemberService = inject(TeamMemberService);
private uploadService = inject(UploadService);
@@ -86,15 +87,19 @@ export class AdminTeamFormComponent implements OnChanges {
const file = input.files[0];
if (!file.type.startsWith('image/')) {
this.error = 'Only image files are allowed.';
this.cdr.detectChanges();
return;
}
if (file.size > 5 * 1024 * 1024) {
this.error = 'File size exceeds 5 MB limit.';
this.cdr.detectChanges();
return;
}
this.uploading = true;
this.error = '';
this.success = '';
this.cdr.detectChanges();
try {
const url = await this.uploadService.uploadImage(file);
@@ -104,14 +109,13 @@ export class AdminTeamFormComponent implements OnChanges {
this.error = this.formatError(err);
} finally {
this.uploading = false;
this.cdr.detectChanges();
}
}
/** Resolve a relative uploads/… path to an absolute URL for preview. */
getPreviewUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${getAppConfig().apiBaseUrl}/${url}`;
return resolveImageUrl(url);
}
async onSubmit(): Promise<void> {
+2 -2
View File
@@ -3,7 +3,7 @@
<div class="donate-banner">
<div class="donate-banner-overlay">
<div class="container donate-banner-content">
<img [src]="config().hero_icon_url" alt="" class="donate-flower-hero" aria-hidden="true">
<img [src]="resolveImageUrl(config().hero_icon_url)" alt="" class="donate-flower-hero" aria-hidden="true">
<h2>Keep the Music <span class="text-accent">Free</span></h2>
<p class="donate-subtitle">
{{ config().name_primary }} {{ config().name_secondary }} Radio is 100% listener-funded and independent.
@@ -42,7 +42,7 @@
<!-- One-time donation -->
<div class="donate-one-time">
<div class="donate-flower-divider" aria-hidden="true">
<img [src]="config().logo_url" alt="">
<img [src]="resolveImageUrl(config().logo_url)" alt="">
</div>
<h3>Or Make a One-Time Gift</h3>
<p class="section-intro">
+5
View File
@@ -5,6 +5,7 @@ import { map, Observable, startWith } from 'rxjs';
import { TierService } from '../services/tier.service';
import { Tier } from '../interfaces/tier';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
interface TiersState {
loading: boolean;
@@ -22,6 +23,10 @@ export class DonateComponent {
private tierService = inject(TierService);
readonly config = inject(StationConfigService).config;
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
/** Async pipe drives the template — guarantees change detection on every emission. */
readonly tiers$: Observable<TiersState> =
this.tierService.getTiers().pipe(
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="container">
<div class="footer-top">
<div class="footer-brand">
<img [src]="config().logo_url" alt="" class="footer-flower" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="" class="footer-flower" aria-hidden="true">
<h3>
<span class="brand-m">{{ config().name_primary | slice:0:1 }}</span>{{ config().name_primary | slice:1 }}
<span class="brand-a">{{ config().name_secondary }}</span>
+5
View File
@@ -3,6 +3,7 @@ import { SlicePipe } from '@angular/common';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-footer',
@@ -14,4 +15,8 @@ import { StationConfigService } from '../services/station-config.service';
export class FooterComponent {
readonly config = inject(StationConfigService).config;
readonly currentYear = computed(() => new Date().getFullYear());
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
}
+3 -3
View File
@@ -1,7 +1,7 @@
<section class="hero">
<!-- Mountain background layer -->
<div class="hero-mountains" aria-hidden="true">
<img [src]="config().hero_background_url" alt="">
<img [src]="resolveImageUrl(config().hero_background_url)" alt="">
</div>
<!-- Gradient overlay -->
@@ -11,7 +11,7 @@
<div class="hero-content container">
<div class="hero-inner">
<div class="hero-icon">
<img [src]="config().hero_icon_url" alt="" class="hero-flower">
<img [src]="resolveImageUrl(config().hero_icon_url)" alt="" class="hero-flower">
</div>
<h1 class="hero-title">
<span class="hero-line1">{{ config().name_primary }}</span>
@@ -37,6 +37,6 @@
<!-- Mountain divider at bottom -->
<div class="hero-divider" aria-hidden="true">
<img [src]="config().hero_divider_url" alt="">
<img [src]="resolveImageUrl(config().hero_divider_url)" alt="">
</div>
</section>
+5
View File
@@ -2,6 +2,7 @@ import { Component, inject } from '@angular/core';
import { RouterLink } from '@angular/router';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-hero',
@@ -12,4 +13,8 @@ import { StationConfigService } from '../services/station-config.service';
})
export class HeroComponent {
readonly config = inject(StationConfigService).config;
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<section class="login-section">
<div class="login-card">
<div class="login-header">
<img [src]="config().logo_url" alt="" class="login-flower" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="" class="login-flower" aria-hidden="true">
<h1>Sign In</h1>
<p>Admin access for {{ config().name_primary }} {{ config().name_secondary }} Radio</p>
</div>
+5
View File
@@ -6,6 +6,7 @@ import { firstValueFrom } from 'rxjs';
import { AuthService } from '../services/auth.service';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-login',
@@ -25,6 +26,10 @@ export class LoginComponent {
error = '';
loading = false;
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
async onSubmit(): Promise<void> {
if (!this.username || !this.password) return;
+1 -1
View File
@@ -1,7 +1,7 @@
<nav class="navbar" role="navigation" aria-label="Main navigation">
<div class="container navbar-inner">
<a routerLink="/" class="navbar-brand" routerLinkActive="active">
<img [src]="config().logo_url" alt="" class="brand-flower" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="" class="brand-flower" aria-hidden="true">
<span class="brand-name">
<span class="brand-mountain">{{ config().callsign | uppercase }}</span>
<span class="brand-tagline">{{ config().tagline }}</span>
+5
View File
@@ -5,6 +5,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AuthService } from '../services/auth.service';
import { StationConfigService } from '../services/station-config.service';
import { resolveImageUrl } from '../services/app-config.service';
@Component({
selector: 'app-navbar',
@@ -47,6 +48,10 @@ export class NavbarComponent implements OnInit {
this.menuOpen = false;
}
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
onLogout(): void {
this.auth.logout();
this.router.navigate(['/']);
+1 -1
View File
@@ -1,7 +1,7 @@
<section class="schedule">
<div class="container">
<div class="schedule-header">
<img [src]="config().logo_url" alt="" class="schedule-flower" aria-hidden="true">
<img [src]="resolveImageUrl(config().logo_url)" alt="" class="schedule-flower" aria-hidden="true">
<h2>Program <span class="text-accent">Schedule</span></h2>
<p class="section-intro">
All times are local (Mountain Time). Every show is free to listen to
+6 -4
View File
@@ -6,7 +6,7 @@ import { ShowService } from '../services/show.service';
import { Show } from '../interfaces/show';
import { StationConfigService } from '../services/station-config.service';
import { SortSchedulesPipe } from '../pipes/sort-schedules.pipe';
import { getAppConfig } from '../services/app-config.service';
import { resolveImageUrl } from '../services/app-config.service';
interface ScheduleState {
loading: boolean;
@@ -33,8 +33,10 @@ export class ScheduleComponent {
/** Resolve a relative uploads/… path to an absolute URL for image rendering. */
resolveAsset(url: string | null): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
return `${getAppConfig().apiBaseUrl}/${url}`;
return resolveImageUrl(url ?? '');
}
resolveImageUrl(url: string): string {
return resolveImageUrl(url);
}
}
+13
View File
@@ -42,3 +42,16 @@ export const appConfigInitProvider = {
export function getAppConfig(): AppConfig {
return _appConfig;
}
/** Resolve an image URL for rendering.
* - Absolute HTTP(S) URLs are returned as-is.
* - Static SVG assets (svg/…) are served by the Angular build — returned as-is.
* - API paths (/api/storage/…) and legacy paths (uploads/…) are prefixed with apiBaseUrl.
*/
export function resolveImageUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('svg/')) return url;
if (url.startsWith('/')) return `${getAppConfig().apiBaseUrl}${url}`;
return `${getAppConfig().apiBaseUrl}/${url}`;
}
+1 -1
View File
@@ -60,7 +60,7 @@ export class StationConfigService {
const formData = new FormData();
formData.append('file', file);
const response = await firstValueFrom(
this.http.post<{ url: string }>(`${this.baseUrl}/upload`, formData),
this.http.post<{ url: string }>(`${getAppConfig().apiBaseUrl}/api/storage/upload`, formData),
);
return response.url;
}
+1 -1
View File
@@ -10,7 +10,7 @@ import { getAppConfig } from './app-config.service';
})
export class UploadService {
private http = inject(HttpClient);
private baseUrl = `${getAppConfig().apiBaseUrl}/api/upload/image`;
private baseUrl = `${getAppConfig().apiBaseUrl}/api/storage/upload`;
/** Upload an image file. Returns the relative URL (e.g. `uploads/abc123.png`). */
async uploadImage(file: File): Promise<string> {
+236
View File
@@ -0,0 +1,236 @@
# Plan: Migrate File Uploads from Disk to Database Blob Storage
## Context
Uploaded images are currently saved to `backend/uploads/` on disk with UUID-based filenames, served via a `StaticFiles` mount in dev and (presumably) Nginx in prod. The upload logic is copy-pasted across two backend files. The goal is to:
1. Unify the duplicated upload code into a single endpoint
2. Store uploaded binary blobs in PostgreSQL instead of on disk
3. Serve them through a new `/api/storage/{blob_id}` endpoint
4. Update all frontend uploaders and display sites to work with the new URL format
Uploaded blob URLs will change from `uploads/{uuid}{ext}` (relative, disk-based) to `/api/storage/{blob_id}` (absolute API path, DB-backed). Static SVG defaults (`svg/small-flower.svg`) are untouched — they are Angular build assets, not uploaded files.
---
## Step 1 — Add `StorageBlob` model
**File:** `backend/app/models.py`
Add a new SQLAlchemy model at the bottom of the file (after `CommunityHighlight`):
```python
from datetime import datetime, timezone
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))
```
- `blob_id`: 32-char hex string (from `uuid.uuid4().hex`), indexed for fast lookups
- `data`: `LargeBinary` → PostgreSQL `BYTEA`, holds the actual image bytes
- No FK relationships — blobs are referenced by URL string in existing models
No Alembic migration needed. `Base.metadata.create_all()` in `main.py` lifespan will create the table on next startup.
---
## Step 2 — Create the storage router
**New file:** `backend/app/api/storage.py`
Two endpoints in a single router:
### `POST /upload` (admin-only) — unified upload
```python
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',
}
```
Logic:
1. Read file content, enforce `MAX_FILE_SIZE`
2. Validate magic bytes against `MAGIC_BYTES` table — reject if no match (this also rejects SVG, which has no binary magic bytes)
3. Generate `blob_id = uuid.uuid4().hex`
4. Insert `StorageBlob` row into the database
5. Return `{"url": f"/api/storage/{blob_id}"}`
### `GET /{blob_id}` (public) — blob retrieval
1. Query `StorageBlob` by `blob_id`
2. Return via `Response(content=blob.data, media_type=blob.content_type, headers={"Content-Length": str(blob.size)})`
3. Return 404 if not found
---
## Step 3 — Register the storage router and remove the old upload router
**File:** `backend/app/main.py`
- Add `storage` to the import line (line 13): `from app.api import events, tiers, auth, station_config, history, team, community, shows, storage`
- Add router registration: `app.include_router(storage.router, prefix="/api/storage", tags=["storage"])`
- Remove `upload` from the import line
- Remove `app.include_router(upload.router, prefix="/api/upload", tags=["upload"])`
- Remove the `StaticFiles` mount block (lines 104108) and its `from fastapi.staticfiles import StaticFiles` import (line 6)
- Remove the `os.makedirs(uploads_dir, exist_ok=True)` block in the lifespan (lines 7173)
- Check if `os` import is still needed — it is not used elsewhere after removing uploads_dir, so remove it
---
## Step 4 — Delete the old disk-based upload file
**File:** `backend/app/api/upload.py`**delete entirely**
This file is replaced by the new `storage.py` router.
---
## Step 5 — Remove duplicate upload code from station_config.py
**File:** `backend/app/api/station_config.py`
- Delete the `upload_image` endpoint function (lines 5995)
- Delete `UPLOAD_DIR` and `MAX_FILE_SIZE` constants (lines 1819)
- Remove unused imports: `os`, `uuid`, `UploadFile` from the `fastapi` import line (line 6)
The file will contain only the `GET /` and `PUT /` endpoints for station config CRUD.
---
## Step 6 — Add `resolveImageUrl()` helper to the frontend
**File:** `src/app/services/app-config.service.ts`
Add a utility function after `getAppConfig()`:
```typescript
/** Resolve an image URL for rendering.
* - Absolute HTTP(S) URLs are returned as-is.
* - Static SVG assets (svg/…) are served by the Angular build — returned as-is.
* - API paths (/api/storage/…) and legacy paths (uploads/…) are prefixed with apiBaseUrl.
*/
export function resolveImageUrl(url: string): string {
if (!url) return '';
if (url.startsWith('http://') || url.startsWith('https://')) return url;
if (url.startsWith('svg/')) return url;
if (url.startsWith('/')) return `${getAppConfig().apiBaseUrl}${url}`;
return `${getAppConfig().apiBaseUrl}/${url}`;
}
```
Key subtlety: new blob URLs start with `/` (`/api/storage/...`), so they concatenate directly with `apiBaseUrl`. Legacy relative paths (`uploads/...`) still get a `/` separator. Static SVG paths are left alone.
---
## Step 7 — Update frontend upload services
**File:** `src/app/services/upload.service.ts`
- Change `baseUrl` from `${getAppConfig().apiBaseUrl}/api/upload/image` to `${getAppConfig().apiBaseUrl}/api/storage/upload`
**File:** `src/app/services/station-config.service.ts`
- Change `uploadImage()` to call `${getAppConfig().apiBaseUrl}/api/storage/upload` instead of `${this.baseUrl}/upload`
- This unifies all uploads through the single storage endpoint
---
## Step 8 — Update admin form preview methods
Both admin forms have `getPreviewUrl()` methods that build the preview URL for uploaded images. Update them to use the shared `resolveImageUrl()`:
**File:** `src/app/admin/admin-team-form.component.ts`
- Import `resolveImageUrl` from `../services/app-config.service`
- Replace `getPreviewUrl()` body with `return resolveImageUrl(url);`
**File:** `src/app/admin/admin-show-form.component.ts`
- Same change as above
---
## Step 9 — Update the schedule component's `resolveAsset()`
**File:** `src/app/schedule/schedule.component.ts`
Replace `resolveAsset()` to use the shared helper:
```typescript
import { resolveImageUrl } from '../services/app-config.service';
resolveAsset(url: string | null): string {
return resolveImageUrl(url ?? '');
}
```
No template changes needed — the schedule template already calls `resolveAsset()` for `show_art_url` and `hero_image_url`.
---
## Step 10 — Add `resolveImageUrl()` to all display components
Currently, StationConfig image fields (`logo_url`, `hero_background_url`, `hero_icon_url`, `hero_divider_url`) and `member.photo_url` are bound directly in templates without any URL resolution. With the new `/api/storage/{blob_id}` format, they need resolution in dev mode (where the Angular app runs on `:4200` and the API on `:8000`).
For each component: import `resolveImageUrl` in the `.ts` file and expose it as a public method. Then wrap every image binding in the template.
### hero.component.ts / hero.component.html
- Add method: `resolveImageUrl(url: string): string { return resolveImageUrl(url); }`
- Template changes (3 bindings):
- Line 4: `[src]="config().hero_background_url"``[src]="resolveImageUrl(config().hero_background_url)"`
- Line 14: `[src]="config().hero_icon_url"``[src]="resolveImageUrl(config().hero_icon_url)"`
- Line 40: `[src]="config().hero_divider_url"``[src]="resolveImageUrl(config().hero_divider_url)"`
### navbar.component.ts / navbar.component.html
- Add method
- Template: Line 4: `[src]="config().logo_url"``[src]="resolveImageUrl(config().logo_url)"`
### footer.component.ts / footer.component.html
- Add method
- Template: Line 5: `[src]="config().logo_url"``[src]="resolveImageUrl(config().logo_url)"`
### about.component.ts / about.component.html
- Add method
- Template changes (3 bindings):
- Line 5: `[src]="config().hero_icon_url"``[src]="resolveImageUrl(config().hero_icon_url)"`
- Line 59: `[src]="member.photo_url"``[src]="resolveImageUrl(member.photo_url)"`
- Line 79: `[src]="config().logo_url"``[src]="resolveImageUrl(config().logo_url)"`
### donate.component.ts / donate.component.html
- Add method
- Template changes (2 bindings):
- Line 6: `[src]="config().hero_icon_url"``[src]="resolveImageUrl(config().hero_icon_url)"`
- Line 45: `[src]="config().logo_url"``[src]="resolveImageUrl(config().logo_url)"`
### login.component.ts / login.component.html
- Add method
- Template: Line 4: `[src]="config().logo_url"``[src]="resolveImageUrl(config().logo_url)"`
### schedule.component.ts / schedule.component.html
- Already has `resolveAsset()` — update it in Step 9
- Add a separate `resolveImageUrl()` method for the StationConfig logo
- Template: Line 4: `[src]="config().logo_url"``[src]="resolveImageUrl(config().logo_url)"`
---
## Verification
1. **Start the backend** — verify the `storage_blobs` table is created on startup
2. **Swagger UI** (`http://localhost:8000/docs`):
- `POST /api/storage/upload` — upload a PNG/JPEG/WebP/AVIF image → returns `{"url": "/api/storage/<hex>"}`
- Upload an SVG → should return 400 (rejected)
- Upload a non-image file with `Content-Type: image/png` → should return 400 (magic byte mismatch)
- `GET /api/storage/<hex>` — returns the binary image with correct Content-Type
3. **Admin UI** — log in, navigate to admin forms, upload images on team/show/station forms, verify the returned URL is stored in the correct field
4. **Public pages** — visit home, about, schedule, donate pages — verify all images render correctly
5. **Dev/prod parity** — verify that with `apiBaseUrl: ''` (prod), `/api/storage/{blob_id}` resolves correctly through the API proxy