feat: Add photo management API endpoints for fetching, favoriting, reporting, and tagging photos
All checks were successful
CI / skip-ci-check (pull_request) Successful in 1m49s
CI / lint-and-type-check (pull_request) Successful in 2m28s
CI / python-lint (pull_request) Successful in 2m14s
CI / test-backend (pull_request) Successful in 4m7s
CI / build (pull_request) Successful in 4m58s
CI / secret-scanning (pull_request) Successful in 2m0s
CI / dependency-scan (pull_request) Successful in 1m54s
CI / sast-scan (pull_request) Successful in 3m6s
CI / workflow-summary (pull_request) Successful in 1m47s
All checks were successful
CI / skip-ci-check (pull_request) Successful in 1m49s
CI / lint-and-type-check (pull_request) Successful in 2m28s
CI / python-lint (pull_request) Successful in 2m14s
CI / test-backend (pull_request) Successful in 4m7s
CI / build (pull_request) Successful in 4m58s
CI / secret-scanning (pull_request) Successful in 2m0s
CI / dependency-scan (pull_request) Successful in 1m54s
CI / sast-scan (pull_request) Successful in 3m6s
CI / workflow-summary (pull_request) Successful in 1m47s
- 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.
This commit is contained in:
parent
f879e660a4
commit
dd8dd0808e
1
.gitignore
vendored
1
.gitignore
vendored
@ -57,7 +57,6 @@ Thumbs.db
|
||||
|
||||
.history/
|
||||
|
||||
photos/
|
||||
|
||||
# Photo files and large directories
|
||||
*.jpg
|
||||
|
||||
94
viewer-frontend/app/api/photos/[id]/adjacent/route.ts
Normal file
94
viewer-frontend/app/api/photos/[id]/adjacent/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
161
viewer-frontend/app/api/photos/[id]/favorite/route.ts
Normal file
161
viewer-frontend/app/api/photos/[id]/favorite/route.ts
Normal file
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
391
viewer-frontend/app/api/photos/[id]/image/route.ts
Normal file
391
viewer-frontend/app/api/photos/[id]/image/route.ts
Normal file
@ -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<string, Buffer>();
|
||||
|
||||
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 = `
|
||||
<svg width="${watermarkWidth}" height="${watermarkHeight}" viewBox="0 0 360 120" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="wmGradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#1a3c7c" stop-opacity="0.45"/>
|
||||
<stop offset="100%" stop-color="#0a1e3f" stop-opacity="0.45"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="360" height="120" fill="url(#wmGradient)" rx="20" ry="20" />
|
||||
<g fill="rgba(255,255,255,0.65)">
|
||||
<text x="30" y="82" font-family="'Inter','Segoe UI',sans-serif" font-size="72" font-weight="700">J</text>
|
||||
<text x="144" y="82" font-family="'Inter','Segoe UI',sans-serif" font-size="72" font-weight="700">M</text>
|
||||
<g transform="translate(80,20) scale(0.7)">
|
||||
<polygon points="60,0 80,35 120,35 90,57 100,95 60,75 20,95 30,57 0,35 40,35"
|
||||
fill="none" stroke="rgba(255,255,255,0.7)" stroke-width="12" stroke-linejoin="round" />
|
||||
</g>
|
||||
<text x="214" y="50" font-family="'Inter','Segoe UI',sans-serif" font-size="24" font-weight="600">Jewish</text>
|
||||
<text x="214" y="78" font-family="'Inter','Segoe UI',sans-serif" font-size="24" font-weight="600">and Modern</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
|
||||
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 = `
|
||||
<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="400" height="400" fill="#e5e7eb"/>
|
||||
<circle cx="200" cy="200" r="40" fill="#6b7280" opacity="0.8"/>
|
||||
<polygon points="190,185 190,215 215,200" fill="white"/>
|
||||
</svg>
|
||||
`.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 = `
|
||||
<svg width="400" height="400" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="400" height="400" fill="#e5e7eb"/>
|
||||
<circle cx="200" cy="200" r="40" fill="#6b7280" opacity="0.8"/>
|
||||
<polygon points="190,185 190,215 215,200" fill="white"/>
|
||||
</svg>
|
||||
`.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 });
|
||||
}
|
||||
}
|
||||
|
||||
426
viewer-frontend/app/api/photos/[id]/report/route.ts
Normal file
426
viewer-frontend/app/api/photos/[id]/report/route.ts
Normal file
@ -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<string, CacheEntry>();
|
||||
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<CacheEntry['data']> {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
51
viewer-frontend/app/api/photos/[id]/route.ts
Normal file
51
viewer-frontend/app/api/photos/[id]/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
53
viewer-frontend/app/api/photos/favorites/batch/route.ts
Normal file
53
viewer-frontend/app/api/photos/favorites/batch/route.ts
Normal file
@ -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<string, boolean> = {};
|
||||
|
||||
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: {} });
|
||||
}
|
||||
}
|
||||
|
||||
180
viewer-frontend/app/api/photos/favorites/bulk/route.ts
Normal file
180
viewer-frontend/app/api/photos/favorites/bulk/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
206
viewer-frontend/app/api/photos/reports/batch/route.ts
Normal file
206
viewer-frontend/app/api/photos/reports/batch/route.ts
Normal file
@ -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<string, CacheEntry>();
|
||||
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<CacheEntry['data']> {
|
||||
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<number, CacheEntry['data']> = {};
|
||||
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<number, { status: string; reportedAt: Date }>();
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
279
viewer-frontend/app/api/photos/tag-linkages/route.ts
Normal file
279
viewer-frontend/app/api/photos/tag-linkages/route.ts
Normal file
@ -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<string, string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
260
viewer-frontend/app/api/photos/upload/route.ts
Normal file
260
viewer-frontend/app/api/photos/upload/route.ts
Normal file
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user