Add Document model upload + download routes (PDFs as first-class media)
Documents are a separate media type from Video/Photo because the Photo pipeline assumes raster images (sharp metadata, EXIF, variant generation). The new routes mirror the photo upload pattern but target the Document Prisma model and serve files with Content-Disposition: attachment so browsers download instead of inline-rendering. Tag-based categorization (e.g. 'volunteer-resource') lets the volunteer dashboard surface curated downloads alongside videos and photos. Admin Library page gets a Documents tab for upload/list/edit/delete with the same affordances as the existing photo and video tabs. Bunker Admin
This commit is contained in:
@@ -30,6 +30,8 @@ import { photoUploadRoutes } from './modules/media/routes/photo-upload.routes';
|
||||
import { photoAlbumsRoutes } from './modules/media/routes/photo-albums.routes';
|
||||
import { photosPublicRoutes } from './modules/media/routes/photos-public.routes';
|
||||
import { photoEngagementRoutes } from './modules/media/routes/photo-engagement.routes';
|
||||
import { documentUploadRoutes } from './modules/media/routes/document-upload.routes';
|
||||
import { documentsRoutes } from './modules/media/routes/documents.routes';
|
||||
import { mediaErrorHandler } from './modules/media/middleware/error-handler';
|
||||
|
||||
// Add BigInt serialization support for Prisma BigInt fields
|
||||
@@ -156,6 +158,10 @@ const start = async () => {
|
||||
await fastify.register(photosPublicRoutes, { prefix: '/api' });
|
||||
await fastify.register(photoEngagementRoutes, { prefix: '/api' });
|
||||
|
||||
// Document routes (PDFs, docx, etc. for volunteer resources)
|
||||
await fastify.register(documentUploadRoutes, { prefix: '/api/documents' });
|
||||
await fastify.register(documentsRoutes, { prefix: '/api/documents' });
|
||||
|
||||
// 404 handler for unmatched routes
|
||||
fastify.setNotFoundHandler((_request, reply) => {
|
||||
reply.status(404).send({ error: { message: 'Route not found', code: 'NOT_FOUND' } });
|
||||
|
||||
133
api/src/modules/media/routes/document-upload.routes.ts
Normal file
133
api/src/modules/media/routes/document-upload.routes.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { pipeline } from 'stream/promises';
|
||||
import { createWriteStream } from 'fs';
|
||||
import { unlink, mkdir, stat } from 'fs/promises';
|
||||
import { join, extname } from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const DOCUMENT_INBOX_DIR = '/media/local/documents/inbox';
|
||||
|
||||
const ALLOWED_DOCUMENT_EXTENSIONS = [
|
||||
'.pdf',
|
||||
'.doc',
|
||||
'.docx',
|
||||
'.xlsx',
|
||||
'.csv',
|
||||
'.txt',
|
||||
'.md',
|
||||
'.zip',
|
||||
];
|
||||
|
||||
const ALLOWED_DOCUMENT_MIMETYPES = new Set([
|
||||
'application/pdf',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'text/csv',
|
||||
'application/csv',
|
||||
'text/plain',
|
||||
'text/markdown',
|
||||
'application/zip',
|
||||
'application/x-zip-compressed',
|
||||
'application/octet-stream',
|
||||
]);
|
||||
|
||||
function parseTags(raw: string | undefined): string[] | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed) && parsed.every((t) => typeof t === 'string')) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
// Fall through: allow plain comma-separated fallback
|
||||
}
|
||||
const split = raw.split(',').map((t) => t.trim()).filter(Boolean);
|
||||
return split.length > 0 ? split : null;
|
||||
}
|
||||
|
||||
export async function documentUploadRoutes(fastify: FastifyInstance) {
|
||||
fastify.post(
|
||||
'/upload',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let tempFilePath: string | null = null;
|
||||
|
||||
try {
|
||||
const data = await request.file();
|
||||
if (!data) {
|
||||
return reply.code(400).send({ message: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const ext = extname(data.filename).toLowerCase();
|
||||
if (!ALLOWED_DOCUMENT_EXTENSIONS.includes(ext)) {
|
||||
return reply.code(400).send({
|
||||
message: `Invalid file type. Allowed: ${ALLOWED_DOCUMENT_EXTENSIONS.join(', ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const mimeType = data.mimetype || 'application/octet-stream';
|
||||
if (!ALLOWED_DOCUMENT_MIMETYPES.has(mimeType)) {
|
||||
return reply.code(400).send({
|
||||
message: `Invalid mime type: ${mimeType}`,
|
||||
});
|
||||
}
|
||||
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
await mkdir(DOCUMENT_INBOX_DIR, { recursive: true });
|
||||
|
||||
const filePath = join(DOCUMENT_INBOX_DIR, filename);
|
||||
tempFilePath = filePath;
|
||||
|
||||
logger.info(`Uploading document to ${filePath}`);
|
||||
await pipeline(data.file, createWriteStream(filePath));
|
||||
|
||||
const fileStat = await stat(filePath);
|
||||
|
||||
const fields = data.fields as Record<string, { value: string }>;
|
||||
const title = fields.title?.value?.trim() || null;
|
||||
const description = fields.description?.value?.trim() || null;
|
||||
const category = fields.category?.value?.trim() || null;
|
||||
const tags = parseTags(fields.tags?.value);
|
||||
|
||||
const document = await prisma.document.create({
|
||||
data: {
|
||||
path: filePath,
|
||||
filename,
|
||||
originalFilename: data.filename,
|
||||
title: title || data.filename,
|
||||
description,
|
||||
mimeType,
|
||||
fileSize: BigInt(fileStat.size),
|
||||
category,
|
||||
tags: tags ? (tags as unknown as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||
uploaderId: request.user?.id || null,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`Document uploaded: ${document.id}`);
|
||||
|
||||
return reply.code(201).send({
|
||||
message: 'Document uploaded successfully',
|
||||
document: {
|
||||
...document,
|
||||
fileSize: document.fileSize?.toString() ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (tempFilePath) {
|
||||
try { await unlink(tempFilePath); } catch { /* ignore */ }
|
||||
}
|
||||
logger.error('Document upload failed:', error);
|
||||
return reply.code(500).send({
|
||||
message: error instanceof Error ? error.message : 'Upload failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
186
api/src/modules/media/routes/documents.routes.ts
Normal file
186
api/src/modules/media/routes/documents.routes.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { createReadStream } from 'fs';
|
||||
import { access, unlink } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
const DOCUMENTS_BASE = '/media/local/documents';
|
||||
|
||||
interface PublicListQuery {
|
||||
tag?: string;
|
||||
category?: string;
|
||||
published?: string;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
}
|
||||
|
||||
interface DocumentUpdateBody {
|
||||
title?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
category?: string;
|
||||
isPublished?: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
function serializeDocument<T extends { fileSize: bigint | null }>(doc: T) {
|
||||
return {
|
||||
...doc,
|
||||
fileSize: doc.fileSize?.toString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function documentsRoutes(fastify: FastifyInstance) {
|
||||
fastify.get<{ Querystring: PublicListQuery }>(
|
||||
'/',
|
||||
async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '100'), 500);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const { tag, category } = request.query;
|
||||
const publishedParam = request.query.published;
|
||||
|
||||
const where: Prisma.DocumentWhereInput = {};
|
||||
|
||||
if (publishedParam === undefined || publishedParam === 'true') {
|
||||
where.isPublished = true;
|
||||
} else if (publishedParam === 'false') {
|
||||
where.isPublished = false;
|
||||
}
|
||||
|
||||
if (category) where.category = category;
|
||||
if (tag) {
|
||||
where.tags = { array_contains: tag } as Prisma.JsonFilter;
|
||||
}
|
||||
|
||||
const items = await prisma.document.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ position: 'asc' },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return { items: items.map(serializeDocument) };
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
const document = await prisma.document.findUnique({ where: { id } });
|
||||
|
||||
if (!document) {
|
||||
return reply.code(404).send({ message: 'Document not found' });
|
||||
}
|
||||
|
||||
if (!document.isPublished) {
|
||||
return reply.code(404).send({ message: 'Document not found' });
|
||||
}
|
||||
|
||||
return serializeDocument(document);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/:id/download',
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
const document = await prisma.document.findUnique({ where: { id } });
|
||||
if (!document || !document.isPublished) {
|
||||
return reply.code(404).send({ message: 'Document not found' });
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(document.path);
|
||||
if (!resolvedPath.startsWith(resolve(DOCUMENTS_BASE) + '/')) {
|
||||
logger.warn(`Document path traversal attempt blocked: ${document.path}`);
|
||||
return reply.code(403).send({ message: 'Access denied' });
|
||||
}
|
||||
|
||||
try {
|
||||
await access(resolvedPath);
|
||||
} catch {
|
||||
return reply.code(404).send({ message: 'Document file not found' });
|
||||
}
|
||||
|
||||
await prisma.document.update({
|
||||
where: { id },
|
||||
data: { downloadCount: { increment: 1 } },
|
||||
});
|
||||
|
||||
const downloadName = document.originalFilename || document.filename;
|
||||
const safeName = downloadName.replace(/"/g, '');
|
||||
|
||||
reply.header('Content-Type', document.mimeType);
|
||||
reply.header('Content-Disposition', `attachment; filename="${safeName}"`);
|
||||
if (document.fileSize) {
|
||||
reply.header('Content-Length', document.fileSize.toString());
|
||||
}
|
||||
return reply.send(createReadStream(resolvedPath));
|
||||
}
|
||||
);
|
||||
|
||||
fastify.put<{ Params: { id: string }; Body: DocumentUpdateBody }>(
|
||||
'/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: { id: string }; Body: DocumentUpdateBody }>, reply: FastifyReply) => {
|
||||
const { id } = request.params;
|
||||
const { title, description, tags, category, isPublished, position } = request.body;
|
||||
|
||||
const existing = await prisma.document.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ message: 'Document not found' });
|
||||
}
|
||||
|
||||
const updated = await prisma.document.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(title !== undefined && { title }),
|
||||
...(description !== undefined && { description }),
|
||||
...(category !== undefined && { category }),
|
||||
...(isPublished !== undefined && { isPublished }),
|
||||
...(position !== undefined && { position }),
|
||||
...(tags !== undefined && {
|
||||
tags: tags === null
|
||||
? Prisma.JsonNull
|
||||
: (tags as unknown as Prisma.InputJsonValue),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
return serializeDocument(updated);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.delete<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request, reply) => {
|
||||
const { id } = request.params;
|
||||
|
||||
const document = await prisma.document.findUnique({ where: { id } });
|
||||
if (!document) {
|
||||
return reply.code(404).send({ message: 'Document not found' });
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(document.path);
|
||||
if (resolvedPath.startsWith(resolve(DOCUMENTS_BASE) + '/')) {
|
||||
try {
|
||||
await unlink(resolvedPath);
|
||||
} catch (err) {
|
||||
logger.warn(`Failed to unlink document file ${resolvedPath}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.document.delete({ where: { id } });
|
||||
|
||||
return { message: 'Document deleted' };
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user