Fix image upload gallery: previews, delete, persistence

- Fix uploaded images showing as 404: /uploads/ was not excluded from the
  auth middleware matcher, so browsers were blocked from loading images.
  Added uploads to the matcher exclusion alongside images.
- Ensure uploads directory exists at startup (mkdir -p in entrypoint +
  recursive mkdir in the upload POST handler).
- Add DELETE /api/upload?url=... endpoint for admins to delete any image.
- Simplify gallery to a single unified view (no stock/uploads filter tabs).
  Each image tile shows a red × delete button on hover with confirmation.
  Upload Image button also available inside the gallery modal.
- Add Docker named volume for /app/public/uploads so uploaded images
  persist across container restarts.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Philip
2026-03-12 09:24:28 -07:00
parent c0e726d5f7
commit 910549e361
5 changed files with 259 additions and 60 deletions
+3
View File
@@ -5,6 +5,8 @@ services:
env_file: .env env_file: .env
environment: environment:
- DATABASE_URL=postgresql://superbowl:superbowl@127.0.0.1:5432/superbowl?schema=public - DATABASE_URL=postgresql://superbowl:superbowl@127.0.0.1:5432/superbowl?schema=public
volumes:
- uploads:/app/public/uploads
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
@@ -25,3 +27,4 @@ services:
volumes: volumes:
pgdata: pgdata:
uploads:
+2
View File
@@ -1,6 +1,8 @@
#!/bin/sh #!/bin/sh
set -e set -e
mkdir -p /app/public/uploads
echo "Running Prisma migrations..." echo "Running Prisma migrations..."
npx prisma db push --skip-generate npx prisma db push --skip-generate
+97 -6
View File
@@ -1,16 +1,41 @@
/**
* Upload API Route
*
* Handles image uploads, gallery listing, and image deletion.
*
* POST - Upload a new image (admin only)
* GET - List all images from uploads/ and images/ as a flat array (admin only)
* DELETE - Delete an image by URL query param (admin only)
*/
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth'; import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth'; import { authOptions } from '@/lib/auth';
import { writeFile, readdir } from 'fs/promises'; import { writeFile, readdir, mkdir, unlink } from 'fs/promises';
import path from 'path'; import path from 'path';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
/** Absolute path to the user-uploaded images directory */
const UPLOADS_DIR = path.join(process.cwd(), 'public', 'uploads'); const UPLOADS_DIR = path.join(process.cwd(), 'public', 'uploads');
/** Absolute path to the stock images directory */
const IMAGES_DIR = path.join(process.cwd(), 'public', 'images'); const IMAGES_DIR = path.join(process.cwd(), 'public', 'images');
/** Allowed MIME types for upload validation (checked server-side) */
const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']; const ALLOWED_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'];
const MAX_SIZE = 5 * 1024 * 1024; // 5MB
/** Maximum upload file size in bytes (5 MB) */
const MAX_SIZE = 5 * 1024 * 1024;
/**
* POST /api/upload
*
* Accepts a multipart form upload with a single 'file' field.
* Validates MIME type and file size server-side, sanitizes the filename,
* appends a timestamp to prevent collisions, and writes to the uploads directory.
*
* @returns {{ url: string }} The public URL path of the uploaded file
*/
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -26,7 +51,10 @@ export async function POST(request: Request) {
} }
if (!ALLOWED_TYPES.includes(file.type)) { if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json({ error: 'Invalid file type. Allowed: PNG, JPEG, GIF, WebP, SVG' }, { status: 400 }); return NextResponse.json(
{ error: 'Invalid file type. Allowed: PNG, JPEG, GIF, WebP, SVG' },
{ status: 400 }
);
} }
if (file.size > MAX_SIZE) { if (file.size > MAX_SIZE) {
@@ -42,6 +70,9 @@ export async function POST(request: Request) {
const bytes = await file.arrayBuffer(); const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes); const buffer = Buffer.from(bytes);
// Ensure the uploads directory exists before writing
await mkdir(UPLOADS_DIR, { recursive: true });
await writeFile(path.join(UPLOADS_DIR, fileName), buffer); await writeFile(path.join(UPLOADS_DIR, fileName), buffer);
return NextResponse.json({ url: `/uploads/${fileName}` }); return NextResponse.json({ url: `/uploads/${fileName}` });
@@ -50,6 +81,14 @@ export async function POST(request: Request) {
} }
} }
/**
* GET /api/upload
*
* Returns a flat array of all available images (both user uploads and stock)
* without any category distinction. Each entry contains { url, name }.
*
* @returns {{ images: Array<{ url: string, name: string }> }}
*/
export async function GET() { export async function GET() {
try { try {
const session = await getServerSession(authOptions); const session = await getServerSession(authOptions);
@@ -57,14 +96,14 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
} }
const images: { url: string; name: string; category: string }[] = []; const images: { url: string; name: string }[] = [];
// List uploaded images // List uploaded images
if (existsSync(UPLOADS_DIR)) { if (existsSync(UPLOADS_DIR)) {
const uploads = await readdir(UPLOADS_DIR); const uploads = await readdir(UPLOADS_DIR);
for (const file of uploads) { for (const file of uploads) {
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) { if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) {
images.push({ url: `/uploads/${file}`, name: file, category: 'uploads' }); images.push({ url: `/uploads/${file}`, name: file });
} }
} }
} }
@@ -74,7 +113,7 @@ export async function GET() {
const stock = await readdir(IMAGES_DIR); const stock = await readdir(IMAGES_DIR);
for (const file of stock) { for (const file of stock) {
if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) { if (/\.(png|jpe?g|gif|webp|svg)$/i.test(file)) {
images.push({ url: `/images/${file}`, name: file, category: 'stock' }); images.push({ url: `/images/${file}`, name: file });
} }
} }
} }
@@ -84,3 +123,55 @@ export async function GET() {
return NextResponse.json({ error: 'Failed to list images' }, { status: 500 }); return NextResponse.json({ error: 'Failed to list images' }, { status: 500 });
} }
} }
/**
* DELETE /api/upload?url=/uploads/filename.png
*
* Deletes an image file from disk. Only allows deletion of files within
* /uploads/ or /images/ directories. Uses path.basename() to prevent
* path traversal attacks.
*
* @param request - Must include a `url` query parameter (e.g., /uploads/foo.png)
* @returns {{ success: true }} on success, or an error response
*/
export async function DELETE(request: Request) {
try {
const session = await getServerSession(authOptions);
if (!session || (session.user as any).role !== 'ADMIN') {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const { searchParams } = new URL(request.url);
const url = searchParams.get('url');
if (!url) {
return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 });
}
// Only allow deletion from known image directories
if (!url.startsWith('/uploads/') && !url.startsWith('/images/')) {
return NextResponse.json({ error: 'Invalid image path' }, { status: 400 });
}
// Extract only the filename to prevent path traversal (e.g., ../../etc/passwd)
const fileName = path.basename(url);
// Determine the target directory based on the URL prefix
const targetDir = url.startsWith('/uploads/') ? UPLOADS_DIR : IMAGES_DIR;
const filePath = path.join(targetDir, fileName);
// Verify the resolved path is still within the expected directory
if (!filePath.startsWith(targetDir)) {
return NextResponse.json({ error: 'Invalid file path' }, { status: 400 });
}
await unlink(filePath);
return NextResponse.json({ success: true });
} catch (err: any) {
if (err?.code === 'ENOENT') {
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
return NextResponse.json({ error: 'Delete failed' }, { status: 500 });
}
}
+156 -53
View File
@@ -1,3 +1,14 @@
/**
* ImagePicker Component
*
* Provides a complete image selection UI with:
* - Thumbnail preview of the currently selected image
* - Text input for manual URL entry
* - Direct upload button
* - Gallery modal with unified view of all images (uploads + stock)
* - In-gallery upload and per-image delete with confirmation
*/
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef } from 'react';
@@ -7,12 +18,14 @@ import { Modal } from './Modal';
interface GalleryImage { interface GalleryImage {
url: string; url: string;
name: string; name: string;
category: string;
} }
interface ImagePickerProps { interface ImagePickerProps {
/** Field label displayed above the picker */
label: string; label: string;
/** Currently selected image URL */
value: string; value: string;
/** Callback when the selected image changes */
onChange: (url: string) => void; onChange: (url: string) => void;
} }
@@ -21,10 +34,20 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
const [gallery, setGallery] = useState<GalleryImage[]>([]); const [gallery, setGallery] = useState<GalleryImage[]>([]);
const [galleryLoading, setGalleryLoading] = useState(false); const [galleryLoading, setGalleryLoading] = useState(false);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [filter, setFilter] = useState<'all' | 'uploads' | 'stock'>('all');
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const galleryFileInputRef = useRef<HTMLInputElement>(null);
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => { /**
* Handles file upload from either the inline button or the gallery modal.
* Sends the file to POST /api/upload and updates the selected value on success.
*
* @param e - File input change event
* @param fromGallery - If true, refreshes the gallery list after upload
*/
const handleUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
fromGallery = false
) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
@@ -46,17 +69,25 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
const data = await res.json(); const data = await res.json();
onChange(data.url); onChange(data.url);
// If uploaded from inside the gallery modal, refresh the list
// so the new image appears immediately
if (fromGallery) {
await fetchGallery();
}
} catch { } catch {
alert('Upload failed'); alert('Upload failed');
} finally { } finally {
setUploading(false); setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
if (galleryFileInputRef.current) galleryFileInputRef.current.value = '';
} }
}; };
const openGallery = async () => { /**
setGalleryOpen(true); * Fetches the full image list from GET /api/upload and updates local state.
setGalleryLoading(true); */
const fetchGallery = async () => {
try { try {
const res = await fetch('/api/upload'); const res = await fetch('/api/upload');
if (res.ok) { if (res.ok) {
@@ -64,15 +95,54 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
setGallery(data.images || []); setGallery(data.images || []);
} }
} catch { } catch {
// ignore // Silently fail — the gallery will show as empty
} finally {
setGalleryLoading(false);
} }
}; };
const filteredGallery = filter === 'all' /**
? gallery * Opens the gallery modal and loads the image list.
: gallery.filter((img) => img.category === filter); */
const openGallery = async () => {
setGalleryOpen(true);
setGalleryLoading(true);
await fetchGallery();
setGalleryLoading(false);
};
/**
* Deletes an image via DELETE /api/upload?url=... after user confirmation.
* Removes the image from local gallery state on success. If the deleted
* image was the currently selected value, clears the selection.
*
* @param imageUrl - The URL path of the image to delete (e.g., /uploads/foo.png)
*/
const handleDelete = async (imageUrl: string) => {
if (!window.confirm(`Delete this image?\n${imageUrl}`)) {
return;
}
try {
const res = await fetch(`/api/upload?url=${encodeURIComponent(imageUrl)}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Delete failed');
return;
}
// Remove from local state without re-fetching
setGallery((prev) => prev.filter((img) => img.url !== imageUrl));
// Clear selection if the deleted image was active
if (value === imageUrl) {
onChange('');
}
} catch {
alert('Delete failed');
}
};
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -82,12 +152,19 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
{/* Thumbnail preview */} {/* Thumbnail preview */}
<div className="w-14 h-14 rounded-lg bg-gray-800 border border-gray-700/60 flex items-center justify-center overflow-hidden flex-shrink-0"> <div className="w-14 h-14 rounded-lg bg-gray-800 border border-gray-700/60 flex items-center justify-center overflow-hidden flex-shrink-0">
{value ? ( {value ? (
<Image src={value} alt={label} width={56} height={56} className="object-contain w-full h-full" /> <Image
src={value}
alt={label}
width={56}
height={56}
className="object-contain w-full h-full"
/>
) : ( ) : (
<span className="text-gray-600 text-xs">None</span> <span className="text-gray-600 text-xs">None</span>
)} )}
</div> </div>
{/* URL text input */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<input <input
type="text" type="text"
@@ -99,6 +176,7 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
</div> </div>
</div> </div>
{/* Action buttons */}
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
@@ -126,62 +204,87 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
)} )}
</div> </div>
{/* Hidden file input for inline upload */}
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
onChange={handleUpload} onChange={(e) => handleUpload(e)}
className="hidden" className="hidden"
/> />
{/* Gallery Modal */}
<Modal open={galleryOpen} onClose={() => setGalleryOpen(false)} title="Image Gallery"> <Modal open={galleryOpen} onClose={() => setGalleryOpen(false)} title="Image Gallery">
{/* Filter tabs */} {/* Upload button inside modal */}
<div className="flex gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">
{(['all', 'uploads', 'stock'] as const).map((f) => ( <button
<button type="button"
key={f} onClick={() => galleryFileInputRef.current?.click()}
onClick={() => setFilter(f)} disabled={uploading}
className={`text-xs px-3 py-1 rounded-full transition-colors ${ className="btn-secondary text-xs py-1 px-3"
filter === f >
? 'bg-primary-600 text-white' {uploading ? 'Uploading...' : 'Upload Image'}
: 'bg-gray-800 text-gray-400 hover:text-white' </button>
}`} <input
> ref={galleryFileInputRef}
{f === 'all' ? 'All' : f === 'uploads' ? 'Uploads' : 'Stock'} type="file"
</button> accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
))} onChange={(e) => handleUpload(e, true)}
className="hidden"
/>
</div> </div>
{/* Image grid */}
{galleryLoading ? ( {galleryLoading ? (
<p className="text-gray-500 text-sm text-center py-8">Loading...</p> <p className="text-gray-500 text-sm text-center py-8">Loading...</p>
) : filteredGallery.length === 0 ? ( ) : gallery.length === 0 ? (
<p className="text-gray-500 text-sm text-center py-8">No images found</p> <p className="text-gray-500 text-sm text-center py-8">No images found</p>
) : ( ) : (
<div className="grid grid-cols-4 gap-2 max-h-80 overflow-y-auto"> <div className="grid grid-cols-4 gap-2 max-h-96 overflow-y-auto">
{filteredGallery.map((img) => ( {gallery.map((img) => (
<button <div
key={img.url} key={img.url}
onClick={() => { className="relative group"
onChange(img.url);
setGalleryOpen(false);
}}
className={`p-1 rounded-lg border transition-all hover:border-primary-500 ${
value === img.url
? 'border-primary-500 bg-primary-900/30'
: 'border-gray-700 bg-gray-800'
}`}
> >
<Image {/* Clickable image tile */}
src={img.url} <button
alt={img.name} type="button"
width={80} onClick={() => {
height={80} onChange(img.url);
className="w-full aspect-square object-contain rounded" setGalleryOpen(false);
/> }}
<span className="text-[9px] text-gray-500 truncate block mt-0.5"> className={`w-full p-1 rounded-lg border transition-all hover:border-primary-500 ${
{img.name} value === img.url
</span> ? 'border-primary-500 bg-primary-900/30'
</button> : 'border-gray-700 bg-gray-800'
}`}
>
{/* Use regular <img> to avoid Next.js optimization issues with dynamic uploads */}
<img
src={img.url}
alt={img.name}
className="w-full aspect-square object-contain rounded"
/>
<span className="text-[9px] text-gray-500 truncate block mt-0.5">
{img.name}
</span>
</button>
{/* Delete button — appears on hover in top-right corner */}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDelete(img.url);
}}
className="absolute top-1 right-1 w-5 h-5 rounded-full bg-red-600 text-white text-xs
flex items-center justify-center opacity-0 group-hover:opacity-100
transition-opacity hover:bg-red-500"
title="Delete image"
>
&times;
</button>
</div>
))} ))}
</div> </div>
)} )}
+1 -1
View File
@@ -53,6 +53,6 @@ export default withAuth(
export const config = { export const config = {
matcher: [ matcher: [
'/((?!_next/static|_next/image|favicon.ico|images).*)', '/((?!_next/static|_next/image|favicon.ico|images|uploads).*)',
], ],
}; };