const path = require('path'); const { Readable } = require('stream'); const { pipeline } = require('stream/promises'); const Document = require('../models/Document'); const { getDocumentsBucket } = require('../configs/gridfs'); const { ensureValidIdOrRespond, findByIdOrRespondNotFound } = require('../utils/controllerResponses'); const { asyncController } = require('../utils/asyncController'); const parseBoundedInt = (value, { min, max, fallback }) => { const parsed = Number.parseInt(String(value ?? ''), 10); if (!Number.isInteger(parsed)) return fallback; return Math.min(Math.max(parsed, min), max); }; const sanitizeDownloadFilename = (filename) => { const normalized = String(filename || 'document') .replace(/[\r\n"]/g, '') .trim(); return normalized || 'document'; }; const sanitizeBaseName = (name) => name .trim() .replace(/[^a-zA-Z0-9._-]+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); const buildStoredFilename = (originalName) => { const ext = path.extname(originalName || '').toLowerCase(); const base = path.basename(originalName || 'document', ext); const safeBase = sanitizeBaseName(base) || 'document'; return `${Date.now()}-${Math.round(Math.random() * 1e9)}-${safeBase}${ext}`; }; const toClientDocument = (doc) => ({ _id: doc._id, originalName: doc.originalName, mimeType: doc.mimeType, size: doc.size, category: doc.category, description: doc.description, uploadedBy: doc.uploadedBy ?? null, createdAt: doc.createdAt, updatedAt: doc.updatedAt, downloadUrl: `/api/documents/${doc._id}/download`, }); const requireDocumentsBucket = () => { const bucket = getDocumentsBucket(); if (!bucket) { const err = new Error('GridFS is not ready. Please try again in a moment.'); err.statusCode = 503; throw err; } return bucket; }; const listDocuments = asyncController(async (req, res) => { const hasPaginationQuery = req.query.page !== undefined || req.query.pageSize !== undefined; const legacyLimit = parseBoundedInt(req.query.limit, { min: 1, max: 1000, fallback: 300 }); const page = parseBoundedInt(req.query.page, { min: 1, max: 5000, fallback: 1 }); const pageSize = parseBoundedInt(req.query.pageSize, { min: 1, max: 100, fallback: 25 }); const category = String(req.query.category || '').trim(); const query = category ? { category } : {}; if (!hasPaginationQuery) { const docs = await Document.find(query) .sort({ createdAt: -1 }) .limit(legacyLimit) .lean(); res.json(docs.map(toClientDocument)); return; } const skip = (page - 1) * pageSize; const [total, docs] = await Promise.all([ Document.countDocuments(query), Document.find(query) .sort({ createdAt: -1 }) .skip(skip) .limit(pageSize) .lean(), ]); res.json({ items: docs.map(toClientDocument), total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)), }); }); const getDocumentById = asyncController(async (req, res) => { if (!ensureValidIdOrRespond(res, req.params.id, 'document')) return; const doc = await findByIdOrRespondNotFound({ Model: Document, id: req.params.id, res, notFoundMessage: 'Document not found', lean: true, }); if (!doc) return; res.json(toClientDocument(doc)); }); const downloadDocument = asyncController(async (req, res) => { if (!ensureValidIdOrRespond(res, req.params.id, 'document')) return; const doc = await findByIdOrRespondNotFound({ Model: Document, id: req.params.id, res, notFoundMessage: 'Document not found', lean: true, }); if (!doc) return; const bucket = requireDocumentsBucket(); const downloadStream = bucket.openDownloadStream(doc.fileId); const safeName = sanitizeDownloadFilename(doc.originalName); const isInline = req.query.inline === 'true' || req.query.inline === '1'; res.setHeader('Content-Type', doc.mimeType || 'application/octet-stream'); res.setHeader('Content-Disposition', `${isInline ? 'inline' : 'attachment'}; filename="${safeName}"`); res.setHeader('Cache-Control', 'private, max-age=60'); if (typeof doc.size === 'number' && doc.size >= 0) { res.setHeader('Content-Length', String(doc.size)); } downloadStream.on('error', () => { if (!res.headersSent) { return res.status(404).json({ message: 'Stored file not found' }); } res.destroy(); }); downloadStream.pipe(res); }); const uploadDocument = asyncController(async (req, res) => { if (!req.file || !req.file.buffer) { return res.status(400).json({ message: 'No document file uploaded' }); } const category = String(req.body.category || 'general').trim() || 'general'; const description = String(req.body.description || '').trim(); const bucket = requireDocumentsBucket(); const storedFilename = buildStoredFilename(req.file.originalname); const uploadStream = bucket.openUploadStream(storedFilename, { contentType: req.file.mimetype || 'application/octet-stream', metadata: { originalName: req.file.originalname, category }, }); const uploadedFileId = uploadStream.id; try { await pipeline(Readable.from(req.file.buffer), uploadStream); } catch (err) { try { const b = getDocumentsBucket(); if (b) await b.delete(uploadedFileId); } catch (_) { // Best-effort cleanup to avoid orphaned files. } throw err; } const doc = await Document.create({ fileId: uploadStream.id, filename: storedFilename, originalName: req.file.originalname, mimeType: req.file.mimetype || 'application/octet-stream', size: req.file.size, category, description, uploadedBy: req.user?.id || req.user?._id || null, }); res.status(201).json(toClientDocument(doc.toObject())); }, { defaultStatus: 400 }); const deleteDocument = asyncController(async (req, res) => { if (!ensureValidIdOrRespond(res, req.params.id, 'document')) return; const doc = await findByIdOrRespondNotFound({ Model: Document, id: req.params.id, res, notFoundMessage: 'Document not found', }); if (!doc) return; const bucket = requireDocumentsBucket(); try { await bucket.delete(doc.fileId); } catch (err) { // Ignore missing file in GridFS but preserve other errors. if (err?.codeName !== 'NamespaceNotFound' && !String(err.message || '').includes('FileNotFound')) { throw err; } } await doc.deleteOne(); res.json({ message: 'Document removed' }); }); module.exports = { listDocuments, getDocumentById, downloadDocument, uploadDocument, deleteDocument, };