2026-03-08 18:11:26 -06:00

439 lines
13 KiB
TypeScript

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 <img src> which can't send headers).
*/
async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
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<string, string | undefined>;
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 };
}
);
}