Devlog 16: Image Uploads - Letting Users Add Custom Contestant Photos
Initially, users could only create text-based matchups. "Cats vs Dogs" with no images. Boring.
I wanted users to upload custom images for contestants. This opens up way more creative matchups, but also introduces security and moderation challenges.
The Problem
Allowing user-uploaded images means:
- Potential for NSFW/inappropriate content
- Large file sizes crushing bandwidth
- Malicious file uploads
- Storage costs
- Need for CDN delivery
I needed uploads that are safe, fast, and cheap.
Supabase Storage
Supabase has built-in storage with a simple API:
const { data, error } = await supabase.storage
.from('matchup-images')
.upload(`public/${filename}`, file);
Pros:
- Automatic CDN via Cloudflare
- Built-in access control (RLS)
- Cheap ($0.021/GB)
- Works with existing Supabase auth
Cons:
- 50MB file limit
- No built-in image processing
- Manual moderation needed
Good enough for v1.
Client-Side Validation
Before uploading, I validate on the client:
function validateImage(file: File): string | null {
// Check file type
const validTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!validTypes.includes(file.type)) {
return 'Only JPG, PNG, WebP, and GIF allowed';
}
// Check file size (5MB max)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
return 'Image must be under 5MB';
}
return null; // Valid
}
This prevents accidental bad uploads but doesn't stop determined attackers. Server-side validation is still needed.
Server-Side Validation
Supabase Storage supports bucket policies:
-- Only authenticated users can upload
CREATE POLICY "Authenticated users can upload images"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (bucket_id = 'matchup-images');
-- File size limit
CREATE POLICY "Max 5MB file size"
ON storage.objects FOR INSERT
WITH CHECK (
bucket_id = 'matchup-images'
AND (storage.foldername(name))[1] = 'public'
AND octet_length(metadata) < 5242880
);
Policies run on the server, so they can't be bypassed.
Image Optimization
Users upload huge 4000x3000 photos straight from their phones. I need to resize them:
import sharp from 'sharp';
async function optimizeImage(buffer: Buffer): Promise<Buffer> {
return await sharp(buffer)
.resize(800, 800, {
fit: 'cover',
position: 'center'
})
.webp({ quality: 85 })
.toBuffer();
}
This runs on upload via a Supabase Edge Function:
// supabase/functions/optimize-image/index.ts
Deno.serve(async (req) => {
const formData = await req.formData();
const file = formData.get('file') as File;
const buffer = await file.arrayBuffer();
const optimized = await optimizeImage(Buffer.from(buffer));
// Upload optimized version
const { data, error } = await supabase.storage
.from('matchup-images')
.upload(`optimized/${filename}`, optimized);
return new Response(JSON.stringify({ url: data.path }));
});
This saves bandwidth and improves load times.
Upload Flow
The complete upload process:
- User selects image in browser
- Client validates file type and size
- Show preview
- On form submit, upload to Supabase Storage
- Edge function optimizes and resaves
- Return optimized URL
- Store URL in matchup record
async function uploadContestantImage(file: File): Promise<string> {
// Validate
const error = validateImage(file);
if (error) throw new Error(error);
// Generate unique filename
const ext = file.name.split('.').pop();
const filename = `${crypto.randomUUID()}.${ext}`;
// Upload original
const { data, error: uploadError } = await supabase.storage
.from('matchup-images')
.upload(`uploads/${filename}`, file);
if (uploadError) throw uploadError;
// Trigger optimization (Edge Function)
const optimized = await fetch('/api/optimize-image', {
method: 'POST',
body: JSON.stringify({ path: data.path })
});
const { url } = await optimized.json();
return url;
}
Fallback Images
Not every matchup needs custom images. I use a fallback system:
function getContestantImage(matchup: Matchup, side: 'a' | 'b'): string {
const customImage = side === 'a' ? matchup.image_a : matchup.image_b;
if (customImage) {
return `${SUPABASE_URL}/storage/v1/object/public/matchup-images/${customImage}`;
}
// Fallback to generated avatar
const name = side === 'a' ? matchup.contestant_a : matchup.contestant_b;
return `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&size=400`;
}
UI Avatars generates colored circles with initials. Better than nothing.
CDN & Caching
Supabase Storage sits behind Cloudflare's CDN automatically:
User Request → Cloudflare CDN → Supabase Storage → S3
Cache headers:
await supabase.storage
.from('matchup-images')
.upload(path, file, {
cacheControl: '3600', // 1 hour
upsert: false
});
First request is slow (~200ms), subsequent requests are instant (served from CDN).
Moderation Challenge
Allowing user uploads means dealing with inappropriate content. Options:
1. Pre-moderation: Review images before publishing (slow, labor-intensive)
2. Post-moderation: Publish first, review later (fast, risky)
3. AI moderation: Auto-detect NSFW (expensive, false positives)
4. Community moderation: Users report bad images (scales, reactive)
I went with a hybrid: community reports + manual review queue.
Report System
Users can flag inappropriate images:
async function reportImage(matchupId: string, reason: string) {
await supabase
.from('image_reports')
.insert({
matchup_id: matchupId,
reason,
reporter_id: currentUserId,
status: 'pending'
});
}
I review flagged images manually in an admin panel. Not scalable long-term but works for now.
Future: AI Moderation
Eventually I'll add automated NSFW detection:
import { predict } from '@cloudflare/ai';
async function checkImageSafety(imageUrl: string): Promise<boolean> {
const result = await predict({
model: 'nsfw-detector',
input: { image: imageUrl }
});
return result.nsfw_score < 0.3; // < 30% NSFW probability
}
But that costs money per image, so I'm holding off until volume justifies it.
Cost Analysis
Supabase Storage pricing:
- $0.021/GB/month storage
- $0.09/GB egress
With CDN caching, most requests don't hit storage (no egress charge). Average costs:
- 1000 images = ~500MB = $0.01/month storage
- CDN handles 99% of requests
- Egress: ~$0.50/month for 10K views
Way cheaper than hosting images myself.
Takeaway
Image uploads are surprisingly complex. Validation, optimization, storage, CDN, moderation all needed.
Supabase Storage handles the hard parts (CDN, access control, scaling) so I could focus on the upload flow and moderation.
Client-side validation is for UX. Server-side validation is for security. Always have both.
Community moderation scales better than manual review for user-generated content. Start simple, add AI when volume demands it.
Next up: rate limiting and abuse prevention to keep the platform healthy.