PunimTag Web Application - Major Feature Release #1
1
.gitignore
vendored
1
.gitignore
vendored
@ -57,7 +57,6 @@ Thumbs.db
|
||||
|
||||
.history/
|
||||
|
||||
photos/
|
||||
|
||||
# Photo files and large directories
|
||||
*.jpg
|
||||
|
||||
@ -477,9 +477,14 @@ def process_photo_faces(
|
||||
return 0, 0
|
||||
|
||||
# Load image for quality calculation
|
||||
# Use context manager to ensure image is closed properly to free memory
|
||||
image = Image.open(photo_path)
|
||||
image_np = np.array(image)
|
||||
image_width, image_height = image.size
|
||||
try:
|
||||
image_np = np.array(image)
|
||||
image_width, image_height = image.size
|
||||
finally:
|
||||
# Explicitly close image to free memory immediately
|
||||
image.close()
|
||||
|
||||
# Count total faces from DeepFace
|
||||
faces_detected = len(results)
|
||||
@ -736,8 +741,19 @@ def process_photo_faces(
|
||||
# If commit fails, rollback and log the error
|
||||
db.rollback()
|
||||
error_msg = str(commit_error)
|
||||
error_str_lower = error_msg.lower()
|
||||
|
||||
# Check if it's a connection/disconnection error
|
||||
is_connection_error = any(keyword in error_str_lower for keyword in [
|
||||
'connection', 'disconnect', 'timeout', 'closed', 'lost',
|
||||
'operationalerror', 'server closed', 'connection reset',
|
||||
'connection pool', 'connection refused'
|
||||
])
|
||||
|
||||
try:
|
||||
_print_with_stderr(f"[FaceService] Failed to commit {faces_stored} faces for {photo.filename}: {error_msg}")
|
||||
if is_connection_error:
|
||||
_print_with_stderr(f"[FaceService] ⚠️ Database connection error detected - session may need refresh")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
except (BrokenPipeError, OSError):
|
||||
@ -747,8 +763,7 @@ def process_photo_faces(
|
||||
# This ensures the return value accurately reflects what was actually saved
|
||||
faces_stored = 0
|
||||
|
||||
# Re-raise to be caught by outer exception handler in process_unprocessed_photos
|
||||
# This allows the batch to continue processing other photos
|
||||
# Re-raise with connection error flag so caller can refresh session
|
||||
raise Exception(f"Database commit failed for {photo.filename}: {error_msg}")
|
||||
|
||||
# Mark photo as processed after handling faces (desktop parity)
|
||||
@ -756,7 +771,18 @@ def process_photo_faces(
|
||||
photo.processed = True
|
||||
db.add(photo)
|
||||
db.commit()
|
||||
except Exception:
|
||||
except Exception as mark_error:
|
||||
# Log connection errors for debugging
|
||||
error_str = str(mark_error).lower()
|
||||
is_connection_error = any(keyword in error_str for keyword in [
|
||||
'connection', 'disconnect', 'timeout', 'closed', 'lost',
|
||||
'operationalerror', 'server closed', 'connection reset'
|
||||
])
|
||||
if is_connection_error:
|
||||
try:
|
||||
_print_with_stderr(f"[FaceService] ⚠️ Database connection error while marking photo as processed: {mark_error}")
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
db.rollback()
|
||||
|
||||
# Log summary
|
||||
@ -1259,6 +1285,26 @@ def process_unprocessed_photos(
|
||||
update_progress(0, total, f"Starting face detection on {total} photos...", 0, 0)
|
||||
|
||||
for idx, photo in enumerate(unprocessed_photos, 1):
|
||||
# Periodic database health check every 10 photos to catch connection issues early
|
||||
if idx > 1 and idx % 10 == 0:
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
db.execute(text("SELECT 1"))
|
||||
db.commit()
|
||||
except Exception as health_check_error:
|
||||
# Database connection is stale - this will be caught and handled below
|
||||
error_str = str(health_check_error).lower()
|
||||
is_connection_error = any(keyword in error_str for keyword in [
|
||||
'connection', 'disconnect', 'timeout', 'closed', 'lost',
|
||||
'operationalerror', 'server closed', 'connection reset'
|
||||
])
|
||||
if is_connection_error:
|
||||
try:
|
||||
print(f"[FaceService] ⚠️ Database health check failed at photo {idx}/{total}: {health_check_error}")
|
||||
print(f"[FaceService] Session may need refresh - will be handled by error handler")
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
|
||||
# Check for cancellation BEFORE starting each photo
|
||||
# This is the primary cancellation point - we stop before starting a new photo
|
||||
if check_cancelled():
|
||||
@ -1385,6 +1431,14 @@ def process_unprocessed_photos(
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
|
||||
# Check if it's a database connection error
|
||||
error_str = str(e).lower()
|
||||
is_db_connection_error = any(keyword in error_str for keyword in [
|
||||
'connection', 'disconnect', 'timeout', 'closed', 'lost',
|
||||
'operationalerror', 'database', 'server closed', 'connection reset',
|
||||
'connection pool', 'connection refused'
|
||||
])
|
||||
|
||||
# Refresh database session after error to ensure it's in a good state
|
||||
# This prevents session state issues from affecting subsequent photos
|
||||
# Note: process_photo_faces already does db.rollback(), but we ensure
|
||||
@ -1394,6 +1448,23 @@ def process_unprocessed_photos(
|
||||
db.rollback()
|
||||
# Expire the current photo object to clear any stale state
|
||||
db.expire(photo)
|
||||
|
||||
# If it's a connection error, try to refresh the session
|
||||
if is_db_connection_error:
|
||||
try:
|
||||
# Test if session is still alive
|
||||
from sqlalchemy import text
|
||||
db.execute(text("SELECT 1"))
|
||||
db.commit()
|
||||
except Exception:
|
||||
# Session is dead - need to get a new one from the caller
|
||||
# We can't create a new SessionLocal here, so we'll raise a special exception
|
||||
try:
|
||||
print(f"[FaceService] ⚠️ Database session is dead after connection error - caller should refresh session")
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
# Re-raise with a flag that indicates session needs refresh
|
||||
raise Exception(f"Database connection lost - session needs refresh: {str(e)}")
|
||||
except Exception as session_error:
|
||||
# If session refresh fails, log but don't fail the batch
|
||||
try:
|
||||
|
||||
@ -119,6 +119,34 @@ def process_faces_task(
|
||||
total_faces_detected = 0
|
||||
total_faces_stored = 0
|
||||
|
||||
def refresh_db_session():
|
||||
"""Refresh database session if it becomes stale or disconnected.
|
||||
|
||||
This prevents crashes when the database connection is lost during long-running
|
||||
processing tasks. Closes the old session and creates a new one.
|
||||
"""
|
||||
nonlocal db
|
||||
try:
|
||||
# Test if the session is still alive by executing a simple query
|
||||
from sqlalchemy import text
|
||||
db.execute(text("SELECT 1"))
|
||||
db.commit() # Ensure transaction is clean
|
||||
except Exception as e:
|
||||
# Session is stale or disconnected - create a new one
|
||||
try:
|
||||
print(f"[Task] Database session disconnected, refreshing... Error: {e}")
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
try:
|
||||
db.close()
|
||||
except Exception:
|
||||
pass
|
||||
db = SessionLocal()
|
||||
try:
|
||||
print(f"[Task] Database session refreshed")
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
|
||||
try:
|
||||
def update_progress(
|
||||
processed: int,
|
||||
@ -181,6 +209,9 @@ def process_faces_task(
|
||||
# Process faces
|
||||
# Wrap in try-except to ensure we preserve progress even if process_unprocessed_photos fails
|
||||
try:
|
||||
# Refresh session before starting processing to ensure it's healthy
|
||||
refresh_db_session()
|
||||
|
||||
photos_processed, total_faces_detected, total_faces_stored = (
|
||||
process_unprocessed_photos(
|
||||
db,
|
||||
@ -191,6 +222,27 @@ def process_faces_task(
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
# Check if it's a database connection error
|
||||
error_str = str(e).lower()
|
||||
is_db_error = any(keyword in error_str for keyword in [
|
||||
'connection', 'disconnect', 'timeout', 'closed', 'lost',
|
||||
'operationalerror', 'database', 'server closed', 'connection reset',
|
||||
'connection pool', 'connection refused', 'session needs refresh'
|
||||
])
|
||||
|
||||
if is_db_error:
|
||||
# Try to refresh the session - this helps if the error is recoverable
|
||||
# but we don't retry the entire batch to avoid reprocessing photos
|
||||
try:
|
||||
print(f"[Task] Database error detected, attempting to refresh session: {e}")
|
||||
refresh_db_session()
|
||||
print(f"[Task] Session refreshed - job will fail gracefully. Restart job to continue processing remaining photos.")
|
||||
except Exception as refresh_error:
|
||||
try:
|
||||
print(f"[Task] Failed to refresh database session: {refresh_error}")
|
||||
except (BrokenPipeError, OSError):
|
||||
pass
|
||||
|
||||
# If process_unprocessed_photos fails, preserve any progress made
|
||||
# and re-raise so the outer handler can log it properly
|
||||
try:
|
||||
|
||||
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