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
environment:
- DATABASE_URL=postgresql://superbowl:superbowl@127.0.0.1:5432/superbowl?schema=public
volumes:
- uploads:/app/public/uploads
depends_on:
db:
condition: service_healthy
@@ -25,3 +27,4 @@ services:
volumes:
pgdata:
uploads:
+2
View File
@@ -1,6 +1,8 @@
#!/bin/sh
set -e
mkdir -p /app/public/uploads
echo "Running Prisma migrations..."
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 { getServerSession } from 'next-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 { existsSync } from 'fs';
/** Absolute path to the user-uploaded images directory */
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');
/** Allowed MIME types for upload validation (checked server-side) */
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) {
try {
const session = await getServerSession(authOptions);
@@ -26,7 +51,10 @@ export async function POST(request: Request) {
}
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) {
@@ -42,6 +70,9 @@ export async function POST(request: Request) {
const bytes = await file.arrayBuffer();
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);
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() {
try {
const session = await getServerSession(authOptions);
@@ -57,14 +96,14 @@ export async function GET() {
return NextResponse.json({ error: 'Unauthorized' }, { status: 403 });
}
const images: { url: string; name: string; category: string }[] = [];
const images: { url: string; name: string }[] = [];
// List uploaded images
if (existsSync(UPLOADS_DIR)) {
const uploads = await readdir(UPLOADS_DIR);
for (const file of uploads) {
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);
for (const file of stock) {
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 });
}
}
/**
* 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';
import { useState, useRef } from 'react';
@@ -7,12 +18,14 @@ import { Modal } from './Modal';
interface GalleryImage {
url: string;
name: string;
category: string;
}
interface ImagePickerProps {
/** Field label displayed above the picker */
label: string;
/** Currently selected image URL */
value: string;
/** Callback when the selected image changes */
onChange: (url: string) => void;
}
@@ -21,10 +34,20 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
const [gallery, setGallery] = useState<GalleryImage[]>([]);
const [galleryLoading, setGalleryLoading] = useState(false);
const [uploading, setUploading] = useState(false);
const [filter, setFilter] = useState<'all' | 'uploads' | 'stock'>('all');
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];
if (!file) return;
@@ -46,17 +69,25 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
const data = await res.json();
onChange(data.url);
// If uploaded from inside the gallery modal, refresh the list
// so the new image appears immediately
if (fromGallery) {
await fetchGallery();
}
} catch {
alert('Upload failed');
} finally {
setUploading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
if (galleryFileInputRef.current) galleryFileInputRef.current.value = '';
}
};
const openGallery = async () => {
setGalleryOpen(true);
setGalleryLoading(true);
/**
* Fetches the full image list from GET /api/upload and updates local state.
*/
const fetchGallery = async () => {
try {
const res = await fetch('/api/upload');
if (res.ok) {
@@ -64,15 +95,54 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
setGallery(data.images || []);
}
} catch {
// ignore
} finally {
setGalleryLoading(false);
// Silently fail — the gallery will show as empty
}
};
const filteredGallery = filter === 'all'
? gallery
: gallery.filter((img) => img.category === filter);
/**
* Opens the gallery modal and loads the image list.
*/
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 (
<div className="space-y-2">
@@ -82,12 +152,19 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
{/* 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">
{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>
)}
</div>
{/* URL text input */}
<div className="flex-1 min-w-0">
<input
type="text"
@@ -99,6 +176,7 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
</div>
</div>
{/* Action buttons */}
<div className="flex gap-2">
<button
type="button"
@@ -126,62 +204,87 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) {
)}
</div>
{/* Hidden file input for inline upload */}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
onChange={handleUpload}
onChange={(e) => handleUpload(e)}
className="hidden"
/>
{/* Gallery Modal */}
<Modal open={galleryOpen} onClose={() => setGalleryOpen(false)} title="Image Gallery">
{/* Filter tabs */}
<div className="flex gap-2 mb-4">
{(['all', 'uploads', 'stock'] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`text-xs px-3 py-1 rounded-full transition-colors ${
filter === f
? 'bg-primary-600 text-white'
: 'bg-gray-800 text-gray-400 hover:text-white'
}`}
>
{f === 'all' ? 'All' : f === 'uploads' ? 'Uploads' : 'Stock'}
</button>
))}
{/* Upload button inside modal */}
<div className="flex items-center gap-2 mb-4">
<button
type="button"
onClick={() => galleryFileInputRef.current?.click()}
disabled={uploading}
className="btn-secondary text-xs py-1 px-3"
>
{uploading ? 'Uploading...' : 'Upload Image'}
</button>
<input
ref={galleryFileInputRef}
type="file"
accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml"
onChange={(e) => handleUpload(e, true)}
className="hidden"
/>
</div>
{/* Image grid */}
{galleryLoading ? (
<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>
) : (
<div className="grid grid-cols-4 gap-2 max-h-80 overflow-y-auto">
{filteredGallery.map((img) => (
<button
<div className="grid grid-cols-4 gap-2 max-h-96 overflow-y-auto">
{gallery.map((img) => (
<div
key={img.url}
onClick={() => {
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'
}`}
className="relative group"
>
<Image
src={img.url}
alt={img.name}
width={80}
height={80}
className="w-full aspect-square object-contain rounded"
/>
<span className="text-[9px] text-gray-500 truncate block mt-0.5">
{img.name}
</span>
</button>
{/* Clickable image tile */}
<button
type="button"
onClick={() => {
onChange(img.url);
setGalleryOpen(false);
}}
className={`w-full 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'
}`}
>
{/* 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>
)}
+1 -1
View File
@@ -53,6 +53,6 @@ export default withAuth(
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|images).*)',
'/((?!_next/static|_next/image|favicon.ico|images|uploads).*)',
],
};