diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc index 7620861..d282507 100644 Binary files a/backend/app/__pycache__/main.cpython-312.pyc and b/backend/app/__pycache__/main.cpython-312.pyc differ diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc index f9d3267..66c5815 100644 Binary files a/backend/app/__pycache__/models.cpython-312.pyc and b/backend/app/__pycache__/models.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/station_config.cpython-312.pyc b/backend/app/api/__pycache__/station_config.cpython-312.pyc index 7a1a2c6..bd3b7d9 100644 Binary files a/backend/app/api/__pycache__/station_config.cpython-312.pyc and b/backend/app/api/__pycache__/station_config.cpython-312.pyc differ diff --git a/backend/app/api/__pycache__/storage.cpython-312.pyc b/backend/app/api/__pycache__/storage.cpython-312.pyc new file mode 100644 index 0000000..7165171 Binary files /dev/null and b/backend/app/api/__pycache__/storage.cpython-312.pyc differ diff --git a/backend/app/api/station_config.py b/backend/app/api/station_config.py index 1771e65..1f0203a 100644 --- a/backend/app/api/station_config.py +++ b/backend/app/api/station_config.py @@ -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}"} diff --git a/backend/app/api/storage.py b/backend/app/api/storage.py new file mode 100644 index 0000000..aaa2816 --- /dev/null +++ b/backend/app/api/storage.py @@ -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)}, + ) diff --git a/backend/app/api/upload.py b/backend/app/api/upload.py deleted file mode 100644 index 8050a55..0000000 --- a/backend/app/api/upload.py +++ /dev/null @@ -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}"} diff --git a/backend/app/main.py b/backend/app/main.py index fae6adc..b58463a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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": diff --git a/backend/app/models.py b/backend/app/models.py index c3b839d..c683101 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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)) diff --git a/src/app/about/about.component.html b/src/app/about/about.component.html index f9a35c1..0c1e79a 100644 --- a/src/app/about/about.component.html +++ b/src/app/about/about.component.html @@ -2,7 +2,7 @@
- +

About {{ config().name_primary }} {{ config().name_secondary }}

For nearly four decades, {{ config().name_primary }} {{ config().name_secondary }} has been the voice of our @@ -56,7 +56,7 @@

@if (member.photo_url) { - + } @else { {{ member.name.charAt(0) }} } @@ -76,7 +76,7 @@

Become a Member

diff --git a/src/app/about/about.component.ts b/src/app/about/about.component.ts index 4dff96c..6a0ab90 100644 --- a/src/app/about/about.component.ts +++ b/src/app/about/about.component.ts @@ -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([]); readonly communityHighlights$ = new BehaviorSubject([]); + resolveImageUrl(url: string): string { + return resolveImageUrl(url); + } + constructor() { this.load(); } diff --git a/src/app/admin/admin-show-form.component.html b/src/app/admin/admin-show-form.component.html index 87656bf..b6655b5 100644 --- a/src/app/admin/admin-show-form.component.html +++ b/src/app/admin/admin-show-form.component.html @@ -91,7 +91,7 @@

-
diff --git a/src/app/admin/admin-show-form.component.ts b/src/app/admin/admin-show-form.component.ts index 2ef376d..f886eeb 100644 --- a/src/app/admin/admin-show-form.component.ts +++ b/src/app/admin/admin-show-form.component.ts @@ -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 { diff --git a/src/app/admin/admin-station-form.component.ts b/src/app/admin/admin-station-form.component.ts index e678a2f..75e7801 100644 --- a/src/app/admin/admin-station-form.component.ts +++ b/src/app/admin/admin-station-form.component.ts @@ -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(); } } diff --git a/src/app/admin/admin-team-form.component.ts b/src/app/admin/admin-team-form.component.ts index f378766..407d4a9 100644 --- a/src/app/admin/admin-team-form.component.ts +++ b/src/app/admin/admin-team-form.component.ts @@ -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 { diff --git a/src/app/donate/donate.component.html b/src/app/donate/donate.component.html index 76f28a9..c1ab344 100644 --- a/src/app/donate/donate.component.html +++ b/src/app/donate/donate.component.html @@ -3,7 +3,7 @@