tinfo-web-server / controllers /documentController.js
Faridaqurr
initial backend
e8c33fa
Raw
History Blame Contribute Delete
6.65 kB
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,
};