diff --git a/docker-compose.yml b/docker-compose.yml index 3e936de..514b67e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index f6cd64e..ac1db63 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,6 +1,8 @@ #!/bin/sh set -e +mkdir -p /app/public/uploads + echo "Running Prisma migrations..." npx prisma db push --skip-generate diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts index f236fe9..f0c3bed 100644 --- a/src/app/api/upload/route.ts +++ b/src/app/api/upload/route.ts @@ -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 }); + } +} diff --git a/src/components/ui/ImagePicker.tsx b/src/components/ui/ImagePicker.tsx index 6703481..18bb8eb 100644 --- a/src/components/ui/ImagePicker.tsx +++ b/src/components/ui/ImagePicker.tsx @@ -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([]); const [galleryLoading, setGalleryLoading] = useState(false); const [uploading, setUploading] = useState(false); - const [filter, setFilter] = useState<'all' | 'uploads' | 'stock'>('all'); const fileInputRef = useRef(null); + const galleryFileInputRef = useRef(null); - const handleUpload = async (e: React.ChangeEvent) => { + /** + * 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, + 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 (
@@ -82,12 +152,19 @@ export function ImagePicker({ label, value, onChange }: ImagePickerProps) { {/* Thumbnail preview */}
{value ? ( - {label} + {label} ) : ( None )}
+ {/* URL text input */}
+ {/* Action buttons */}
- ))} + {/* Upload button inside modal */} +
+ + handleUpload(e, true)} + className="hidden" + />
+ {/* Image grid */} {galleryLoading ? (

Loading...

- ) : filteredGallery.length === 0 ? ( + ) : gallery.length === 0 ? (

No images found

) : ( -
- {filteredGallery.map((img) => ( - + {/* Clickable image tile */} + + + {/* Delete button — appears on hover in top-right corner */} + +
))}
)} diff --git a/src/middleware.ts b/src/middleware.ts index b08a191..d69d808 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -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).*)', ], };