From dd8dd0808ea97cb0416410b7d92e99ad9f61ddc0 Mon Sep 17 00:00:00 2001 From: Tanya Date: Wed, 21 Jan 2026 14:33:59 -0500 Subject: [PATCH] feat: Add photo management API endpoints for fetching, favoriting, reporting, and tagging photos - Implemented GET endpoint to retrieve photo details by ID, including associated faces and tags. - Added GET endpoint for fetching adjacent photos based on date taken. - Created POST endpoint to toggle favorites for photos, including user authentication checks. - Developed POST and GET endpoints for reporting photos, with caching for report statuses. - Introduced POST endpoint for bulk toggling of favorites. - Implemented batch processing for checking report statuses. - Added endpoint for managing tag linkages, including validation and error handling. - Created upload endpoint for handling photo uploads with size and type validation. These changes enhance the photo management capabilities of the application, allowing users to interact with photos more effectively. --- .gitignore | 1 - .../app/api/photos/[id]/adjacent/route.ts | 94 ++++ .../app/api/photos/[id]/favorite/route.ts | 161 +++++++ .../app/api/photos/[id]/image/route.ts | 391 ++++++++++++++++ .../app/api/photos/[id]/report/route.ts | 426 ++++++++++++++++++ viewer-frontend/app/api/photos/[id]/route.ts | 51 +++ .../app/api/photos/favorites/batch/route.ts | 53 +++ .../app/api/photos/favorites/bulk/route.ts | 180 ++++++++ .../app/api/photos/reports/batch/route.ts | 206 +++++++++ .../app/api/photos/tag-linkages/route.ts | 279 ++++++++++++ .../app/api/photos/upload/route.ts | 260 +++++++++++ 11 files changed, 2101 insertions(+), 1 deletion(-) create mode 100644 viewer-frontend/app/api/photos/[id]/adjacent/route.ts create mode 100644 viewer-frontend/app/api/photos/[id]/favorite/route.ts create mode 100644 viewer-frontend/app/api/photos/[id]/image/route.ts create mode 100644 viewer-frontend/app/api/photos/[id]/report/route.ts create mode 100644 viewer-frontend/app/api/photos/[id]/route.ts create mode 100644 viewer-frontend/app/api/photos/favorites/batch/route.ts create mode 100644 viewer-frontend/app/api/photos/favorites/bulk/route.ts create mode 100644 viewer-frontend/app/api/photos/reports/batch/route.ts create mode 100644 viewer-frontend/app/api/photos/tag-linkages/route.ts create mode 100644 viewer-frontend/app/api/photos/upload/route.ts diff --git a/.gitignore b/.gitignore index 954d7f3..66ca02d 100644 --- a/.gitignore +++ b/.gitignore @@ -57,7 +57,6 @@ Thumbs.db .history/ -photos/ # Photo files and large directories *.jpg diff --git a/viewer-frontend/app/api/photos/[id]/adjacent/route.ts b/viewer-frontend/app/api/photos/[id]/adjacent/route.ts new file mode 100644 index 0000000..d664ebb --- /dev/null +++ b/viewer-frontend/app/api/photos/[id]/adjacent/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const photoId = parseInt(id, 10); + + if (isNaN(photoId)) { + return NextResponse.json( + { error: 'Invalid photo ID' }, + { status: 400 } + ); + } + + // Get the current photo to find its date_taken for ordering + const currentPhoto = await prisma.photo.findUnique({ + where: { id: photoId }, + select: { date_taken: true }, + }); + + if (!currentPhoto) { + return NextResponse.json( + { error: 'Photo not found' }, + { status: 404 } + ); + } + + // Get previous photo (earlier date_taken, or same date_taken with higher ID) + const previousPhoto = await prisma.photo.findFirst({ + where: { + processed: true, + OR: [ + { + date_taken: { + lt: currentPhoto.date_taken || new Date(), + }, + }, + { + date_taken: currentPhoto.date_taken, + id: { + gt: photoId, + }, + }, + ], + }, + orderBy: [ + { date_taken: 'desc' }, + { id: 'asc' }, + ], + select: { id: true }, + }); + + // Get next photo (later date_taken, or same date_taken with lower ID) + const nextPhoto = await prisma.photo.findFirst({ + where: { + processed: true, + OR: [ + { + date_taken: { + gt: currentPhoto.date_taken || new Date(), + }, + }, + { + date_taken: currentPhoto.date_taken, + id: { + lt: photoId, + }, + }, + ], + }, + orderBy: [ + { date_taken: 'asc' }, + { id: 'desc' }, + ], + select: { id: true }, + }); + + return NextResponse.json({ + previous: previousPhoto?.id || null, + next: nextPhoto?.id || null, + }); + } catch (error) { + console.error('Error fetching adjacent photos:', error); + return NextResponse.json( + { error: 'Failed to fetch adjacent photos' }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/photos/[id]/favorite/route.ts b/viewer-frontend/app/api/photos/[id]/favorite/route.ts new file mode 100644 index 0000000..816b608 --- /dev/null +++ b/viewer-frontend/app/api/photos/[id]/favorite/route.ts @@ -0,0 +1,161 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma, prismaAuth } from '@/lib/db'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; + +// POST - Toggle favorite (add if not exists, remove if exists) +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required. Please sign in to favorite photos.' }, + { status: 401 } + ); + } + + const { id } = await params; + const photoId = parseInt(id, 10); + const userId = parseInt(session.user.id, 10); + + if (isNaN(photoId)) { + return NextResponse.json( + { error: 'Invalid photo ID' }, + { status: 400 } + ); + } + + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user ID' }, + { status: 400 } + ); + } + + // Verify photo exists in main database + const photo = await prisma.photo.findUnique({ + where: { id: photoId }, + select: { id: true, filename: true }, + }); + + if (!photo) { + return NextResponse.json( + { error: 'Photo not found' }, + { status: 404 } + ); + } + + // Verify user exists in auth database + const user = await prismaAuth.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + + if (!user) { + console.error(`[FAVORITE] User ${userId} not found in auth database`); + return NextResponse.json( + { error: 'User account not found. Please sign in again.' }, + { status: 404 } + ); + } + + // Check if favorite already exists + const existing = await prismaAuth.photoFavorite.findUnique({ + where: { + uq_photo_user_favorite: { + photoId, + userId, + }, + }, + }); + + if (existing) { + // Remove favorite + await prismaAuth.photoFavorite.delete({ + where: { id: existing.id }, + }); + return NextResponse.json({ + favorited: false, + message: 'Photo removed from favorites' + }); + } else { + // Add favorite + await prismaAuth.photoFavorite.create({ + data: { + photoId, + userId, + favoritedAt: new Date(), + }, + }); + return NextResponse.json({ + favorited: true, + message: 'Photo added to favorites' + }); + } + } catch (error: any) { + console.error('Error toggling favorite:', error); + + // Handle case where table doesn't exist yet (P2021 = table does not exist) + if (error.code === 'P2021') { + return NextResponse.json( + { error: 'Favorites feature is not available yet. Please run the migration: migrations/add-photo-favorites-table.sql' }, + { status: 503 } + ); + } + + if (error.code === 'P2002') { + return NextResponse.json( + { error: 'Favorite already exists' }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: 'Failed to toggle favorite', details: error.message }, + { status: 500 } + ); + } +} + +// GET - Check if photo is favorited +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ favorited: false }); + } + + const { id } = await params; + const photoId = parseInt(id, 10); + const userId = parseInt(session.user.id, 10); + + if (isNaN(photoId) || isNaN(userId)) { + return NextResponse.json({ favorited: false }); + } + + const favorite = await prismaAuth.photoFavorite.findUnique({ + where: { + uq_photo_user_favorite: { + photoId, + userId, + }, + }, + }); + + return NextResponse.json({ favorited: !!favorite }); + } catch (error: any) { + // Handle case where table doesn't exist yet (P2021 = table does not exist) + if (error.code === 'P2021') { + // Table doesn't exist yet, return false (not favorited) + return NextResponse.json({ favorited: false }); + } + console.error('Error checking favorite:', error); + return NextResponse.json({ favorited: false }); + } +} + diff --git a/viewer-frontend/app/api/photos/[id]/image/route.ts b/viewer-frontend/app/api/photos/[id]/image/route.ts new file mode 100644 index 0000000..28e7e3f --- /dev/null +++ b/viewer-frontend/app/api/photos/[id]/image/route.ts @@ -0,0 +1,391 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; +import { readFile } from 'fs/promises'; +import { createReadStream } from 'fs'; +import { existsSync, statSync } from 'fs'; +import path from 'path'; +import { getVideoThumbnail } from '@/lib/video-thumbnail'; + +// Conditionally import sharp - handle case where libvips is not installed +let sharp: any = null; +try { + sharp = require('sharp'); +} catch (error) { + console.warn('Sharp library not available. Watermarking and image processing will be disabled.'); + console.warn('To enable image processing, install libvips: sudo apt-get install libvips-dev'); +} + +const WATERMARK_BUCKET_SIZE = 200; +const watermarkCache = new Map(); + +async function getWatermarkOverlay(baseWidth: number, baseHeight: number) { + if (!sharp) { + throw new Error('Sharp library not available'); + } + + const bucketWidth = Math.ceil(baseWidth / WATERMARK_BUCKET_SIZE) * WATERMARK_BUCKET_SIZE; + const bucketHeight = Math.ceil(baseHeight / WATERMARK_BUCKET_SIZE) * WATERMARK_BUCKET_SIZE; + const cacheKey = `${bucketWidth}x${bucketHeight}`; + + let cached: Buffer | undefined = watermarkCache.get(cacheKey); + if (!cached) { + const diagonal = Math.sqrt(bucketWidth * bucketWidth + bucketHeight * bucketHeight); + const watermarkWidth = Math.round(diagonal * 1.05); + const watermarkHeight = Math.round((watermarkWidth * 120) / 360); + const watermarkSvg = ` + + + + + + + + + + J + M + + + + Jewish + and Modern + + + `; + + const rotationAngle = -Math.atan(bucketHeight / bucketWidth) * (180 / Math.PI); + const newCached = await sharp(Buffer.from(watermarkSvg)) + .resize({ + width: watermarkWidth, + height: watermarkHeight, + fit: 'inside', + withoutEnlargement: true, + }) + .rotate(rotationAngle, { background: { r: 0, g: 0, b: 0, alpha: 0 } }) + .resize({ + width: bucketWidth, + height: bucketHeight, + fit: 'cover', + position: 'centre', + }) + .ensureAlpha() + .toBuffer(); + + watermarkCache.set(cacheKey, newCached); + cached = newCached; + } + + // cached is guaranteed to be defined here (either from cache or just created) + return sharp(cached) + .resize({ + width: baseWidth, + height: baseHeight, + fit: 'cover', + position: 'centre', + }) + .ensureAlpha() + .toBuffer(); +} + +/** + * Get content type from file extension + */ +function getContentType(filePath: string, mediaType?: string): string { + const ext = path.extname(filePath).toLowerCase(); + + // If media_type is video, prioritize video MIME types + if (mediaType === 'video') { + if (ext === '.mp4') return 'video/mp4'; + if (ext === '.webm') return 'video/webm'; + if (ext === '.mov') return 'video/quicktime'; + if (ext === '.avi') return 'video/x-msvideo'; + if (ext === '.mkv') return 'video/x-matroska'; + return 'video/mp4'; // default + } + + // Image types + if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'; + if (ext === '.png') return 'image/png'; + if (ext === '.gif') return 'image/gif'; + if (ext === '.webp') return 'image/webp'; + if (ext === '.heic') return 'image/heic'; + if (ext === '.heif') return 'image/heif'; + return 'image/jpeg'; // default +} + +/** + * Handle HTTP range requests for video streaming + */ +function parseRange(range: string | null, fileSize: number): { start: number; end: number } | null { + if (!range) return null; + + const parts = range.replace(/bytes=/, '').split('-'); + const start = parseInt(parts[0], 10); + const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1; + + if (isNaN(start) || isNaN(end) || start > end || start < 0) { + return null; + } + + return { start, end }; +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const photoId = parseInt(id); + + if (isNaN(photoId)) { + return new NextResponse('Invalid photo ID', { status: 400 }); + } + + const { searchParams } = new URL(request.url); + const thumbnail = searchParams.get('thumbnail') === 'true'; + const watermark = searchParams.get('watermark') === 'true'; + + // Fetch photo from database + const photo = await prisma.photo.findUnique({ + where: { id: photoId }, + select: { path: true, filename: true, media_type: true }, + }); + + if (!photo?.path) { + return new NextResponse('Photo not found', { status: 404 }); + } + + const filePath = photo.path; + const mediaType = photo.media_type || 'image'; + + // Check if file exists + if (!existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + return new NextResponse('File not found on server', { status: 404 }); + } + + // Handle video thumbnail request + if (thumbnail && mediaType === 'video') { + // Check if video path is a URL (can't generate thumbnail for remote videos) + const isVideoUrl = filePath.startsWith('http://') || filePath.startsWith('https://'); + + if (isVideoUrl) { + // For remote videos, we can't generate thumbnails locally + // Return a placeholder + console.warn(`Cannot generate thumbnail for remote video URL: ${filePath}`); + try { + if (!sharp) throw new Error('Sharp not available'); + const placeholderSvg = ` + + + + + + `.trim(); + + const placeholderBuffer = await sharp(Buffer.from(placeholderSvg)) + .png() + .toBuffer(); + + return new NextResponse(placeholderBuffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=60', + }, + }); + } catch (error) { + console.error('Error generating placeholder for remote video:', error); + const minimalPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + return new NextResponse(minimalPng as unknown as BodyInit, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=60', + }, + }); + } + } + + try { + const thumbnailBuffer = await getVideoThumbnail(filePath); + if (thumbnailBuffer && thumbnailBuffer.length > 0) { + return new NextResponse(thumbnailBuffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'image/jpeg', + 'Cache-Control': 'public, max-age=31536000, immutable', + }, + }); + } + console.error(`Thumbnail generation returned empty buffer for video: ${filePath}`); + } catch (error) { + console.error(`Error generating thumbnail for video ${filePath}:`, error); + } + + // Fallback: return a placeholder image (gray box with play icon) + // Generate a PNG placeholder using sharp for better compatibility + try { + if (!sharp) throw new Error('Sharp not available'); + const placeholderSvg = ` + + + + + + `.trim(); + + const placeholderBuffer = await sharp(Buffer.from(placeholderSvg)) + .png() + .toBuffer(); + + return new NextResponse(placeholderBuffer as unknown as BodyInit, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=60', // Short cache for placeholder + }, + }); + } catch (error) { + console.error('Error generating placeholder:', error); + // Ultimate fallback: return a minimal 1x1 PNG + const minimalPng = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + return new NextResponse(minimalPng as unknown as BodyInit, { + headers: { + 'Content-Type': 'image/png', + 'Cache-Control': 'public, max-age=60', + }, + }); + } + } + + // Handle video streaming + if (mediaType === 'video') { + const fileStats = statSync(filePath); + const fileSize = fileStats.size; + const range = request.headers.get('range'); + + const contentType = getContentType(filePath, mediaType); + + // Handle range requests for video seeking + if (range) { + const rangeData = parseRange(range, fileSize); + if (!rangeData) { + return new NextResponse('Range Not Satisfiable', { + status: 416, + headers: { + 'Content-Range': `bytes */${fileSize}`, + }, + }); + } + + const { start, end } = rangeData; + const chunkSize = end - start + 1; + + const fileBuffer = await readFile(filePath); + const chunk = fileBuffer.slice(start, end + 1); + + return new NextResponse(chunk, { + status: 206, + headers: { + 'Content-Range': `bytes ${start}-${end}/${fileSize}`, + 'Accept-Ranges': 'bytes', + 'Content-Length': chunkSize.toString(), + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=3600', + }, + }); + } + + // Full video file (no range request) + const videoBuffer = await readFile(filePath); + return new NextResponse(videoBuffer, { + headers: { + 'Content-Type': contentType, + 'Content-Length': fileSize.toString(), + 'Accept-Ranges': 'bytes', + 'Cache-Control': 'public, max-age=3600', + }, + }); + } + + // Handle images (existing logic) + const imageBuffer = await readFile(filePath); + const contentType = getContentType(filePath, mediaType); + + const createOriginalResponse = (cacheControl = 'public, max-age=31536000, immutable') => + new NextResponse(imageBuffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': cacheControl, + }, + }); + + if (!watermark) { + return createOriginalResponse(); + } + + // If sharp is not available, return original image without watermark + if (!sharp) { + console.warn('Sharp library not available, returning original image without watermark'); + return createOriginalResponse('public, max-age=60'); + } + + try { + const metadata = await sharp(imageBuffer).metadata(); + const baseWidth = metadata.width ?? 2000; + const baseHeight = metadata.height ?? 2000; + const overlayBuffer = await getWatermarkOverlay(baseWidth, baseHeight); + const ext = path.extname(filePath).toLowerCase(); + const outputFormat = + ext === '.png' ? 'png' : + ext === '.webp' ? 'webp' : + 'jpeg'; + const pipeline = sharp(imageBuffer) + .composite([ + { + input: overlayBuffer, + gravity: 'center', + blend: 'hard-light', + }, + ]) + .toColorspace('srgb'); + + const result = await pipeline + .toFormat( + outputFormat, + outputFormat === 'png' + ? { compressionLevel: 5 } + : outputFormat === 'webp' + ? { quality: 90 } + : { quality: 90 } + ) + .toBuffer(); + + const responseContentType = + outputFormat === 'png' + ? 'image/png' + : outputFormat === 'webp' + ? 'image/webp' + : 'image/jpeg'; + + return new NextResponse(result as unknown as BodyInit, { + headers: { + 'Content-Type': responseContentType, + 'Cache-Control': 'public, max-age=3600', + }, + }); + } catch (error) { + console.error('Error applying watermark:', error); + return createOriginalResponse('public, max-age=60'); + } + } catch (error) { + console.error('Error serving media:', error); + return new NextResponse('Internal server error', { status: 500 }); + } +} + diff --git a/viewer-frontend/app/api/photos/[id]/report/route.ts b/viewer-frontend/app/api/photos/[id]/report/route.ts new file mode 100644 index 0000000..e9171e7 --- /dev/null +++ b/viewer-frontend/app/api/photos/[id]/report/route.ts @@ -0,0 +1,426 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma, prismaAuth } from '@/lib/db'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; + +// In-memory cache for report statuses +// Key: `${userId}:${photoId}`, Value: { data, expiresAt } +interface CacheEntry { + data: { + reported: boolean; + status?: string; + reportedAt?: Date; + }; + expiresAt: number; +} + +const reportStatusCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes +const MAX_COMMENT_LENGTH = 300; + +// Helper function to get cache key +function getCacheKey(userId: number, photoId: number): string { + return `${userId}:${photoId}`; +} + +// Helper function to get cached report status +function getCachedStatus(userId: number, photoId: number): CacheEntry['data'] | null { + const key = getCacheKey(userId, photoId); + const entry = reportStatusCache.get(key); + + if (!entry) { + return null; + } + + // Check if cache entry has expired + if (Date.now() > entry.expiresAt) { + reportStatusCache.delete(key); + return null; + } + + return entry.data; +} + +// Helper function to set cache +function setCachedStatus( + userId: number, + photoId: number, + data: CacheEntry['data'] +): void { + const key = getCacheKey(userId, photoId); + reportStatusCache.set(key, { + data, + expiresAt: Date.now() + CACHE_TTL, + }); +} + +// Helper function to invalidate cache for a specific user/photo combination +function invalidateCache(userId: number, photoId: number): void { + const key = getCacheKey(userId, photoId); + reportStatusCache.delete(key); +} + +// Helper function to fetch report status from database +async function fetchReportStatusFromDB( + userId: number, + photoId: number +): Promise { + const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({ + where: { + uq_photo_user_report: { + photoId, + userId, + }, + }, + select: { + id: true, + status: true, + reportedAt: true, + }, + }); + + const result: CacheEntry['data'] = existingReport + ? { + reported: true, + status: existingReport.status, + reportedAt: existingReport.reportedAt, + } + : { + reported: false, + }; + + // Cache the result + setCachedStatus(userId, photoId, result); + + return result; +} + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Check authentication + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required. Please sign in to report photos.' }, + { status: 401 } + ); + } + + const { id } = await params; + const photoId = parseInt(id, 10); + + if (isNaN(photoId)) { + return NextResponse.json( + { error: 'Invalid photo ID' }, + { status: 400 } + ); + } + + // Verify photo exists + const photo = await prisma.photo.findUnique({ + where: { id: photoId }, + select: { id: true, filename: true }, + }); + + if (!photo) { + return NextResponse.json( + { error: 'Photo not found' }, + { status: 404 } + ); + } + + const userId = parseInt(session.user.id, 10); + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user ID' }, + { status: 400 } + ); + } + + // Verify user exists in auth database before proceeding + const user = await prismaAuth.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + + if (!user) { + console.error(`[REPORT] User ${userId} not found in auth database`); + return NextResponse.json( + { error: 'User account not found. Please sign in again.' }, + { status: 404 } + ); + } + + // Parse optional comment payload + let reportComment: string | null = null; + try { + const body = await request.json(); + if (body && typeof body === 'object' && !Array.isArray(body)) { + if (body.comment !== undefined && typeof body.comment !== 'string' && body.comment !== null) { + return NextResponse.json( + { error: 'Comment must be a string' }, + { status: 400 } + ); + } + if (typeof body.comment === 'string') { + const trimmed = body.comment.trim(); + if (trimmed.length > MAX_COMMENT_LENGTH) { + return NextResponse.json( + { error: `Comment must be ${MAX_COMMENT_LENGTH} characters or less` }, + { status: 400 } + ); + } + reportComment = trimmed.length > 0 ? trimmed : null; + } + } + } catch (error: any) { + // If the request body is empty or invalid JSON, treat as no comment + if (error.type !== 'invalid-json') { + console.warn('Invalid report payload received', error); + } + } + + // Check if user has already reported this photo (get all info in one query) + const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({ + where: { + uq_photo_user_report: { + photoId, + userId, + }, + }, + }); + + let report; + + if (existingReport) { + // If report exists and is still pending, return error + if (existingReport.status === 'pending') { + return NextResponse.json( + { error: 'You have already reported this photo' }, + { status: 409 } + ); + } + + // If report was dismissed, cannot re-report + if (existingReport.status === 'dismissed') { + return NextResponse.json( + { error: 'Cannot re-report a dismissed report' }, + { status: 403 } + ); + } + + // If report was reviewed, update it back to pending (re-report) + // Keep reviewNotes but clear reviewedAt and reviewedBy + if (existingReport.status === 'reviewed') { + report = await prismaAuth.inappropriatePhotoReport.update({ + where: { + id: existingReport.id, + }, + data: { + status: 'pending', + reviewedAt: null, + reviewedBy: null, + reportComment, + // Keep reviewNotes - don't clear them + }, + }); + // Invalidate cache after update + invalidateCache(userId, photoId); + } + } else { + // Create new report + report = await prismaAuth.inappropriatePhotoReport.create({ + data: { + photoId, + userId, + status: 'pending', + reportComment, + }, + }); + // Invalidate cache after creation + invalidateCache(userId, photoId); + } + + if (!report) { + return NextResponse.json( + { error: 'Failed to create report' }, + { status: 500 } + ); + } + + return NextResponse.json( + { + message: 'Photo reported successfully', + report: { + id: report.id, + photoId: report.photoId, + status: report.status, + reportedAt: report.reportedAt, + reportComment: report.reportComment, + }, + }, + { status: 201 } + ); + } catch (error: any) { + console.error('Error reporting photo:', error); + + // Handle unique constraint violation + if (error.code === 'P2002') { + return NextResponse.json( + { error: 'You have already reported this photo' }, + { status: 409 } + ); + } + + // Handle foreign key constraint violation + if (error.code === 'P2003') { + console.error('[REPORT] Foreign key constraint violation:', error.meta); + return NextResponse.json( + { error: 'User account not found. Please sign in again.' }, + { status: 404 } + ); + } + + return NextResponse.json( + { error: 'Failed to report photo', details: error.message }, + { status: 500 } + ); + } +} + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Check authentication + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + const { id } = await params; + const photoId = parseInt(id, 10); + + if (isNaN(photoId)) { + return NextResponse.json( + { error: 'Invalid photo ID' }, + { status: 400 } + ); + } + + const userId = parseInt(session.user.id, 10); + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user ID' }, + { status: 400 } + ); + } + + // Check cache first + const cachedStatus = getCachedStatus(userId, photoId); + if (cachedStatus !== null) { + return NextResponse.json(cachedStatus); + } + + // Cache miss - fetch from database (this will also cache the result) + const reportStatus = await fetchReportStatusFromDB(userId, photoId); + + return NextResponse.json(reportStatus); + } catch (error: any) { + console.error('Error checking report status:', error); + return NextResponse.json( + { error: 'Failed to check report status', details: error.message }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + // Check authentication + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required. Please sign in to undo reports.' }, + { status: 401 } + ); + } + + const { id } = await params; + const photoId = parseInt(id, 10); + + if (isNaN(photoId)) { + return NextResponse.json( + { error: 'Invalid photo ID' }, + { status: 400 } + ); + } + + const userId = parseInt(session.user.id, 10); + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user ID' }, + { status: 400 } + ); + } + + // Find the report + const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({ + where: { + uq_photo_user_report: { + photoId, + userId, + }, + }, + }); + + if (!existingReport) { + return NextResponse.json( + { error: 'Report not found' }, + { status: 404 } + ); + } + + // Only allow undo if status is still pending + if (existingReport.status !== 'pending') { + return NextResponse.json( + { error: 'Cannot undo report that has already been reviewed' }, + { status: 403 } + ); + } + + // Delete the report + await prismaAuth.inappropriatePhotoReport.delete({ + where: { + id: existingReport.id, + }, + }); + + // Invalidate cache after deletion + invalidateCache(userId, photoId); + + return NextResponse.json( + { + message: 'Report undone successfully', + }, + { status: 200 } + ); + } catch (error: any) { + console.error('Error undoing report:', error); + + return NextResponse.json( + { error: 'Failed to undo report', details: error.message }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/photos/[id]/route.ts b/viewer-frontend/app/api/photos/[id]/route.ts new file mode 100644 index 0000000..a386bd7 --- /dev/null +++ b/viewer-frontend/app/api/photos/[id]/route.ts @@ -0,0 +1,51 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/lib/db'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const photoId = parseInt(id, 10); + + if (isNaN(photoId)) { + return NextResponse.json( + { error: 'Invalid photo ID' }, + { status: 400 } + ); + } + + const photo = await prisma.photo.findUnique({ + where: { id: photoId }, + include: { + Face: { + include: { + Person: true, + }, + }, + PhotoTagLinkage: { + include: { + Tag: true, + }, + }, + }, + }); + + if (!photo) { + return NextResponse.json( + { error: 'Photo not found' }, + { status: 404 } + ); + } + + return NextResponse.json(photo); + } catch (error) { + console.error('Error fetching photo:', error); + return NextResponse.json( + { error: 'Failed to fetch photo' }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/photos/favorites/batch/route.ts b/viewer-frontend/app/api/photos/favorites/batch/route.ts new file mode 100644 index 0000000..059d4cc --- /dev/null +++ b/viewer-frontend/app/api/photos/favorites/batch/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prismaAuth } from '@/lib/db'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ results: {} }); + } + + const userId = parseInt(session.user.id, 10); + if (isNaN(userId)) { + return NextResponse.json({ results: {} }); + } + + const { photoIds } = await request.json(); + + if (!Array.isArray(photoIds) || photoIds.length === 0) { + return NextResponse.json({ results: {} }); + } + + // Fetch all favorites for this user and these photos + const favorites = await prismaAuth.photoFavorite.findMany({ + where: { + userId, + photoId: { in: photoIds }, + }, + select: { + photoId: true, + }, + }); + + // Build result map + const favoriteSet = new Set(favorites.map(f => f.photoId)); + const results: Record = {}; + + photoIds.forEach((photoId: number) => { + results[photoId.toString()] = favoriteSet.has(photoId); + }); + + return NextResponse.json({ results }); + } catch (error: any) { + // Handle case where table doesn't exist yet (P2021 = table does not exist) + if (error.code === 'P2021') { + console.warn('photo_favorites table does not exist yet. Run migration: migrations/add-photo-favorites-table.sql'); + return NextResponse.json({ results: {} }); + } + console.error('Error fetching batch favorites:', error); + return NextResponse.json({ results: {} }); + } +} + diff --git a/viewer-frontend/app/api/photos/favorites/bulk/route.ts b/viewer-frontend/app/api/photos/favorites/bulk/route.ts new file mode 100644 index 0000000..62124c8 --- /dev/null +++ b/viewer-frontend/app/api/photos/favorites/bulk/route.ts @@ -0,0 +1,180 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma, prismaAuth } from '@/lib/db'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; + +// POST - Bulk toggle favorites (add if not exists, remove if exists) +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required. Please sign in to favorite photos.' }, + { status: 401 } + ); + } + + const { photoIds, action } = await request.json(); + const userId = parseInt(session.user.id, 10); + + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user ID' }, + { status: 400 } + ); + } + + if (!Array.isArray(photoIds) || photoIds.length === 0) { + return NextResponse.json( + { error: 'Photo IDs array is required' }, + { status: 400 } + ); + } + + // Validate action + if (action && action !== 'add' && action !== 'remove' && action !== 'toggle') { + return NextResponse.json( + { error: 'Action must be "add", "remove", or "toggle"' }, + { status: 400 } + ); + } + + const validPhotoIds = photoIds + .map((id: any) => parseInt(id, 10)) + .filter((id: number) => !isNaN(id)); + + if (validPhotoIds.length === 0) { + return NextResponse.json( + { error: 'No valid photo IDs provided' }, + { status: 400 } + ); + } + + // Verify user exists in auth database + const user = await prismaAuth.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }); + + if (!user) { + return NextResponse.json( + { error: 'User account not found. Please sign in again.' }, + { status: 404 } + ); + } + + // Verify photos exist in main database + const existingPhotos = await prisma.photo.findMany({ + where: { id: { in: validPhotoIds } }, + select: { id: true }, + }); + + const existingPhotoIds = new Set(existingPhotos.map(p => p.id)); + const validIds = validPhotoIds.filter((id: number) => existingPhotoIds.has(id)); + + if (validIds.length === 0) { + return NextResponse.json( + { error: 'No valid photos found' }, + { status: 404 } + ); + } + + // Get current favorites for these photos + const currentFavorites = await prismaAuth.photoFavorite.findMany({ + where: { + userId, + photoId: { in: validIds }, + }, + select: { + photoId: true, + id: true, + }, + }); + + const favoritedPhotoIds = new Set(currentFavorites.map(f => f.photoId)); + const favoriteIdsToDelete = new Set(currentFavorites.map(f => f.id)); + + let added = 0; + let removed = 0; + + if (action === 'add') { + // Only add favorites that don't exist + const toAdd = validIds.filter(id => !favoritedPhotoIds.has(id)); + + if (toAdd.length > 0) { + await prismaAuth.photoFavorite.createMany({ + data: toAdd.map(photoId => ({ + photoId, + userId, + favoritedAt: new Date(), + })), + skipDuplicates: true, + }); + added = toAdd.length; + } + } else if (action === 'remove') { + // Only remove favorites that exist + if (favoriteIdsToDelete.size > 0) { + await prismaAuth.photoFavorite.deleteMany({ + where: { + id: { in: Array.from(favoriteIdsToDelete) }, + }, + }); + removed = favoriteIdsToDelete.size; + } + } else { + // Toggle: add if not favorited, remove if favorited + const toAdd = validIds.filter(id => !favoritedPhotoIds.has(id)); + const toRemove = Array.from(favoriteIdsToDelete); + + if (toAdd.length > 0) { + await prismaAuth.photoFavorite.createMany({ + data: toAdd.map(photoId => ({ + photoId, + userId, + favoritedAt: new Date(), + })), + skipDuplicates: true, + }); + added = toAdd.length; + } + + if (toRemove.length > 0) { + await prismaAuth.photoFavorite.deleteMany({ + where: { + id: { in: toRemove }, + }, + }); + removed = toRemove.length; + } + } + + return NextResponse.json({ + success: true, + added, + removed, + total: validIds.length, + message: `Favorites updated: ${added} added, ${removed} removed`, + }); + } catch (error: any) { + console.error('Error bulk toggling favorites:', error); + + // Handle case where table doesn't exist yet (P2021 = table does not exist) + if (error.code === 'P2021') { + return NextResponse.json( + { error: 'Favorites feature is not available yet. Please run the migration: migrations/add-photo-favorites-table.sql' }, + { status: 503 } + ); + } + + return NextResponse.json( + { error: 'Failed to update favorites', details: error.message }, + { status: 500 } + ); + } +} + + + + + + diff --git a/viewer-frontend/app/api/photos/reports/batch/route.ts b/viewer-frontend/app/api/photos/reports/batch/route.ts new file mode 100644 index 0000000..bdc3645 --- /dev/null +++ b/viewer-frontend/app/api/photos/reports/batch/route.ts @@ -0,0 +1,206 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; +import { prismaAuth } from '@/lib/db'; + +// In-memory cache for report statuses +// Key: `${userId}:${photoId}`, Value: { data, expiresAt } +interface CacheEntry { + data: { + reported: boolean; + status?: string; + reportedAt?: Date; + }; + expiresAt: number; +} + +const reportStatusCache = new Map(); +const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + +// Helper function to get cache key +function getCacheKey(userId: number, photoId: number): string { + return `${userId}:${photoId}`; +} + +// Helper function to get cached report status +function getCachedStatus(userId: number, photoId: number): CacheEntry['data'] | null { + const key = getCacheKey(userId, photoId); + const entry = reportStatusCache.get(key); + + if (!entry) { + return null; + } + + // Check if cache entry has expired + if (Date.now() > entry.expiresAt) { + reportStatusCache.delete(key); + return null; + } + + return entry.data; +} + +// Helper function to set cache +function setCachedStatus( + userId: number, + photoId: number, + data: CacheEntry['data'] +): void { + const key = getCacheKey(userId, photoId); + reportStatusCache.set(key, { + data, + expiresAt: Date.now() + CACHE_TTL, + }); +} + +// Helper function to fetch report status from database +async function fetchReportStatusFromDB( + userId: number, + photoId: number +): Promise { + const existingReport = await prismaAuth.inappropriatePhotoReport.findUnique({ + where: { + uq_photo_user_report: { + photoId, + userId, + }, + }, + select: { + id: true, + status: true, + reportedAt: true, + }, + }); + + const result: CacheEntry['data'] = existingReport + ? { + reported: true, + status: existingReport.status, + reportedAt: existingReport.reportedAt, + } + : { + reported: false, + }; + + // Cache the result + setCachedStatus(userId, photoId, result); + + return result; +} + +export async function POST(request: NextRequest) { + try { + // Check authentication + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required' }, + { status: 401 } + ); + } + + const userId = parseInt(session.user.id, 10); + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user ID' }, + { status: 400 } + ); + } + + // Parse request body + const body = await request.json(); + const { photoIds } = body; + + if (!Array.isArray(photoIds) || photoIds.length === 0) { + return NextResponse.json( + { error: 'photoIds must be a non-empty array' }, + { status: 400 } + ); + } + + // Validate photo IDs are numbers + const validPhotoIds = photoIds + .map((id) => parseInt(String(id), 10)) + .filter((id) => !isNaN(id) && id > 0); + + if (validPhotoIds.length === 0) { + return NextResponse.json( + { error: 'No valid photo IDs provided' }, + { status: 400 } + ); + } + + // Limit batch size to prevent abuse + const MAX_BATCH_SIZE = 100; + const photoIdsToCheck = validPhotoIds.slice(0, MAX_BATCH_SIZE); + + // Check cache first for all photos + const results: Record = {}; + const uncachedPhotoIds: number[] = []; + + for (const photoId of photoIdsToCheck) { + const cached = getCachedStatus(userId, photoId); + if (cached !== null) { + results[photoId] = cached; + } else { + uncachedPhotoIds.push(photoId); + } + } + + // Fetch uncached report statuses from database in a single query + if (uncachedPhotoIds.length > 0) { + const reports = await prismaAuth.inappropriatePhotoReport.findMany({ + where: { + userId, + photoId: { in: uncachedPhotoIds }, + }, + select: { + photoId: true, + status: true, + reportedAt: true, + }, + }); + + // Create a map of photoId -> report status + const reportMap = new Map(); + for (const report of reports) { + reportMap.set(report.photoId, { + status: report.status, + reportedAt: report.reportedAt, + }); + } + + // Build results for uncached photos + for (const photoId of uncachedPhotoIds) { + const report = reportMap.get(photoId); + const result: CacheEntry['data'] = report + ? { + reported: true, + status: report.status, + reportedAt: report.reportedAt, + } + : { + reported: false, + }; + + results[photoId] = result; + // Cache the result + setCachedStatus(userId, photoId, result); + } + } + + return NextResponse.json({ results }); + } catch (error: any) { + console.error('Error fetching batch report statuses:', error); + return NextResponse.json( + { error: 'Failed to fetch report statuses', details: error.message }, + { status: 500 } + ); + } +} + + + + + + + diff --git a/viewer-frontend/app/api/photos/tag-linkages/route.ts b/viewer-frontend/app/api/photos/tag-linkages/route.ts new file mode 100644 index 0000000..dfad9d3 --- /dev/null +++ b/viewer-frontend/app/api/photos/tag-linkages/route.ts @@ -0,0 +1,279 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma, prismaAuth } from '@/lib/db'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; + +const MAX_TAG_NAME_LENGTH = 100; +const MAX_NOTES_LENGTH = 500; +const MAX_CUSTOM_TAGS = 10; + +interface TagLinkagePayload { + photoIds?: number[]; + tagIds?: number[]; + newTagName?: string; + newTagNames?: string[]; + notes?: string; +} + +function parseIds(values: unknown, label: string) { + if (!Array.isArray(values)) { + throw new Error(`${label} must be an array`); + } + + const parsed = Array.from( + new Set( + values.map((value) => { + const num = Number(value); + if (!Number.isInteger(num) || num <= 0) { + throw new Error(`${label} must contain positive integers`); + } + return num; + }) + ) + ); + + if (parsed.length === 0) { + throw new Error(`${label} cannot be empty`); + } + + return parsed; +} + +export async function POST(request: NextRequest) { + try { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required. Please sign in to tag photos.' }, + { status: 401 } + ); + } + + const userId = parseInt(session.user.id, 10); + if (!Number.isInteger(userId)) { + return NextResponse.json( + { error: 'Invalid user session' }, + { status: 401 } + ); + } + + let payload: TagLinkagePayload; + try { + payload = await request.json(); + } catch (error: any) { + if (error?.type === 'invalid-json') { + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }); + } + throw error; + } + + if (!payload || typeof payload !== 'object') { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }); + } + + if (!payload.photoIds) { + return NextResponse.json({ error: 'photoIds are required' }, { status: 400 }); + } + + const photoIds = parseIds(payload.photoIds, 'photoIds'); + const tagIds = + Array.isArray(payload.tagIds) && payload.tagIds.length > 0 + ? parseIds(payload.tagIds, 'tagIds') + : []; + + const incomingNewNames: string[] = []; + if (Array.isArray(payload.newTagNames)) { + incomingNewNames.push(...payload.newTagNames); + } + if (typeof payload.newTagName === 'string') { + incomingNewNames.push(payload.newTagName); + } + + const newNameMap = new Map(); + for (const rawName of incomingNewNames) { + if (typeof rawName !== 'string') continue; + const trimmed = rawName.trim().replace(/\s+/g, ' '); + if (trimmed.length === 0) continue; + if (trimmed.length > MAX_TAG_NAME_LENGTH) { + return NextResponse.json( + { error: `Tag name must be ${MAX_TAG_NAME_LENGTH} characters or less` }, + { status: 400 } + ); + } + const key = trimmed.toLowerCase(); + if (!newNameMap.has(key)) { + newNameMap.set(key, trimmed); + } + } + const normalizedNewNames = Array.from(newNameMap.values()); + + if (normalizedNewNames.length > MAX_CUSTOM_TAGS) { + return NextResponse.json( + { error: `You can submit up to ${MAX_CUSTOM_TAGS} new tag names at a time` }, + { status: 400 } + ); + } + + if (tagIds.length === 0 && normalizedNewNames.length === 0) { + return NextResponse.json( + { error: 'Select at least one existing tag or provide a new tag name' }, + { status: 400 } + ); + } + + let notes: string | null = null; + if (typeof payload.notes === 'string') { + notes = payload.notes.trim(); + if (notes.length > MAX_NOTES_LENGTH) { + return NextResponse.json( + { error: `Notes must be ${MAX_NOTES_LENGTH} characters or less` }, + { status: 400 } + ); + } + if (notes.length === 0) { + notes = null; + } + } + + // Ensure all photos exist + const photos = await prisma.photo.findMany({ + where: { id: { in: photoIds } }, + select: { id: true }, + }); + + if (photos.length !== photoIds.length) { + return NextResponse.json( + { error: 'One or more photos were not found' }, + { status: 404 } + ); + } + + // Ensure all tag IDs exist + if (tagIds.length > 0) { + const tags = await prisma.tag.findMany({ + where: { id: { in: tagIds } }, + select: { id: true }, + }); + + if (tags.length !== tagIds.length) { + return NextResponse.json( + { error: 'One or more tags were not found' }, + { status: 404 } + ); + } + } + + // Skip combinations that are already applied + let appliedCombinationKeys = new Set(); + if (tagIds.length > 0) { + const applied = await prisma.photoTagLinkage.findMany({ + where: { + photo_id: { in: photoIds }, + tag_id: { in: tagIds }, + }, + select: { + photo_id: true, + tag_id: true, + }, + }); + + appliedCombinationKeys = new Set( + applied.map((link) => `${link.photo_id}:${link.tag_id}`) + ); + } + + // Skip combinations that are already pending for the same user + const pendingConditions: any[] = []; + if (tagIds.length > 0) { + pendingConditions.push({ + photo_id: { in: photoIds }, + tag_id: { in: tagIds }, + }); + } + normalizedNewNames.forEach((name) => { + pendingConditions.push({ + photo_id: { in: photoIds }, + tag_name: name, + }); + }); + + let pendingCombinationKeys = new Set(); + if (pendingConditions.length > 0) { + const existingPending = await prismaAuth.pendingLinkage.findMany({ + where: { + userId, + status: 'pending', + OR: pendingConditions, + }, + select: { + photoId: true, + tagId: true, + tagName: true, + }, + }); + + pendingCombinationKeys = new Set( + existingPending.map((item) => + item.tagId + ? `${item.photoId}:${item.tagId}` + : `${item.photoId}:name:${item.tagName?.toLowerCase()}` + ) + ); + } + + type PendingEntry = { photoId: number; tagId?: number; tagName?: string | null }; + const entries: PendingEntry[] = []; + + for (const photoId of photoIds) { + for (const tagId of tagIds) { + const key = `${photoId}:${tagId}`; + if (appliedCombinationKeys.has(key)) { + continue; + } + if (pendingCombinationKeys.has(key)) { + continue; + } + entries.push({ photoId, tagId }); + } + + for (const tagName of normalizedNewNames) { + const key = `${photoId}:name:${tagName.toLowerCase()}`; + if (!pendingCombinationKeys.has(key)) { + entries.push({ photoId, tagName }); + } + } + } + + if (entries.length === 0) { + return NextResponse.json( + { error: 'Selected tag linkages are already applied or pending' }, + { status: 409 } + ); + } + + await prismaAuth.pendingLinkage.createMany({ + data: entries.map((entry) => ({ + photoId: entry.photoId, + tagId: entry.tagId ?? null, + tagName: entry.tagName ?? null, + userId, + status: 'pending', + notes, + })), + }); + + return NextResponse.json({ + message: 'Tags were submitted and are pending approval', + pendingCount: entries.length, + }); + } catch (error: any) { + console.error('Error submitting tag linkages:', error); + if (error.message?.includes('must')) { + return NextResponse.json({ error: error.message }, { status: 400 }); + } + return NextResponse.json( + { error: 'Failed to submit tag linkages', details: error.message }, + { status: 500 } + ); + } +} + diff --git a/viewer-frontend/app/api/photos/upload/route.ts b/viewer-frontend/app/api/photos/upload/route.ts new file mode 100644 index 0000000..5b1def2 --- /dev/null +++ b/viewer-frontend/app/api/photos/upload/route.ts @@ -0,0 +1,260 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@/app/api/auth/[...nextauth]/route'; +import { prismaAuth } from '@/lib/db'; +import { writeFile, mkdir, access } from 'fs/promises'; +import { existsSync } from 'fs'; +import { constants } from 'fs'; +import path from 'path'; + +// Maximum file size: 50MB for images, 500MB for videos +const MAX_IMAGE_SIZE = 50 * 1024 * 1024; +const MAX_VIDEO_SIZE = 500 * 1024 * 1024; +// Allowed image types +const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; +// Allowed video types +const ALLOWED_VIDEO_TYPES = ['video/mp4', 'video/mpeg', 'video/quicktime', 'video/x-msvideo', 'video/webm']; +// Combined allowed types +const ALLOWED_TYPES = [...ALLOWED_IMAGE_TYPES, ...ALLOWED_VIDEO_TYPES]; + +// Get upload directory from environment variable +// REQUIRED: Must be set to a network share path accessible by both this app and the approval system +// Examples: +// - Linux: /mnt/shared/pending-photos +// - Windows: \\server\share\pending-photos (mapped to drive or use UNC path) +// - SMB/CIFS: /mnt/smb/photos/pending +const getUploadDir = (): string => { + const uploadDir = process.env.UPLOAD_DIR || process.env.PENDING_PHOTOS_DIR; + + if (!uploadDir) { + throw new Error( + 'UPLOAD_DIR or PENDING_PHOTOS_DIR environment variable must be set. ' + + 'This should point to a network share accessible by both the web server and approval system.' + ); + } + + // Use absolute path (network shares should already be absolute) + return path.resolve(uploadDir); +}; + +export async function POST(request: NextRequest) { + try { + // Check authentication + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json( + { error: 'Authentication required. Please sign in to upload files.' }, + { status: 401 } + ); + } + + const formData = await request.formData(); + const photos = formData.getAll('photos') as File[]; + + if (photos.length === 0) { + return NextResponse.json( + { error: 'No files provided' }, + { status: 400 } + ); + } + + // Validate files + const errors: string[] = []; + const validPhotos: Array<{ file: File; name: string; size: number; type: string }> = []; + + for (const photo of photos) { + // Check file type + if (!ALLOWED_TYPES.includes(photo.type)) { + errors.push(`${photo.name}: Invalid file type. Only images (JPEG, PNG, GIF, WebP) and videos (MP4, MOV, AVI, WebM) are allowed.`); + continue; + } + + // Check file size based on type + const isVideo = ALLOWED_VIDEO_TYPES.includes(photo.type); + const maxSize = isVideo ? MAX_VIDEO_SIZE : MAX_IMAGE_SIZE; + const maxSizeMB = isVideo ? 500 : 50; + + if (photo.size > maxSize) { + errors.push(`${photo.name}: File size exceeds ${maxSizeMB}MB limit.`); + continue; + } + + if (photo.size === 0) { + errors.push(`${photo.name}: File is empty.`); + continue; + } + + validPhotos.push({ + file: photo, + name: photo.name, + size: photo.size, + type: photo.type, + }); + } + + if (validPhotos.length === 0) { + return NextResponse.json( + { + error: 'No valid files to upload', + details: errors.length > 0 ? errors : ['No files provided'] + }, + { status: 400 } + ); + } + + const userId = parseInt(session.user.id, 10); + if (isNaN(userId)) { + return NextResponse.json( + { error: 'Invalid user ID' }, + { status: 400 } + ); + } + + // Get upload directory and ensure it exists + let uploadBaseDir: string; + try { + uploadBaseDir = getUploadDir(); + } catch (error: any) { + console.error('[PHOTO UPLOAD] Configuration error:', error.message); + return NextResponse.json( + { + error: 'Upload directory not configured', + details: error.message + '. Please set UPLOAD_DIR or PENDING_PHOTOS_DIR environment variable to a network share path.' + }, + { status: 500 } + ); + } + + const userUploadDir = path.join(uploadBaseDir, userId.toString()); + + // Check if parent directory (mount point) exists and is accessible + const parentDir = path.dirname(uploadBaseDir); + if (!existsSync(parentDir)) { + console.error(`[PHOTO UPLOAD] Parent directory (mount point) does not exist: ${parentDir}`); + return NextResponse.json( + { + error: 'Upload directory mount point not found', + details: `The mount point ${parentDir} does not exist. Please ensure the network share is mounted. See docs/NETWORK_SHARE_SETUP.md for setup instructions.` + }, + { status: 500 } + ); + } + + // Check if parent directory is accessible (not just exists) + try { + await access(parentDir, constants.R_OK | constants.W_OK); + } catch (accessError: any) { + console.error(`[PHOTO UPLOAD] Cannot access parent directory: ${parentDir}`, accessError); + return NextResponse.json( + { + error: 'Upload directory not accessible', + details: `Cannot access mount point ${parentDir}. Please check permissions and ensure the network share is properly mounted. Error: ${accessError.message}` + }, + { status: 500 } + ); + } + + // Create directories if they don't exist + // Note: Network shares should already exist, but we create user subdirectories + try { + if (!existsSync(uploadBaseDir)) { + console.warn(`[PHOTO UPLOAD] Upload directory does not exist, attempting to create: ${uploadBaseDir}`); + await mkdir(uploadBaseDir, { recursive: true }); + } + if (!existsSync(userUploadDir)) { + await mkdir(userUploadDir, { recursive: true }); + } + } catch (error: any) { + // Check if error is permission-related + if (error.code === 'EACCES' || error.errno === -13) { + console.error(`[PHOTO UPLOAD] Permission denied creating directory: ${uploadBaseDir}`, error); + return NextResponse.json( + { + error: 'Permission denied', + details: `Permission denied when creating ${uploadBaseDir}. The mount point ${parentDir} exists but the application does not have write permissions. Please check:\n1. The network share is mounted with correct permissions\n2. The web server user has write access to the mount point\n3. See docs/NETWORK_SHARE_SETUP.md for troubleshooting` + }, + { status: 500 } + ); + } + console.error(`[PHOTO UPLOAD] Failed to access/create upload directory: ${uploadBaseDir}`, error); + return NextResponse.json( + { + error: 'Failed to access upload directory', + details: `Cannot access network share at ${uploadBaseDir}. Please verify the path is correct and the server has read/write permissions. Error: ${error.message}` + }, + { status: 500 } + ); + } + + // Save files and create database records + const savedPhotos = []; + const saveErrors: string[] = []; + + for (const photo of validPhotos) { + try { + // Generate unique filename: timestamp-originalname + const timestamp = Date.now(); + const sanitizedOriginalName = photo.name.replace(/[^a-zA-Z0-9._-]/g, '_'); + const fileExtension = path.extname(photo.name); + const baseName = path.basename(sanitizedOriginalName, fileExtension); + const uniqueFilename = `${timestamp}-${baseName}${fileExtension}`; + const filePath = path.join(userUploadDir, uniqueFilename); + + // Read file buffer + const arrayBuffer = await photo.file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Write file to disk + await writeFile(filePath, buffer); + + // Create database record + const pendingPhoto = await prismaAuth.pendingPhoto.create({ + data: { + userId, + filename: uniqueFilename, + originalFilename: photo.name, + filePath: filePath, + fileSize: photo.size, + mimeType: photo.type, + status: 'pending', + }, + }); + + savedPhotos.push({ + id: pendingPhoto.id, + filename: photo.name, + size: photo.size, + }); + } catch (error: any) { + console.error(`Error saving photo ${photo.name}:`, error); + saveErrors.push(`${photo.name}: ${error.message || 'Failed to save file'}`); + } + } + + if (savedPhotos.length === 0) { + return NextResponse.json( + { + error: 'Failed to save any photos', + details: saveErrors.length > 0 ? saveErrors : ['Unknown error occurred'] + }, + { status: 500 } + ); + } + + console.log(`[FILE UPLOAD] User ${userId} (${session.user.email}) submitted ${savedPhotos.length} file(s) for review`); + + return NextResponse.json({ + message: `Successfully submitted ${savedPhotos.length} file(s) for admin review`, + photos: savedPhotos, + errors: errors.length > 0 || saveErrors.length > 0 + ? [...errors, ...saveErrors] + : undefined, + }); + } catch (error: any) { + console.error('Error uploading photos:', error); + return NextResponse.json( + { error: 'Failed to upload files', details: error.message }, + { status: 500 } + ); + } +} + -- 2.49.1