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:
parent
ed011a762b
commit
ae5a90d8d4
412
admin/src/pages/media/DocumentsTab.tsx
Normal file
412
admin/src/pages/media/DocumentsTab.tsx
Normal file
@ -0,0 +1,412 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Upload,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Tag,
|
||||
Space,
|
||||
message,
|
||||
Tooltip,
|
||||
Switch,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import type { UploadProps, UploadFile } from 'antd/es/upload/interface';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
UploadOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
LinkOutlined,
|
||||
FilePdfOutlined,
|
||||
FileTextOutlined,
|
||||
FileExcelOutlined,
|
||||
FileWordOutlined,
|
||||
FileZipOutlined,
|
||||
FileOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { mediaApi } from '@/lib/media-api';
|
||||
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||
|
||||
interface DocumentRow {
|
||||
id: string;
|
||||
path: string;
|
||||
filename: string;
|
||||
originalFilename: string | null;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
mimeType: string;
|
||||
fileSize: string | null;
|
||||
pageCount: number | null;
|
||||
thumbnailPath: string | null;
|
||||
category: string | null;
|
||||
tags: string[] | null;
|
||||
isPublished: boolean;
|
||||
position: number | null;
|
||||
downloadCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const TAG_SUGGESTIONS = ['volunteer-resource', 'training', 'handout', 'policy'];
|
||||
|
||||
function formatBytes(raw: string | null | undefined): string {
|
||||
if (!raw) return '—';
|
||||
const bytes = Number(raw);
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit += 1;
|
||||
}
|
||||
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function iconForMime(mime: string) {
|
||||
if (mime.includes('pdf')) return <FilePdfOutlined style={{ fontSize: 24, color: '#d4380d' }} />;
|
||||
if (mime.includes('word')) return <FileWordOutlined style={{ fontSize: 24, color: '#1677ff' }} />;
|
||||
if (mime.includes('sheet') || mime.includes('excel') || mime.includes('csv')) {
|
||||
return <FileExcelOutlined style={{ fontSize: 24, color: '#389e0d' }} />;
|
||||
}
|
||||
if (mime.includes('zip')) return <FileZipOutlined style={{ fontSize: 24, color: '#8c8c8c' }} />;
|
||||
if (mime.startsWith('text/')) return <FileTextOutlined style={{ fontSize: 24, color: '#595959' }} />;
|
||||
return <FileOutlined style={{ fontSize: 24, color: '#8c8c8c' }} />;
|
||||
}
|
||||
|
||||
function normalizeTags(raw: unknown): string[] {
|
||||
if (!raw) return [];
|
||||
if (Array.isArray(raw)) return raw.filter((t): t is string => typeof t === 'string');
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function DocumentsTab() {
|
||||
const [documents, setDocuments] = useState<DocumentRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [editingDoc, setEditingDoc] = useState<DocumentRow | null>(null);
|
||||
const [uploadForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const pendingFile = useRef<File | null>(null);
|
||||
|
||||
const fetchDocuments = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await mediaApi.get<{ items: DocumentRow[] }>('/documents', {
|
||||
params: { published: 'false' },
|
||||
});
|
||||
const items = (data.items || []).map((d) => ({
|
||||
...d,
|
||||
tags: normalizeTags(d.tags),
|
||||
}));
|
||||
setDocuments(items);
|
||||
} catch (error: unknown) {
|
||||
message.error(getErrorMessage(error, 'Failed to load documents'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDocuments();
|
||||
}, [fetchDocuments]);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!pendingFile.current) {
|
||||
message.warning('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await uploadForm.validateFields();
|
||||
setUploading(true);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', pendingFile.current);
|
||||
if (values.title) formData.append('title', values.title);
|
||||
if (values.tags && values.tags.length > 0) {
|
||||
formData.append('tags', JSON.stringify(values.tags));
|
||||
}
|
||||
if (values.category) formData.append('category', values.category);
|
||||
if (values.description) formData.append('description', values.description);
|
||||
|
||||
await mediaApi.post('/documents/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
message.success('Document uploaded');
|
||||
setUploadModalOpen(false);
|
||||
uploadForm.resetFields();
|
||||
pendingFile.current = null;
|
||||
await fetchDocuments();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && 'errorFields' in error) return;
|
||||
message.error(getErrorMessage(error, 'Upload failed'));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (doc: DocumentRow) => {
|
||||
setEditingDoc(doc);
|
||||
editForm.setFieldsValue({
|
||||
title: doc.title || '',
|
||||
description: doc.description || '',
|
||||
tags: doc.tags || [],
|
||||
category: doc.category || '',
|
||||
isPublished: doc.isPublished,
|
||||
position: doc.position ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
if (!editingDoc) return;
|
||||
try {
|
||||
const values = await editForm.validateFields();
|
||||
await mediaApi.put(`/documents/${editingDoc.id}`, values);
|
||||
message.success('Document updated');
|
||||
setEditingDoc(null);
|
||||
editForm.resetFields();
|
||||
await fetchDocuments();
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && 'errorFields' in error) return;
|
||||
message.error(getErrorMessage(error, 'Update failed'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (doc: DocumentRow) => {
|
||||
Modal.confirm({
|
||||
title: 'Delete this document?',
|
||||
content: doc.title || doc.originalFilename || doc.filename,
|
||||
okText: 'Delete',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await mediaApi.delete(`/documents/${doc.id}`);
|
||||
message.success('Document deleted');
|
||||
await fetchDocuments();
|
||||
} catch (error: unknown) {
|
||||
message.error(getErrorMessage(error, 'Delete failed'));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyLink = (doc: DocumentRow) => {
|
||||
const url = `${window.location.origin}/media/api/documents/${doc.id}/download`;
|
||||
navigator.clipboard.writeText(url).then(
|
||||
() => message.success('Download URL copied to clipboard'),
|
||||
() => message.error('Failed to copy URL'),
|
||||
);
|
||||
};
|
||||
|
||||
const uploadProps: UploadProps = {
|
||||
beforeUpload: (file) => {
|
||||
pendingFile.current = file;
|
||||
if (!uploadForm.getFieldValue('title')) {
|
||||
uploadForm.setFieldsValue({ title: file.name });
|
||||
}
|
||||
return false;
|
||||
},
|
||||
onRemove: () => {
|
||||
pendingFile.current = null;
|
||||
},
|
||||
maxCount: 1,
|
||||
fileList: pendingFile.current
|
||||
? [{ uid: '-1', name: pendingFile.current.name, status: 'done' } as UploadFile]
|
||||
: [],
|
||||
};
|
||||
|
||||
const columns: ColumnsType<DocumentRow> = [
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'mimeType',
|
||||
width: 56,
|
||||
render: (mime: string) => iconForMime(mime),
|
||||
},
|
||||
{
|
||||
title: 'Title',
|
||||
dataIndex: 'title',
|
||||
render: (_: unknown, row) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{row.title || row.originalFilename || row.filename}</div>
|
||||
<div style={{ fontSize: 12, color: '#8c8c8c' }}>
|
||||
{row.originalFilename || row.filename}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Size',
|
||||
dataIndex: 'fileSize',
|
||||
width: 90,
|
||||
render: (size: string | null) => formatBytes(size),
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
dataIndex: 'mimeType',
|
||||
width: 140,
|
||||
render: (mime: string) => <code style={{ fontSize: 11 }}>{mime}</code>,
|
||||
},
|
||||
{
|
||||
title: 'Tags',
|
||||
dataIndex: 'tags',
|
||||
render: (tags: string[] | null) =>
|
||||
tags && tags.length > 0 ? (
|
||||
<Space wrap size={4}>
|
||||
{tags.map((t) => (
|
||||
<Tag key={t} color={t === 'volunteer-resource' ? 'purple' : 'blue'}>
|
||||
{t}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
) : (
|
||||
<span style={{ color: '#bfbfbf' }}>—</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Downloads',
|
||||
dataIndex: 'downloadCount',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'isPublished',
|
||||
width: 100,
|
||||
render: (published: boolean) =>
|
||||
published ? <Tag color="green">Published</Tag> : <Tag>Draft</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Actions',
|
||||
key: 'actions',
|
||||
width: 150,
|
||||
render: (_: unknown, row) => (
|
||||
<Space size={4}>
|
||||
<Tooltip title="Copy download URL">
|
||||
<Button size="small" icon={<LinkOutlined />} onClick={() => handleCopyLink(row)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Edit">
|
||||
<Button size="small" icon={<EditOutlined />} onClick={() => handleEdit(row)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => handleDelete(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
|
||||
<div style={{ color: '#999', fontSize: 13 }}>
|
||||
{documents.length} document{documents.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<Button type="primary" icon={<UploadOutlined />} onClick={() => setUploadModalOpen(true)}>
|
||||
Upload
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table<DocumentRow>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={documents}
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 50, showSizeChanger: true, pageSizeOptions: [25, 50, 100] }}
|
||||
locale={{ emptyText: <Empty description="No documents uploaded yet" /> }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Upload Document"
|
||||
open={uploadModalOpen}
|
||||
onCancel={() => {
|
||||
if (uploading) return;
|
||||
setUploadModalOpen(false);
|
||||
uploadForm.resetFields();
|
||||
pendingFile.current = null;
|
||||
}}
|
||||
onOk={handleUpload}
|
||||
confirmLoading={uploading}
|
||||
okText="Upload"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={uploadForm} layout="vertical">
|
||||
<Form.Item label="File" required>
|
||||
<Upload.Dragger {...uploadProps}>
|
||||
<p className="ant-upload-drag-icon">
|
||||
<UploadOutlined />
|
||||
</p>
|
||||
<p className="ant-upload-text">Click or drag file to upload</p>
|
||||
<p className="ant-upload-hint" style={{ fontSize: 12 }}>
|
||||
PDF, DOC/DOCX, XLSX, CSV, TXT, MD, ZIP
|
||||
</p>
|
||||
</Upload.Dragger>
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="Title">
|
||||
<Input placeholder="Optional — defaults to filename" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="Tags" tooltip="Tag with 'volunteer-resource' to surface in the volunteer dashboard">
|
||||
<Select
|
||||
mode="tags"
|
||||
placeholder="Select or type tags"
|
||||
options={TAG_SUGGESTIONS.map((t) => ({ value: t, label: t }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="Category">
|
||||
<Input placeholder="Optional" />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea rows={2} placeholder="Optional" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="Edit Document"
|
||||
open={!!editingDoc}
|
||||
onCancel={() => {
|
||||
setEditingDoc(null);
|
||||
editForm.resetFields();
|
||||
}}
|
||||
onOk={handleSaveEdit}
|
||||
okText="Save"
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="title" label="Title">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="description" label="Description">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="tags" label="Tags">
|
||||
<Select
|
||||
mode="tags"
|
||||
options={TAG_SUGGESTIONS.map((t) => ({ value: t, label: t }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="category" label="Category">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="position" label="Position">
|
||||
<Input type="number" />
|
||||
</Form.Item>
|
||||
<Form.Item name="isPublished" label="Published" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -16,6 +16,7 @@ import {
|
||||
VideoCameraOutlined,
|
||||
FolderOutlined,
|
||||
FolderAddOutlined,
|
||||
FileTextOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { mediaApi } from '@/lib/media-api';
|
||||
@ -44,10 +45,11 @@ import FetchVideosDrawer from '@/components/media/FetchVideosDrawer';
|
||||
import AddToPlaylistModal from '@/components/media/AddToPlaylistModal';
|
||||
import BulkAddToPlaylistModal from '@/components/media/BulkAddToPlaylistModal';
|
||||
import BulkAccessLevelModal from '@/components/media/BulkAccessLevelModal';
|
||||
import DocumentsTab from '@/pages/media/DocumentsTab';
|
||||
import { getErrorMessage } from '@/utils/getErrorMessage';
|
||||
import { PageTour } from '@/components/tour/PageTour';
|
||||
|
||||
type MediaTab = 'Videos' | 'Photos' | 'Albums';
|
||||
type MediaTab = 'Videos' | 'Photos' | 'Albums' | 'Documents';
|
||||
|
||||
export default function LibraryPage() {
|
||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||
@ -117,7 +119,7 @@ export default function LibraryPage() {
|
||||
useEffect(() => {
|
||||
if (mediaTab === 'Videos') fetchVideos();
|
||||
else if (mediaTab === 'Photos') fetchPhotos();
|
||||
else fetchAlbums();
|
||||
else if (mediaTab === 'Albums') fetchAlbums();
|
||||
}, [mediaTab, debouncedSearch, orientation, selectedProducers, shortsFilter, formatFilter,
|
||||
videoPagination.page, videoPagination.limit,
|
||||
photoPagination.page, photoPagination.limit,
|
||||
@ -364,11 +366,16 @@ export default function LibraryPage() {
|
||||
{ value: 'Videos', icon: <VideoCameraOutlined />, label: 'Videos' },
|
||||
{ value: 'Photos', icon: <PictureOutlined />, label: 'Photos' },
|
||||
{ value: 'Albums', icon: <FolderOutlined />, label: 'Albums' },
|
||||
{ value: 'Documents', icon: <FileTextOutlined />, label: 'Documents' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
{/* Documents tab owns its own layout */}
|
||||
{mediaTab === 'Documents' && <DocumentsTab />}
|
||||
|
||||
{/* Toolbar — hidden for Documents tab */}
|
||||
{mediaTab !== 'Documents' && (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 12 }} data-tour-media-filters>
|
||||
<Input
|
||||
placeholder={`Search ${mediaTab.toLowerCase()}...`}
|
||||
@ -487,16 +494,20 @@ export default function LibraryPage() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{/* Stats (hidden for Documents) */}
|
||||
{mediaTab !== 'Documents' && (
|
||||
<div style={{ marginBottom: 8, color: '#999', fontSize: 13 }}>
|
||||
{activePagination.total} {mediaTab.toLowerCase().replace(/s$/, '')}{activePagination.total !== 1 ? 's' : ''}
|
||||
{mediaTab === 'Videos' && selectedVideoIds.length > 0 && ` · ${selectedVideoIds.length} selected`}
|
||||
{mediaTab === 'Photos' && selectedPhotoIds.length > 0 && ` · ${selectedPhotoIds.length} selected`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Grid */}
|
||||
{loading ? (
|
||||
{/* Content Grid (hidden for Documents) */}
|
||||
{mediaTab !== 'Documents' && (
|
||||
loading ? (
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
@ -594,6 +605,7 @@ export default function LibraryPage() {
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* === Video Bulk Actions === */}
|
||||
|
||||
@ -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' };
|
||||
}
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user