import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import jwt from 'jsonwebtoken'; import { UserRole, UserStatus } from '@prisma/client'; import { prisma } from '../../../config/database'; import { env } from '../../../config/env'; import { requireAdminRole } from '../middleware/auth'; import { logger } from '../../../utils/logger'; import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles'; import { unlink } from 'fs/promises'; /** * Check if the request is from an authenticated admin user. * Supports JWT from Authorization header or ?token= query parameter * (needed for which can't send headers). */ async function isAdminRequest(request: FastifyRequest): Promise { try { let token: string | undefined; const authHeader = request.headers.authorization; if (authHeader?.startsWith('Bearer ')) { token = authHeader.substring(7); } else { const query = request.query as Record; token = query.token; } if (!token) return false; const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as { id: string; role: UserRole; roles?: UserRole[]; }; if (!hasAnyRole(payload, MEDIA_ROLES)) return false; const user = await prisma.user.findUnique({ where: { id: payload.id }, select: { status: true }, }); return user?.status === UserStatus.ACTIVE; } catch { return false; } } /** * Admin photo CRUD routes (prefix: /api/photos) */ interface PhotosQuery { limit?: string; offset?: string; search?: string; format?: string; orientation?: 'H' | 'V' | 'S'; producer?: string; albumId?: string; isPublished?: string; } interface PhotoUpdateBody { title?: string; description?: string; producer?: string; creator?: string; tags?: string[]; category?: string; accessLevel?: string; } interface BulkIdsBody { ids: number[]; } export async function photosRoutes(fastify: FastifyInstance) { // GET /api/photos - List photos (admin, paginated) fastify.get<{ Querystring: PhotosQuery }>( '/', { preHandler: requireAdminRole }, async (request, reply) => { const limit = Math.min(parseInt(request.query.limit || '48'), 200); const offset = parseInt(request.query.offset || '0'); const { search, format, orientation, producer, albumId, isPublished } = request.query; const where: any = {}; if (search) { where.OR = [ { title: { contains: search, mode: 'insensitive' } }, { originalFilename: { contains: search, mode: 'insensitive' } }, { producer: { contains: search, mode: 'insensitive' } }, ]; } if (format) where.format = format; if (orientation) where.orientation = orientation; if (producer) where.producer = producer; if (albumId) where.albumId = parseInt(albumId); if (isPublished !== undefined) where.isPublished = isPublished === 'true'; const [photos, total] = await Promise.all([ prisma.photo.findMany({ where, orderBy: { createdAt: 'desc' }, take: limit, skip: offset, include: { album: { select: { id: true, title: true } }, }, }), prisma.photo.count({ where }), ]); return { photos: photos.map(p => ({ ...p, fileSize: p.fileSize?.toString() ?? null, thumbnailUrl: p.thumbnailPath ? `/media/photos/${p.id}/thumbnail` : null, })), total, limit, offset, }; } ); // GET /api/photos/producers - Distinct producers list fastify.get( '/producers', { preHandler: requireAdminRole }, async () => { const results = await prisma.photo.findMany({ where: { producer: { not: null } }, select: { producer: true }, distinct: ['producer'], }); return results.map(r => r.producer).filter(Boolean); } ); // GET /api/photos/formats - Distinct formats list fastify.get( '/formats', { preHandler: requireAdminRole }, async () => { const results = await prisma.photo.findMany({ where: { format: { not: null } }, select: { format: true }, distinct: ['format'], }); return results.map(r => r.format).filter(Boolean); } ); // GET /api/photos/:id - Single photo detail fastify.get<{ Params: { id: string } }>( '/:id', { preHandler: requireAdminRole }, async (request, reply) => { const id = parseInt(request.params.id as string); const photo = await prisma.photo.findUnique({ where: { id }, include: { album: { select: { id: true, title: true } }, uploader: { select: { id: true, name: true, email: true } }, }, }); if (!photo) { return reply.code(404).send({ message: 'Photo not found' }); } return { ...photo, fileSize: photo.fileSize?.toString() ?? null, thumbnailUrl: photo.thumbnailPath ? `/media/photos/${photo.id}/thumbnail` : null, }; } ); // GET /api/photos/:id/thumbnail - Serve thumbnail image // Public endpoint with admin bypass for unpublished photos (matches video thumbnail pattern) fastify.get<{ Params: { id: string } }>( '/:id/thumbnail', async (request, reply) => { const id = parseInt(request.params.id as string); if (isNaN(id)) { return reply.code(400).send({ message: 'Invalid photo ID' }); } // Admin bypass: skip publication filter for authenticated admin users const admin = await isAdminRequest(request); const photo = await prisma.photo.findFirst({ where: admin ? { id } : { id, isPublished: true, isLocked: false }, select: { thumbnailPath: true }, }); if (!photo?.thumbnailPath) { return reply.code(404).send({ message: 'Thumbnail not found' }); } if (photo.thumbnailPath.includes('..')) { logger.warn(`Path traversal attempt detected: ${photo.thumbnailPath}`); return reply.code(403).send({ message: 'Access denied' }); } const { createReadStream } = await import('fs'); const { access } = await import('fs/promises'); try { await access(photo.thumbnailPath); } catch { return reply.code(404).send({ message: 'Thumbnail file not found' }); } reply.header('Content-Type', 'image/jpeg'); reply.header('Cache-Control', 'public, max-age=86400'); return reply.send(createReadStream(photo.thumbnailPath)); } ); // GET /api/photos/:id/image - Serve full image (admin, size: thumb/medium/large) fastify.get<{ Params: { id: string }; Querystring: { size?: string } }>( '/:id/image', { preHandler: requireAdminRole }, async (request, reply) => { const id = parseInt(request.params.id as string); const size = request.query.size || 'large'; const photo = await prisma.photo.findUnique({ where: { id }, select: { thumbnailPath: true, mediumPath: true, largePath: true, webpPath: true, path: true, format: true }, }); if (!photo) { return reply.code(404).send({ message: 'Photo not found' }); } // Pick variant based on size param let filePath: string | null = null; let contentType = 'image/jpeg'; switch (size) { case 'thumb': filePath = photo.thumbnailPath; break; case 'medium': filePath = photo.mediumPath; break; case 'large': default: filePath = photo.largePath || photo.mediumPath; break; } if (!filePath || filePath.includes('..')) { return reply.code(404).send({ message: 'Image variant not found' }); } const { createReadStream } = await import('fs'); const { access } = await import('fs/promises'); try { await access(filePath); } catch { return reply.code(404).send({ message: 'Image file not found' }); } reply.header('Content-Type', contentType); reply.header('Cache-Control', 'public, max-age=86400'); return reply.send(createReadStream(filePath)); } ); // PATCH /api/photos/:id - Update photo metadata fastify.patch<{ Params: { id: string }; Body: PhotoUpdateBody }>( '/:id', { preHandler: requireAdminRole }, async (request, reply) => { const id = parseInt(request.params.id as string); const { title, description, producer, creator, tags, category, accessLevel } = request.body; const photo = await prisma.photo.findUnique({ where: { id } }); if (!photo) { return reply.code(404).send({ message: 'Photo not found' }); } const updated = await prisma.photo.update({ where: { id }, data: { ...(title !== undefined && { title }), ...(description !== undefined && { description }), ...(producer !== undefined && { producer }), ...(creator !== undefined && { creator }), ...(tags !== undefined && { tags: tags as any }), ...(category !== undefined && { category }), ...(accessLevel !== undefined && { accessLevel }), }, }); return { ...updated, fileSize: updated.fileSize?.toString() ?? null }; } ); // DELETE /api/photos/:id - Delete photo + variant files fastify.delete<{ Params: { id: string } }>( '/:id', { preHandler: requireAdminRole }, async (request, reply) => { const id = parseInt(request.params.id as string); const photo = await prisma.photo.findUnique({ where: { id } }); if (!photo) { return reply.code(404).send({ message: 'Photo not found' }); } // Delete variant files const filesToDelete = [ photo.path, photo.thumbnailPath, photo.mediumPath, photo.largePath, photo.webpPath, ].filter(Boolean) as string[]; for (const filePath of filesToDelete) { try { await unlink(filePath); } catch { // File may already be gone } } // If this was a cover photo for an album, clear the cover if (photo.albumId) { await prisma.photoAlbum.updateMany({ where: { coverPhotoId: id }, data: { coverPhotoId: null }, }); } await prisma.photo.delete({ where: { id } }); return { message: 'Photo deleted' }; } ); // POST /api/photos/:id/publish fastify.post<{ Params: { id: string } }>( '/:id/publish', { preHandler: requireAdminRole }, async (request, reply) => { const id = parseInt(request.params.id as string); const photo = await prisma.photo.update({ where: { id }, data: { isPublished: true, publishedAt: new Date() }, }); return { ...photo, fileSize: photo.fileSize?.toString() ?? null }; } ); // POST /api/photos/:id/unpublish fastify.post<{ Params: { id: string } }>( '/:id/unpublish', { preHandler: requireAdminRole }, async (request, reply) => { const id = parseInt(request.params.id as string); const photo = await prisma.photo.update({ where: { id }, data: { isPublished: false }, }); return { ...photo, fileSize: photo.fileSize?.toString() ?? null }; } ); // POST /api/photos/bulk-publish fastify.post<{ Body: BulkIdsBody }>( '/bulk-publish', { preHandler: requireAdminRole }, async (request) => { const { ids } = request.body; const result = await prisma.photo.updateMany({ where: { id: { in: ids } }, data: { isPublished: true, publishedAt: new Date() }, }); return { updated: result.count }; } ); // POST /api/photos/bulk-unpublish fastify.post<{ Body: BulkIdsBody }>( '/bulk-unpublish', { preHandler: requireAdminRole }, async (request) => { const { ids } = request.body; const result = await prisma.photo.updateMany({ where: { id: { in: ids } }, data: { isPublished: false }, }); return { updated: result.count }; } ); // POST /api/photos/bulk-delete fastify.post<{ Body: BulkIdsBody }>( '/bulk-delete', { preHandler: requireAdminRole }, async (request) => { const { ids } = request.body; // Get file paths before deleting const photos = await prisma.photo.findMany({ where: { id: { in: ids } }, select: { path: true, thumbnailPath: true, mediumPath: true, largePath: true, webpPath: true }, }); // Delete files for (const photo of photos) { const paths = [photo.path, photo.thumbnailPath, photo.mediumPath, photo.largePath, photo.webpPath].filter(Boolean) as string[]; for (const p of paths) { try { await unlink(p); } catch { /* ignore */ } } } // Clear album covers referencing these photos await prisma.photoAlbum.updateMany({ where: { coverPhotoId: { in: ids } }, data: { coverPhotoId: null }, }); const result = await prisma.photo.deleteMany({ where: { id: { in: ids } } }); return { deleted: result.count }; } ); }