439 lines
13 KiB
TypeScript
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 };
|
|
}
|
|
);
|
|
}
|