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

@@ -43,3 +43,6 @@ SMTP_PASS=
SMTP_FROM= SMTP_FROM=
EMAIL_VERIFY_TOKEN_TTL_MINUTES=30 EMAIL_VERIFY_TOKEN_TTL_MINUTES=30
PASSWORD_RESET_TOKEN_TTL_MINUTES=30 PASSWORD_RESET_TOKEN_TTL_MINUTES=30
# Maximum size for each uploaded PDF document (1-100 MB).
DOCUMENT_MAX_FILE_SIZE_MB=25

View File

@@ -10,6 +10,7 @@ Thay vì lưu rải rác tài khoản ứng dụng ở nhiều nơi, AccManager
- Quản lý danh sách ứng dụng đang sử dụng trong công ty. - Quản lý danh sách ứng dụng đang sử dụng trong công ty.
- Gán tài khoản truy cập ứng dụng cho từng người dùng. - Gán tài khoản truy cập ứng dụng cho từng người dùng.
- Theo dõi thông tin tài khoản rõ ràng, tránh thất lạc. - Theo dõi thông tin tài khoản rõ ràng, tránh thất lạc.
- Lưu trữ, tìm kiếm, xem và tải xuống tài liệu PDF dùng chung.
## Ai sẽ sử dụng ## Ai sẽ sử dụng
@@ -22,7 +23,8 @@ Thay vì lưu rải rác tài khoản ứng dụng ở nhiều nơi, AccManager
2. Vào mục Người dùng để tạo mới hoặc cập nhật thông tin nhân sự. 2. Vào mục Người dùng để tạo mới hoặc cập nhật thông tin nhân sự.
3. Vào mục Ứng dụng để thêm các hệ thống cần quản lý. 3. Vào mục Ứng dụng để thêm các hệ thống cần quản lý.
4. Vào mục Tài khoản để gán tài khoản ứng dụng cho đúng người. 4. Vào mục Tài khoản để gán tài khoản ứng dụng cho đúng người.
5. Dùng nút Xem chi tiết trong danh sách người dùng để kiểm tra đầy đủ thông tin trước khi chỉnh sửa. 5. Vào mục Tài liệu PDF để tải lên hoặc xem tài liệu dùng chung.
6. Dùng nút Xem chi tiết trong danh sách người dùng để kiểm tra đầy đủ thông tin trước khi chỉnh sửa.
## Quy trình vận hành gợi ý ## Quy trình vận hành gợi ý

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 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 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 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; 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 // Serve static files from /public
const path = require('path'); const path = require('path');
const publicDir = path.join(__dirname, '..', 'public'); const publicDir = path.join(__dirname, '..', 'public');
@@ -2676,6 +2731,24 @@ async function createTables() {
) )
END`, 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 // Asset Inventory Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory') `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetInventory')
BEGIN 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 // API ROUTES - Database Info
// ========================================== // ==========================================
@@ -8882,7 +9092,15 @@ app.get('/api/database/info', requireAdmin, async (req, res) => {
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
console.error('Unhandled request error:', err.message); 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 }); return res.status(400).json({ success: false, message: err.message });
} }
res.status(500).json({ success: false, message: 'Internal server error' }); res.status(500).json({ success: false, message: 'Internal server error' });
@@ -8936,5 +9154,8 @@ module.exports = {
encryptSensitiveValue, encryptSensitiveValue,
decryptSensitiveValue, decryptSensitiveValue,
hashSessionToken, hashSessionToken,
normalizeOptionalHttpUrl normalizeOptionalHttpUrl,
isPdfBuffer,
sanitizeDocumentFileName,
getDocumentContentDisposition
}; };

View File

@@ -102,6 +102,27 @@ BEGIN
ALTER TABLE Accounts ALTER COLUMN AccountPassword NVARCHAR(2048) NULL; ALTER TABLE Accounts ALTER COLUMN AccountPassword NVARCHAR(2048) NULL;
END END
-- ===========================================
-- 4. CREATE 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);
PRINT 'Table Documents created successfully.';
END
-- =========================================== -- ===========================================
-- 4. CREATE ASSET INVENTORY TABLE -- 4. CREATE ASSET INVENTORY TABLE
-- =========================================== -- ===========================================

View File

@@ -38,3 +38,4 @@ services:
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30} EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30} PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}
DOCUMENT_MAX_FILE_SIZE_MB: ${DOCUMENT_MAX_FILE_SIZE_MB:-25}

View File

@@ -40,3 +40,4 @@ services:
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30} EMAIL_VERIFY_TOKEN_TTL_MINUTES: ${EMAIL_VERIFY_TOKEN_TTL_MINUTES:-30}
PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30} PASSWORD_RESET_TOKEN_TTL_MINUTES: ${PASSWORD_RESET_TOKEN_TTL_MINUTES:-30}
DOCUMENT_MAX_FILE_SIZE_MB: ${DOCUMENT_MAX_FILE_SIZE_MB:-25}

File diff suppressed because one or more lines are too long

View File

@@ -65,6 +65,7 @@ class AccountManager {
this.users = []; this.users = [];
this.assets = []; this.assets = [];
this.consumables = []; this.consumables = [];
this.documents = [];
this.roles = []; this.roles = [];
this.accountPage = 1; this.accountPage = 1;
this.accountPageSize = 9; this.accountPageSize = 9;
@@ -98,6 +99,8 @@ class AccountManager {
this.consumableExportRecipientFilter = ''; this.consumableExportRecipientFilter = '';
this.consumableExportProjectFilter = ''; this.consumableExportProjectFilter = '';
this.consumableExportDateFilter = ''; this.consumableExportDateFilter = '';
this.documentSearchTerm = '';
this.documentMaxFileSizeMb = 25;
this.assetBorrows = []; this.assetBorrows = [];
this.assetBorrowSearchTerm = ''; this.assetBorrowSearchTerm = '';
this.assetBorrowTypeFilter = ''; this.assetBorrowTypeFilter = '';
@@ -259,6 +262,7 @@ class AccountManager {
await this.fetchAssetBorrows(); await this.fetchAssetBorrows();
await this.fetchAssetDepartments(); await this.fetchAssetDepartments();
await this.fetchAssetProjects(); await this.fetchAssetProjects();
await this.fetchDocuments();
if (this.canCurrentUserManageAssets()) { if (this.canCurrentUserManageAssets()) {
await this.fetchUsers(); await this.fetchUsers();
@@ -338,6 +342,9 @@ class AccountManager {
this.setupAddButtonListeners(); this.setupAddButtonListeners();
this.setupFilters(); this.setupFilters();
this.setupAccountPagerListeners(); this.setupAccountPagerListeners();
} else if (page === 'documents') {
mainContent.innerHTML = this.getDocumentsContent();
this.setupDocumentListeners();
} else if (page === 'users') { } else if (page === 'users') {
// Check if user is admin // Check if user is admin
if (!this.isCurrentUserAdmin()) { if (!this.isCurrentUserAdmin()) {
@@ -481,6 +488,25 @@ class AccountManager {
} }
} }
async fetchDocuments(search = '') {
try {
const query = String(search || '').trim();
const url = query
? `${this.apiBase}/documents?search=${encodeURIComponent(query)}`
: `${this.apiBase}/documents`;
const response = await fetch(url, { cache: 'no-store' });
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Không thể tải danh sách tài liệu');
}
this.documents = Array.isArray(data.data) ? data.data : [];
this.documentMaxFileSizeMb = Number(data.maxFileSizeMb) || 25;
} catch (err) {
console.error('Fetch documents error:', err);
this.documents = [];
}
}
async fetchAccountSecret(accountId) { async fetchAccountSecret(accountId) {
const response = await fetch(`${this.apiBase}/accounts/${accountId}/secret`, { cache: 'no-store' }); const response = await fetch(`${this.apiBase}/accounts/${accountId}/secret`, { cache: 'no-store' });
const data = await response.json(); const data = await response.json();
@@ -2737,6 +2763,310 @@ class AccountManager {
`; `;
} }
formatFileSize(value) {
const bytes = Number(value) || 0;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
getFilteredDocuments() {
const keyword = String(this.documentSearchTerm || '').trim().toLocaleLowerCase('vi');
if (!keyword) return this.documents;
return this.documents.filter(document => [
document?.Title,
document?.OriginalFileName,
document?.UploadedByName
].some(value => String(value || '').toLocaleLowerCase('vi').includes(keyword)));
}
getDocumentsContent() {
const documents = this.getFilteredDocuments();
const totalSize = this.documents.reduce((sum, document) => sum + (Number(document?.FileSize) || 0), 0);
return `
<div class="documents-page p-4 md:p-6 w-full h-full overflow-y-auto">
<div class="page-header flex items-end justify-between gap-4 mb-5">
<div>
<div class="flex items-center gap-2 text-red-600 mb-1">
<span class="material-symbols-outlined">picture_as_pdf</span>
<span class="text-xs font-extrabold uppercase tracking-widest">Kho tài liệu</span>
</div>
<h1 class="text-2xl font-extrabold text-slate-900 dark:text-slate-50">Tài liệu PDF</h1>
<p class="text-sm text-slate-500 mt-1">Lưu trữ, xem và tải xuống tài liệu dùng chung.</p>
</div>
<div class="document-header-actions flex flex-wrap items-center justify-end gap-3">
<div class="inline-flex h-10 items-center gap-2 whitespace-nowrap px-1 text-xs text-slate-500" aria-label="${this.documents.length} tài liệu, tổng dung lượng ${this.formatFileSize(totalSize)}">
<span class="material-symbols-outlined text-lg text-slate-400">folder</span>
<span><strong class="font-extrabold text-slate-700">${this.documents.length}</strong> tài liệu</span>
<span class="text-slate-300" aria-hidden="true">•</span>
<span class="font-semibold text-slate-600">${this.formatFileSize(totalSize)}</span>
</div>
<button id="openDocumentUploadDialog" type="button" class="inline-flex items-center justify-center gap-2 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm transition hover:bg-red-700 active:scale-95">
<span class="material-symbols-outlined text-base">upload_file</span>
<span>Thêm tài liệu</span>
</button>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div class="page-filters flex items-center gap-3 border-b border-slate-200 p-4">
<div class="relative flex-1">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
<input id="documentSearch" type="search" value="${this.escapeHtml(this.documentSearchTerm)}" placeholder="Tìm theo tên tài liệu, tên file hoặc người tải lên..."
class="w-full rounded-lg border-slate-200 py-2 pl-10 pr-3 text-sm focus:border-primary focus:ring-primary" />
</div>
<span class="text-xs font-semibold text-slate-500 whitespace-nowrap">${documents.length} kết quả</span>
</div>
${documents.length ? `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 p-4">
${documents.map(document => {
const documentId = Number(document?.DocumentId);
const canDelete = Boolean(document?.CanDelete);
return `
<article class="flex min-w-0 gap-4 rounded-xl border border-slate-200 p-4 transition hover:border-red-200 hover:shadow-sm">
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-red-100 text-red-600">
<span class="material-symbols-outlined text-3xl">picture_as_pdf</span>
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate font-extrabold text-slate-800" title="${this.escapeHtml(document?.Title || '')}">${this.escapeHtml(document?.Title || 'Tài liệu PDF')}</h2>
<p class="mt-0.5 truncate text-xs text-slate-500" title="${this.escapeHtml(document?.OriginalFileName || '')}">${this.escapeHtml(document?.OriginalFileName || '-')}</p>
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span class="inline-flex items-center gap-1"><span class="material-symbols-outlined text-sm">hard_drive</span>${this.formatFileSize(document?.FileSize)}</span>
<span class="inline-flex items-center gap-1"><span class="material-symbols-outlined text-sm">person</span>${this.escapeHtml(document?.UploadedByName || '-')}</span>
<span class="inline-flex items-center gap-1"><span class="material-symbols-outlined text-sm">schedule</span>${this.formatDateTime(document?.UploadedDate)}</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<a href="${this.apiBase}/documents/${documentId}/file" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-white hover:bg-primary-dim">
<span class="material-symbols-outlined text-base">visibility</span>Xem PDF
</a>
<a href="${this.apiBase}/documents/${documentId}/file?download=1" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-bold text-slate-700 hover:bg-slate-50">
<span class="material-symbols-outlined text-base">download</span>Tải xuống
</a>
${canDelete ? `
<button type="button" class="delete-document ml-auto inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-xs font-bold text-red-600 hover:bg-red-50" data-document-id="${documentId}" data-document-title="${this.escapeHtml(document?.Title || document?.OriginalFileName || 'Tài liệu')}">
<span class="material-symbols-outlined text-base">delete</span>Xóa
</button>
` : ''}
</div>
</div>
</article>
`;
}).join('')}
</div>
` : `
<div class="flex flex-col items-center justify-center px-6 py-16 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<span class="material-symbols-outlined text-4xl">folder_open</span>
</div>
<h2 class="mt-4 font-extrabold text-slate-700">${this.documentSearchTerm ? 'Không tìm thấy tài liệu' : 'Chưa có tài liệu PDF'}</h2>
<p class="mt-1 max-w-md text-sm text-slate-500">${this.documentSearchTerm ? 'Hãy thử một từ khóa khác.' : 'Bấm “Thêm tài liệu” để lưu tài liệu đầu tiên.'}</p>
</div>
`}
</div>
</div>
<dialog id="documentUploadDialog" aria-labelledby="documentUploadDialogTitle" class="m-auto w-[min(92vw,34rem)] max-w-none overflow-hidden rounded-2xl border-0 bg-white p-0 text-slate-800 shadow-2xl backdrop:bg-slate-950/50 backdrop:backdrop-blur-sm">
<form id="documentUploadForm">
<div class="flex items-start justify-between gap-4 border-b border-slate-200 bg-red-50 px-5 py-4">
<div class="flex min-w-0 items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-100 text-red-600">
<span class="material-symbols-outlined">upload_file</span>
</div>
<div class="min-w-0">
<h2 id="documentUploadDialogTitle" class="text-lg font-extrabold text-slate-900">Thêm tài liệu PDF</h2>
<p class="mt-0.5 text-xs text-slate-500">Tải tài liệu lên kho dùng chung.</p>
</div>
</div>
<button type="button" data-close-document-dialog class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-800 disabled:cursor-not-allowed disabled:opacity-50" aria-label="Đóng hộp thoại">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="space-y-4 px-5 py-5">
<div>
<label for="documentFile" class="mb-1.5 block text-sm font-bold text-slate-700">Chọn file PDF <span class="text-red-600">*</span></label>
<input id="documentFile" name="file" type="file" accept="application/pdf,.pdf" required
class="block h-11 w-full rounded-lg border border-slate-200 bg-white p-0 pr-3 text-sm file:mr-4 file:h-full file:border-0 file:bg-slate-100 file:px-4 file:font-bold file:text-slate-700 hover:file:bg-slate-200" />
<p class="mt-1.5 text-xs text-slate-500">Chỉ nhận định dạng PDF, tối đa ${this.documentMaxFileSizeMb} MB.</p>
</div>
<div>
<label for="documentTitle" class="mb-1.5 block text-sm font-bold text-slate-700">Tên hiển thị <span class="font-normal text-slate-400">(không bắt buộc)</span></label>
<input id="documentTitle" name="title" maxlength="255" placeholder="Mặc định lấy theo tên file"
class="h-11 w-full rounded-lg border-slate-200 bg-white text-sm focus:border-red-400 focus:ring-red-400" />
</div>
</div>
<div class="flex flex-col-reverse gap-2 border-t border-slate-200 bg-slate-50 px-5 py-4 sm:flex-row sm:justify-end">
<button type="button" data-close-document-dialog class="inline-flex h-10 items-center justify-center rounded-lg border border-slate-300 bg-white px-4 text-sm font-bold text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50">Hủy</button>
<button id="documentUploadSubmit" type="submit" class="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-red-600 px-5 text-sm font-bold text-white shadow-sm transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60">
<span class="material-symbols-outlined text-base">upload_file</span>
<span>Tải lên</span>
</button>
</div>
</form>
</dialog>
`;
}
setupDocumentListeners() {
const dialog = document.getElementById('documentUploadDialog');
const form = document.getElementById('documentUploadForm');
const openDialogButton = document.getElementById('openDocumentUploadDialog');
const closeDialog = () => {
if (!dialog || form?.dataset.uploading === 'true') return;
if (typeof dialog.close === 'function' && dialog.open) {
dialog.close();
} else {
dialog.removeAttribute('open');
}
};
if (openDialogButton && dialog && !openDialogButton.dataset.boundClick) {
openDialogButton.addEventListener('click', () => {
if (typeof dialog.showModal === 'function') {
if (!dialog.open) dialog.showModal();
} else {
dialog.setAttribute('open', '');
}
requestAnimationFrame(() => document.getElementById('documentFile')?.focus());
});
openDialogButton.dataset.boundClick = 'true';
}
document.querySelectorAll('[data-close-document-dialog]').forEach(button => {
if (button.dataset.boundClick) return;
button.addEventListener('click', closeDialog);
button.dataset.boundClick = 'true';
});
if (dialog && !dialog.dataset.boundDialog) {
dialog.addEventListener('cancel', event => {
if (form?.dataset.uploading === 'true') event.preventDefault();
});
dialog.addEventListener('close', () => form?.reset());
dialog.addEventListener('click', event => {
if (event.target !== dialog || form?.dataset.uploading === 'true') return;
const bounds = dialog.getBoundingClientRect();
const outside = event.clientX < bounds.left
|| event.clientX > bounds.right
|| event.clientY < bounds.top
|| event.clientY > bounds.bottom;
if (outside) closeDialog();
});
dialog.dataset.boundDialog = 'true';
}
if (form && !form.dataset.boundSubmit) {
form.addEventListener('submit', event => this.handleDocumentUpload(event));
form.dataset.boundSubmit = 'true';
}
const searchInput = document.getElementById('documentSearch');
if (searchInput && !searchInput.dataset.boundInput) {
searchInput.addEventListener('input', event => {
const cursor = event.target.selectionStart ?? event.target.value.length;
this.documentSearchTerm = event.target.value;
this.renderView('documents');
const nextInput = document.getElementById('documentSearch');
nextInput?.focus();
nextInput?.setSelectionRange(cursor, cursor);
});
searchInput.dataset.boundInput = 'true';
}
document.querySelectorAll('.delete-document').forEach(button => {
if (button.dataset.boundClick) return;
button.addEventListener('click', () => {
this.deleteDocument(button.dataset.documentId, button.dataset.documentTitle);
});
button.dataset.boundClick = 'true';
});
}
async handleDocumentUpload(event) {
event.preventDefault();
const form = event.currentTarget;
const fileInput = form.querySelector('#documentFile');
const titleInput = form.querySelector('#documentTitle');
const submitButton = form.querySelector('#documentUploadSubmit');
const file = fileInput?.files?.[0];
if (!file) {
this.notifyWarning('Vui lòng chọn file PDF');
return;
}
const extensionIsPdf = String(file.name || '').toLowerCase().endsWith('.pdf');
if (!extensionIsPdf || (file.type && file.type !== 'application/pdf')) {
this.notifyFailure('Chỉ chấp nhận file PDF');
return;
}
if (file.size > this.documentMaxFileSizeMb * 1024 * 1024) {
this.notifyFailure(`File vượt quá ${this.documentMaxFileSizeMb} MB`);
return;
}
const payload = new FormData();
payload.append('file', file);
payload.append('title', String(titleInput?.value || '').trim());
form.dataset.uploading = 'true';
form.querySelectorAll('[data-close-document-dialog]').forEach(button => {
button.disabled = true;
});
if (submitButton) {
submitButton.disabled = true;
submitButton.querySelector('span:last-child').textContent = 'Đang tải...';
}
try {
const response = await fetch(`${this.apiBase}/documents`, {
method: 'POST',
body: payload
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Không thể tải tài liệu lên');
}
this.documentSearchTerm = '';
await this.fetchDocuments();
this.renderView('documents');
this.notifySuccess(data.message || 'Đã lưu tài liệu PDF');
} catch (err) {
console.error('Upload document error:', err);
this.notifyFailure(err.message || 'Không thể tải tài liệu lên');
delete form.dataset.uploading;
form.querySelectorAll('[data-close-document-dialog]').forEach(button => {
button.disabled = false;
});
if (submitButton) {
submitButton.disabled = false;
submitButton.querySelector('span:last-child').textContent = 'Tải lên';
}
}
}
async deleteDocument(documentId, title) {
const parsedId = Number.parseInt(documentId, 10);
if (!Number.isInteger(parsedId) || parsedId <= 0) return;
if (!window.confirm(`Xóa tài liệu “${String(title || 'Tài liệu')}”? Thao tác này không thể hoàn tác.`)) return;
try {
const response = await fetch(`${this.apiBase}/documents/${parsedId}`, { method: 'DELETE' });
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Không thể xóa tài liệu');
}
await this.fetchDocuments();
this.renderView('documents');
this.notifySuccess(data.message || 'Đã xóa tài liệu');
} catch (err) {
console.error('Delete document error:', err);
this.notifyFailure(err.message || 'Không thể xóa tài liệu');
}
}
getApplicationsContent() { getApplicationsContent() {
const filteredApps = this.getFilteredApplications(); const filteredApps = this.getFilteredApplications();
const pageInfo = this.getPaged(filteredApps, this.appPage, this.appPageSize); const pageInfo = this.getPaged(filteredApps, this.appPage, this.appPageSize);

View File

@@ -9,7 +9,7 @@
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
<!-- Material Symbols --> <!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/> <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="../css/main.css" /> <link rel="stylesheet" href="../css/main.css?v=20260806-4" />
<!-- Notiflix Notify --> <!-- Notiflix Notify -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
@@ -281,7 +281,7 @@
</a> </a>
</div> </div>
<!-- Primary Nav --> <!-- Primary Nav -->
<nav class="flex-1 px-3 space-y-4"> <nav class="flex-1 px-3 space-y-4 overflow-y-auto">
<div> <div>
<div class="tree-label">Tài khoản</div> <div class="tree-label">Tài khoản</div>
<div class="tree-branch"> <div class="tree-branch">
@@ -340,6 +340,16 @@
</div> </div>
</div> </div>
<div>
<div class="tree-label">Tài liệu</div>
<div class="tree-branch">
<a href="#documents" data-nav="documents" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
<span class="material-symbols-outlined">picture_as_pdf</span>
<span>Tài liệu PDF</span>
</a>
</div>
</div>
<div id="usersSection" class="pt-2 border-t border-outline-variant/10" style="display: none;"> <div id="usersSection" class="pt-2 border-t border-outline-variant/10" style="display: none;">
<a id="usersNav" href="#users" data-nav="users" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-lg" style="display: none;"> <a id="usersNav" href="#users" data-nav="users" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-lg" style="display: none;">
<span class="material-symbols-outlined">people</span> <span class="material-symbols-outlined">people</span>
@@ -390,6 +400,6 @@
</div> </div>
</main> </main>
<script src="../js/app.js?v=20260717-1"></script> <script src="../js/app.js?v=20260806-4"></script>
</body> </body>
</html> </html>

View File

@@ -9,7 +9,10 @@ const {
encryptSensitiveValue, encryptSensitiveValue,
decryptSensitiveValue, decryptSensitiveValue,
hashSessionToken, hashSessionToken,
normalizeOptionalHttpUrl normalizeOptionalHttpUrl,
isPdfBuffer,
sanitizeDocumentFileName,
getDocumentContentDisposition
} = require('../backend/server'); } = require('../backend/server');
async function withTestServer(run) { async function withTestServer(run) {
@@ -55,6 +58,26 @@ test('application URLs accept only HTTP and HTTPS protocols', () => {
assert.match(normalizeOptionalHttpUrl('https://example.com/path'), /^https:\/\/example\.com\/path/); assert.match(normalizeOptionalHttpUrl('https://example.com/path'), /^https:\/\/example\.com\/path/);
}); });
test('PDF uploads require a PDF header and end-of-file marker', () => {
assert.equal(isPdfBuffer(Buffer.from('%PDF-1.7\nbody\n%%EOF')), true);
assert.equal(isPdfBuffer(Buffer.from('%PDF-1.7\nbody without trailer')), false);
assert.equal(isPdfBuffer(Buffer.from('<html>not a pdf</html>')), false);
});
test('document filenames and response headers cannot inject control characters', () => {
const safeName = sanitizeDocumentFileName('bao/cao\r\nInjected: value');
const longName = sanitizeDocumentFileName('a'.repeat(300));
const disposition = getDocumentContentDisposition('Báo cáo tháng 8.pdf');
assert.equal(safeName, 'bao_caoInjected_ value.pdf');
assert.equal(longName.length, 255);
assert.match(longName, /\.pdf$/);
assert.match(disposition, /^inline; filename="/);
assert.match(disposition, /filename\*=UTF-8''/);
assert.equal(disposition.includes('\r'), false);
assert.equal(disposition.includes('\n'), false);
});
test('forged legacy identity headers cannot bypass protected APIs', async () => { test('forged legacy identity headers cannot bypass protected APIs', async () => {
await withTestServer(async baseUrl => { await withTestServer(async baseUrl => {
const response = await fetch(`${baseUrl}/api/users`, { const response = await fetch(`${baseUrl}/api/users`, {