update docs
This commit is contained in:
@@ -8,6 +8,7 @@ const path = require('path');
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const repository = require('./src/repository');
|
||||
const { normalizeDocumentFileName } = require('./src/document-file-name');
|
||||
const notificationRepository = require('./src/notification-repository');
|
||||
const mailer = require('./src/mailer');
|
||||
const { closePool, getPool } = require('./src/db');
|
||||
@@ -16,6 +17,7 @@ const notiflixVersion = require('notiflix/package.json').version;
|
||||
const app = express();
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const uploadDir = path.join(__dirname, 'uploads', 'packages');
|
||||
const documentUploadDir = path.join(__dirname, 'uploads', 'documents');
|
||||
const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent'));
|
||||
const agentDebianPackageName = 'local-installer-agent';
|
||||
const authCookieName = 'robot_installer_session';
|
||||
@@ -31,6 +33,23 @@ const installerIdentifierPattern = /^[a-zA-Z0-9._+-]+$/;
|
||||
const installerVersionPattern = /^[a-zA-Z0-9._:+~=-]+$/;
|
||||
const installerIdentifierHint = 'Code chi duoc dung chu, so, dau ., _, +, - va khong co khoang trang.';
|
||||
const installerVersionHint = 'Version chi duoc dung chu, so va cac ky tu . _ : + ~ = -.';
|
||||
const documentCategories = Object.freeze([
|
||||
{ id: 'introduction', label: 'Giới thiệu' },
|
||||
{ id: 'guide', label: 'Hướng dẫn' },
|
||||
{ id: 'user-guide', label: 'Hướng dẫn sử dụng' },
|
||||
{ id: 'technical', label: 'Tài liệu kỹ thuật' },
|
||||
{ id: 'policy', label: 'Quy trình / chính sách' },
|
||||
{ id: 'other', label: 'Khác' }
|
||||
]);
|
||||
const documentCategoryIds = new Set(documentCategories.map((category) => category.id));
|
||||
const allowedDocumentExtensions = new Set([
|
||||
'.pdf', '.doc', '.docx', '.odt', '.rtf', '.txt', '.md',
|
||||
'.png', '.jpg', '.jpeg', '.webp',
|
||||
'.ppt', '.pptx', '.xls', '.xlsx'
|
||||
]);
|
||||
const documentTextExtensions = new Set(['.txt', '.md']);
|
||||
const documentImageExtensions = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
const documentMaxContentChars = Number(process.env.DOCUMENT_MAX_CONTENT_CHARS || 500000);
|
||||
const agentVersionCollator = new Intl.Collator('en', {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
@@ -51,12 +70,14 @@ app.get('/readyz', asyncRoute(async (req, res) => {
|
||||
}));
|
||||
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
fs.mkdirSync(documentUploadDir, { recursive: true });
|
||||
fs.mkdirSync(agentPackageDir, { recursive: true });
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
|
||||
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
|
||||
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
|
||||
{ id: 'documents', label: 'Tài liệu', href: '/documents', icon: 'library_books' },
|
||||
{ id: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' },
|
||||
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
|
||||
{ id: 'users', label: 'Users', href: '/users', icon: 'group', adminOnly: true }
|
||||
@@ -123,6 +144,24 @@ const upload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
const documentStorage = multer.diskStorage({
|
||||
destination: documentUploadDir,
|
||||
filename(req, file, callback) {
|
||||
const normalizedName = normalizeDocumentFileName(file.originalname)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-zA-Z0-9._-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.toLowerCase();
|
||||
const extension = path.extname(normalizedName).slice(0, 20);
|
||||
const baseName = normalizedName.slice(0, normalizedName.length - extension.length).slice(0, 180) || 'document';
|
||||
const safeName = `${baseName}${extension}`;
|
||||
const suffix = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`;
|
||||
|
||||
callback(null, `${suffix}-${safeName}`);
|
||||
}
|
||||
});
|
||||
|
||||
const agentUpload = multer({
|
||||
storage: agentStorage,
|
||||
limits: {
|
||||
@@ -131,6 +170,15 @@ const agentUpload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
const documentUpload = multer({
|
||||
storage: documentStorage,
|
||||
limits: {
|
||||
...multipartLimits,
|
||||
fieldSize: Number(process.env.DOCUMENT_MAX_CONTENT_BYTES || 2 * 1024 * 1024),
|
||||
fileSize: Number(process.env.DOCUMENT_MAX_UPLOAD_BYTES || 50 * 1024 * 1024)
|
||||
}
|
||||
});
|
||||
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
|
||||
@@ -227,6 +275,9 @@ function helpers() {
|
||||
if (type === 'docker') return 'badge-info';
|
||||
if (type === 'apt') return 'badge-warning';
|
||||
return 'badge-primary';
|
||||
},
|
||||
documentCategoryLabel(categoryId) {
|
||||
return documentCategories.find((category) => category.id === categoryId)?.label || 'Khác';
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1037,6 +1088,128 @@ async function getArtifactFromUpload(file) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDocumentCategory(value) {
|
||||
const category = String(value || '').trim().toLowerCase();
|
||||
return documentCategoryIds.has(category) ? category : '';
|
||||
}
|
||||
|
||||
function getDocumentUploadValidationMessage(file) {
|
||||
if (!file) return '';
|
||||
|
||||
const originalFileName = normalizeDocumentFileName(file.originalname);
|
||||
|
||||
if (originalFileName.length > 260) {
|
||||
return 'Tên file tài liệu không được vượt quá 260 ký tự.';
|
||||
}
|
||||
|
||||
const extension = path.extname(originalFileName).toLowerCase();
|
||||
if (!allowedDocumentExtensions.has(extension)) {
|
||||
return 'Định dạng tài liệu chưa được hỗ trợ. Hãy dùng PDF, Word, OpenDocument, text/Markdown, ảnh, PowerPoint hoặc Excel.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getDocumentArtifact(file) {
|
||||
if (!file) {
|
||||
return {
|
||||
filePath: null,
|
||||
originalFileName: null,
|
||||
mimeType: null,
|
||||
fileSizeBytes: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: `/uploads/documents/${file.filename}`,
|
||||
originalFileName: normalizeDocumentFileName(file.originalname),
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
fileSizeBytes: file.size
|
||||
};
|
||||
}
|
||||
|
||||
function getLocalDocumentFilePath(filePath) {
|
||||
const storedPath = String(filePath || '').trim();
|
||||
const prefix = '/uploads/documents/';
|
||||
|
||||
if (!storedPath.startsWith(prefix)) return null;
|
||||
|
||||
let relativePath;
|
||||
try {
|
||||
relativePath = decodeURIComponent(storedPath.slice(prefix.length));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!relativePath || relativePath.includes('\0')) return null;
|
||||
|
||||
const documentRoot = path.resolve(documentUploadDir);
|
||||
const localPath = path.resolve(documentRoot, relativePath);
|
||||
const pathDelta = path.relative(documentRoot, localPath);
|
||||
|
||||
if (!pathDelta || pathDelta.startsWith('..') || path.isAbsolute(pathDelta)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return localPath;
|
||||
}
|
||||
|
||||
async function removeStoredDocumentFile(filePath) {
|
||||
const localPath = getLocalDocumentFilePath(filePath);
|
||||
if (!localPath) return;
|
||||
|
||||
try {
|
||||
await fsp.unlink(localPath);
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.warn(`Cannot remove document file ${localPath}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDocumentPreviewKind(document) {
|
||||
if (!document?.filePath) return '';
|
||||
|
||||
const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
|
||||
if (extension === '.pdf') return 'pdf';
|
||||
if (documentImageExtensions.has(extension)) return 'image';
|
||||
if (documentTextExtensions.has(extension)) return 'text';
|
||||
return '';
|
||||
}
|
||||
|
||||
function getDocumentResponseMimeType(document) {
|
||||
const extension = path.extname(document.originalFileName || document.filePath).toLowerCase();
|
||||
const knownTypes = {
|
||||
'.pdf': 'application/pdf',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.md': 'text/plain; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp'
|
||||
};
|
||||
|
||||
return knownTypes[extension] || document.mimeType || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function setDocumentContentDisposition(res, disposition, fileName) {
|
||||
const originalName = String(fileName || 'document')
|
||||
.replace(/[\r\n]/g, '')
|
||||
.slice(0, 260);
|
||||
const asciiName = originalName
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^\x20-\x7E]/g, '_')
|
||||
.replace(/["\\;]/g, '_') || 'document';
|
||||
const encodedName = encodeURIComponent(originalName)
|
||||
.replace(/['()]/g, (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
|
||||
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`${disposition}; filename="${asciiName}"; filename*=UTF-8''${encodedName}`
|
||||
);
|
||||
}
|
||||
|
||||
async function getDebUploadMetadataValidationMessage(file, packageCode, version) {
|
||||
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
|
||||
|
||||
@@ -1568,7 +1741,7 @@ exit 1
|
||||
});
|
||||
|
||||
app.use(requireAuthenticated);
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
app.use('/uploads/packages', express.static(uploadDir));
|
||||
|
||||
app.get('/api/notifications', asyncRoute(async (req, res) => {
|
||||
const countOnly = String(req.query.countOnly || '').toLowerCase() === 'true';
|
||||
@@ -2309,6 +2482,228 @@ app.get('/applications/:id', asyncRoute(async (req, res) => {
|
||||
);
|
||||
}));
|
||||
|
||||
app.get('/documents', asyncRoute(async (req, res) => {
|
||||
const [pageData, documents] = await Promise.all([
|
||||
repository.getPageData(req.currentUser),
|
||||
repository.listDocuments()
|
||||
]);
|
||||
|
||||
res.render(
|
||||
'documents',
|
||||
viewModel(req, 'documents', 'Tài liệu', pageData, { documents, documentCategories })
|
||||
);
|
||||
}));
|
||||
|
||||
app.post('/documents', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
|
||||
try {
|
||||
const title = String(req.body.title || '').trim();
|
||||
const category = normalizeDocumentCategory(req.body.category);
|
||||
const summary = String(req.body.summary || '').trim();
|
||||
const content = String(req.body.content || '').trim();
|
||||
const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
|
||||
|
||||
if (!title || title.length > 200) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summary.length > 1000 || content.length > documentMaxContentChars) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileValidationMessage) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', fileValidationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content && !req.file) {
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Hãy nhập nội dung hoặc đính kèm ít nhất một file tài liệu.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = getDocumentArtifact(req.file);
|
||||
await repository.createDocument({
|
||||
title,
|
||||
category,
|
||||
summary,
|
||||
content,
|
||||
...artifact,
|
||||
createdByUserId: req.currentUser.id
|
||||
});
|
||||
|
||||
redirectWithNotice(res, '/documents', 'success', 'Đã lưu tài liệu mới.');
|
||||
} catch (error) {
|
||||
await removeUploadedFile(req.file);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
app.get('/documents/:id/file', asyncRoute(async (req, res) => {
|
||||
const document = await repository.getDocumentById(req.params.id);
|
||||
const localPath = document ? getLocalDocumentFilePath(document.filePath) : null;
|
||||
|
||||
if (!document || !localPath) {
|
||||
res.status(404).type('text/plain').send('Không tìm thấy file tài liệu.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fsp.access(localPath, fs.constants.R_OK);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.status(404).type('text/plain').send('File tài liệu không còn tồn tại trên máy chủ.');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const previewKind = getDocumentPreviewKind(document);
|
||||
const shouldDownload = req.query.download === '1' || !previewKind;
|
||||
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Type', getDocumentResponseMimeType(document));
|
||||
setDocumentContentDisposition(
|
||||
res,
|
||||
shouldDownload ? 'attachment' : 'inline',
|
||||
document.originalFileName || path.basename(localPath)
|
||||
);
|
||||
res.sendFile(localPath);
|
||||
}));
|
||||
|
||||
app.post('/documents/:id/edit', documentUpload.single('documentFile'), asyncRoute(async (req, res) => {
|
||||
const documentId = String(req.params.id || '').trim();
|
||||
|
||||
try {
|
||||
const existingDocument = await repository.getDocumentById(documentId);
|
||||
if (!existingDocument) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
|
||||
return;
|
||||
}
|
||||
|
||||
const title = String(req.body.title || '').trim();
|
||||
const category = normalizeDocumentCategory(req.body.category);
|
||||
const summary = String(req.body.summary || '').trim();
|
||||
const content = String(req.body.content || '').trim();
|
||||
const removeAttachment = req.body.removeAttachment === '1';
|
||||
const fileValidationMessage = getDocumentUploadValidationMessage(req.file);
|
||||
const willHaveAttachment = Boolean(req.file) || (Boolean(existingDocument.filePath) && !removeAttachment);
|
||||
|
||||
if (!title || title.length > 200) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tiêu đề tài liệu là bắt buộc và không được vượt quá 200 ký tự.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Vui lòng chọn nhóm tài liệu hợp lệ.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summary.length > 1000 || content.length > documentMaxContentChars) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Mô tả hoặc nội dung tài liệu vượt quá độ dài cho phép.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileValidationMessage) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', fileValidationMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content && !willHaveAttachment) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'warning', 'Tài liệu phải có nội dung hoặc file đính kèm.');
|
||||
return;
|
||||
}
|
||||
|
||||
const artifact = getDocumentArtifact(req.file);
|
||||
const replaceAttachment = Boolean(req.file) || removeAttachment;
|
||||
const result = await repository.updateDocument({
|
||||
documentId,
|
||||
title,
|
||||
category,
|
||||
summary,
|
||||
content,
|
||||
replaceAttachment,
|
||||
...artifact
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
await removeUploadedFile(req.file);
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần cập nhật.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
result.attachmentChanged
|
||||
&& result.previousFilePath
|
||||
&& result.previousFilePath !== result.currentFilePath
|
||||
) {
|
||||
await removeStoredDocumentFile(result.previousFilePath);
|
||||
}
|
||||
|
||||
redirectWithNotice(res, `/documents/${documentId}`, 'success', 'Đã cập nhật tài liệu.');
|
||||
} catch (error) {
|
||||
await removeUploadedFile(req.file);
|
||||
throw error;
|
||||
}
|
||||
}));
|
||||
|
||||
app.post('/documents/:id/delete', asyncRoute(async (req, res) => {
|
||||
const documentId = String(req.params.id || '').trim();
|
||||
|
||||
if (!isUuid(documentId)) {
|
||||
redirectWithNotice(res, '/documents', 'warning', 'Không tìm thấy tài liệu cần xóa.');
|
||||
return;
|
||||
}
|
||||
|
||||
const deletedDocument = await repository.deleteDocument(documentId);
|
||||
if (deletedDocument?.filePath) {
|
||||
await removeStoredDocumentFile(deletedDocument.filePath);
|
||||
}
|
||||
|
||||
redirectWithNotice(
|
||||
res,
|
||||
'/documents',
|
||||
deletedDocument ? 'success' : 'warning',
|
||||
deletedDocument ? 'Đã xóa tài liệu và file đính kèm.' : 'Không tìm thấy tài liệu cần xóa.'
|
||||
);
|
||||
}));
|
||||
|
||||
app.get('/documents/:id', asyncRoute(async (req, res) => {
|
||||
const [pageData, document] = await Promise.all([
|
||||
repository.getPageData(req.currentUser),
|
||||
repository.getDocumentById(req.params.id)
|
||||
]);
|
||||
|
||||
if (!document) {
|
||||
res.status(404).render('not-found', viewModel(req, 'documents', 'Không tìm thấy', pageData));
|
||||
return;
|
||||
}
|
||||
|
||||
res.render(
|
||||
'document-detail',
|
||||
viewModel(req, 'documents', document.title, pageData, {
|
||||
document,
|
||||
documentCategories,
|
||||
documentPreviewKind: getDocumentPreviewKind(document)
|
||||
})
|
||||
);
|
||||
}));
|
||||
|
||||
app.get('/users', requireAdmin, asyncRoute(async (req, res) => {
|
||||
const [pageData, users] = await Promise.all([
|
||||
repository.getPageData(req.currentUser),
|
||||
@@ -2447,7 +2842,7 @@ app.post('/users/:id/delete', requireAdmin, asyncRoute(async (req, res) => {
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code === 'USER_HAS_OWNED_DATA') {
|
||||
redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package hoặc application. Hãy khóa tài khoản nếu cần.');
|
||||
redirectWithNotice(res, '/users', 'warning', 'Không thể xóa user đang sở hữu package, application hoặc tài liệu. Hãy khóa tài khoản nếu cần.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2472,7 +2867,9 @@ app.use(async (error, req, res, next) => {
|
||||
LIMIT_PART_COUNT: 'Biểu mẫu upload có quá nhiều thành phần.',
|
||||
LIMIT_UNEXPECTED_FILE: 'Trường file upload không hợp lệ.'
|
||||
};
|
||||
const returnPath = req.path.startsWith('/agent/') ? '/agent' : '/packages';
|
||||
const returnPath = req.path.startsWith('/agent/')
|
||||
? '/agent'
|
||||
: (req.path.startsWith('/documents') ? '/documents' : '/packages');
|
||||
|
||||
console.warn(`Rejected multipart upload (${error.code || 'UNKNOWN'}):`, error.message);
|
||||
redirectWithNotice(
|
||||
|
||||
Reference in New Issue
Block a user