upload tài liệu

This commit is contained in:
2026-08-06 10:44:25 +07:00
parent 92bcb04682
commit 26094ee4d6
10 changed files with 620 additions and 8 deletions

View File

@@ -65,6 +65,11 @@ const SMTP_PASS = process.env.SMTP_PASS || '';
const SMTP_FROM = process.env.SMTP_FROM || SMTP_USER || 'no-reply@accmanager.local';
const EMAIL_VERIFY_TOKEN_TTL_MINUTES = Number(process.env.EMAIL_VERIFY_TOKEN_TTL_MINUTES || 30);
const PASSWORD_RESET_TOKEN_TTL_MINUTES = Number(process.env.PASSWORD_RESET_TOKEN_TTL_MINUTES || 30);
const configuredDocumentMaxFileSizeMb = Number(process.env.DOCUMENT_MAX_FILE_SIZE_MB || 25);
const DOCUMENT_MAX_FILE_SIZE_MB = Number.isFinite(configuredDocumentMaxFileSizeMb)
? Math.min(100, Math.max(1, configuredDocumentMaxFileSizeMb))
: 25;
const DOCUMENT_MAX_FILE_SIZE_BYTES = DOCUMENT_MAX_FILE_SIZE_MB * 1024 * 1024;
let mailTransporter;
@@ -2221,6 +2226,56 @@ const upload = multer({
}
});
const documentUpload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: DOCUMENT_MAX_FILE_SIZE_BYTES,
files: 1,
fields: 5,
parts: 7
},
fileFilter(req, file, callback) {
const extension = require('path').extname(String(file.originalname || '')).toLowerCase();
const mimeType = String(file.mimetype || '').toLowerCase();
if (extension !== '.pdf' || mimeType !== 'application/pdf') {
return callback(new Error('Chỉ chấp nhận file PDF'));
}
callback(null, true);
}
});
function isPdfBuffer(value) {
if (!Buffer.isBuffer(value) || value.length < 10 || value.subarray(0, 5).toString('ascii') !== '%PDF-') {
return false;
}
const trailerStart = Math.max(0, value.length - 2048);
return value.subarray(trailerStart).includes(Buffer.from('%%EOF', 'ascii'));
}
function sanitizeDocumentFileName(value) {
const cleaned = String(value || 'document.pdf')
.replace(/[\u0000-\u001f\u007f]/g, '')
.replace(/[\\/:*?"<>|]/g, '_')
.trim()
.slice(0, 255);
const baseName = cleaned || 'document.pdf';
return baseName.toLowerCase().endsWith('.pdf')
? baseName
: `${baseName.slice(0, 251)}.pdf`;
}
function getDocumentContentDisposition(fileName, download = false) {
const safeFileName = sanitizeDocumentFileName(fileName);
const asciiFileName = safeFileName
.normalize('NFKD')
.replace(/[^\x20-\x7e]/g, '')
.replace(/["\\]/g, '_')
.trim() || 'document.pdf';
const encodedFileName = encodeURIComponent(safeFileName)
.replace(/['()*]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
return `${download ? 'attachment' : 'inline'}; filename="${asciiFileName}"; filename*=UTF-8''${encodedFileName}`;
}
// Serve static files from /public
const path = require('path');
const publicDir = path.join(__dirname, '..', 'public');
@@ -2676,6 +2731,24 @@ async function createTables() {
)
END`,
// PDF Documents Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Documents')
BEGIN
CREATE TABLE Documents (
DocumentId INT PRIMARY KEY IDENTITY(1,1),
Title NVARCHAR(255) NOT NULL,
OriginalFileName NVARCHAR(255) NOT NULL,
MimeType NVARCHAR(100) NOT NULL DEFAULT 'application/pdf',
FileSize BIGINT NOT NULL,
FileData VARBINARY(MAX) NOT NULL,
UploadedBy INT NULL,
UploadedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (UploadedBy) REFERENCES Users(UserId) ON DELETE SET NULL
);
CREATE INDEX IX_Documents_UploadedDate ON Documents(UploadedDate DESC);
CREATE INDEX IX_Documents_UploadedBy ON Documents(UploadedBy);
END`,
// Asset Inventory Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory')
BEGIN
@@ -8837,6 +8910,143 @@ app.post('/api/assets/import', requireAssetOrAdmin, upload.single('file'), async
}
});
// ==========================================
// API ROUTES - PDF Documents
// ==========================================
app.get('/api/documents', async (req, res) => {
try {
const search = String(req.query.search || '').trim().slice(0, 255);
const result = await pool.request()
.input('search', sql.NVarChar(255), search)
.input('currentUserId', sql.Int, req.user.UserId)
.input('isAdmin', sql.Bit, normalizeRole(req.user.Role) === 'admin')
.query(`
SELECT
d.DocumentId,
d.Title,
d.OriginalFileName,
d.MimeType,
d.FileSize,
d.UploadedBy,
COALESCE(NULLIF(LTRIM(RTRIM(u.FullName)), ''), u.Username, N'Người dùng đã xóa') AS UploadedByName,
d.UploadedDate,
CAST(CASE WHEN @isAdmin = 1 OR d.UploadedBy = @currentUserId THEN 1 ELSE 0 END AS BIT) AS CanDelete
FROM Documents d
LEFT JOIN Users u ON u.UserId = d.UploadedBy
WHERE @search = N''
OR d.Title LIKE N'%' + @search + N'%'
OR d.OriginalFileName LIKE N'%' + @search + N'%'
OR COALESCE(u.FullName, u.Username, N'') LIKE N'%' + @search + N'%'
ORDER BY d.UploadedDate DESC, d.DocumentId DESC;
`);
res.json({
success: true,
data: result.recordset,
maxFileSizeMb: DOCUMENT_MAX_FILE_SIZE_MB
});
} catch (err) {
sendInternalError(res, err, 'Không thể tải danh sách tài liệu');
}
});
app.post('/api/documents', documentUpload.single('file'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ success: false, message: 'Vui lòng chọn file PDF' });
}
if (!isPdfBuffer(req.file.buffer)) {
return res.status(400).json({ success: false, message: 'Nội dung file không phải là PDF hợp lệ' });
}
const originalFileName = sanitizeDocumentFileName(req.file.originalname);
const requestedTitle = String(req.body.title || '').trim();
const defaultTitle = originalFileName.replace(/\.pdf$/i, '').trim() || 'Tài liệu PDF';
const title = (requestedTitle || defaultTitle).slice(0, 255);
const result = await pool.request()
.input('title', sql.NVarChar(255), title)
.input('originalFileName', sql.NVarChar(255), originalFileName)
.input('mimeType', sql.NVarChar(100), 'application/pdf')
.input('fileSize', sql.BigInt, req.file.size)
.input('fileData', sql.VarBinary(sql.MAX), req.file.buffer)
.input('uploadedBy', sql.Int, req.user.UserId)
.query(`
INSERT INTO Documents (Title, OriginalFileName, MimeType, FileSize, FileData, UploadedBy)
OUTPUT INSERTED.DocumentId, INSERTED.Title, INSERTED.OriginalFileName,
INSERTED.MimeType, INSERTED.FileSize, INSERTED.UploadedBy, INSERTED.UploadedDate
VALUES (@title, @originalFileName, @mimeType, @fileSize, @fileData, @uploadedBy);
`);
res.status(201).json({
success: true,
message: 'Đã lưu tài liệu PDF',
data: result.recordset[0]
});
} catch (err) {
sendInternalError(res, err, 'Không thể lưu tài liệu');
}
});
app.get('/api/documents/:id/file', async (req, res) => {
const documentId = Number.parseInt(req.params.id, 10);
if (!Number.isInteger(documentId) || documentId <= 0) {
return res.status(400).json({ success: false, message: 'Mã tài liệu không hợp lệ' });
}
try {
const result = await pool.request()
.input('documentId', sql.Int, documentId)
.query(`
SELECT OriginalFileName, MimeType, FileSize, FileData
FROM Documents
WHERE DocumentId = @documentId;
`);
const document = result.recordset[0];
if (!document) {
return res.status(404).json({ success: false, message: 'Không tìm thấy tài liệu' });
}
const shouldDownload = String(req.query.download || '') === '1';
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Length', String(document.FileSize));
res.setHeader('Content-Disposition', getDocumentContentDisposition(document.OriginalFileName, shouldDownload));
res.send(document.FileData);
} catch (err) {
sendInternalError(res, err, 'Không thể mở tài liệu');
}
});
app.delete('/api/documents/:id', async (req, res) => {
const documentId = Number.parseInt(req.params.id, 10);
if (!Number.isInteger(documentId) || documentId <= 0) {
return res.status(400).json({ success: false, message: 'Mã tài liệu không hợp lệ' });
}
try {
const existingResult = await pool.request()
.input('documentId', sql.Int, documentId)
.query('SELECT DocumentId, UploadedBy FROM Documents WHERE DocumentId = @documentId');
const document = existingResult.recordset[0];
if (!document) {
return res.status(404).json({ success: false, message: 'Không tìm thấy tài liệu' });
}
const isOwner = Number(document.UploadedBy) === Number(req.user.UserId);
const isAdmin = normalizeRole(req.user.Role) === 'admin';
if (!isOwner && !isAdmin) {
return res.status(403).json({ success: false, message: 'Bạn không có quyền xóa tài liệu này' });
}
await pool.request()
.input('documentId', sql.Int, documentId)
.query('DELETE FROM Documents WHERE DocumentId = @documentId');
res.json({ success: true, message: 'Đã xóa tài liệu' });
} catch (err) {
sendInternalError(res, err, 'Không thể xóa tài liệu');
}
});
// ==========================================
// API ROUTES - Database Info
// ==========================================
@@ -8882,7 +9092,15 @@ app.get('/api/database/info', requireAdmin, async (req, res) => {
app.use((err, req, res, next) => {
console.error('Unhandled request error:', err.message);
if (err instanceof multer.MulterError || err.message === 'Only .xls and .xlsx files are allowed') {
if (err instanceof multer.MulterError) {
const message = err.code === 'LIMIT_FILE_SIZE'
? (req.path.startsWith('/api/documents')
? `File vượt quá dung lượng cho phép (${DOCUMENT_MAX_FILE_SIZE_MB} MB)`
: 'File exceeds the 10 MB upload limit')
: err.message;
return res.status(400).json({ success: false, message });
}
if (err.message === 'Only .xls and .xlsx files are allowed' || err.message === 'Chỉ chấp nhận file PDF') {
return res.status(400).json({ success: false, message: err.message });
}
res.status(500).json({ success: false, message: 'Internal server error' });
@@ -8936,5 +9154,8 @@ module.exports = {
encryptSensitiveValue,
decryptSensitiveValue,
hashSessionToken,
normalizeOptionalHttpUrl
normalizeOptionalHttpUrl,
isPdfBuffer,
sanitizeDocumentFileName,
getDocumentContentDisposition
};