Spaces:
Running
Running
| 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, | |
| }; | |