update docs

This commit is contained in:
2026-07-22 14:11:18 +07:00
parent a836645728
commit 166276d19b
14 changed files with 1308 additions and 17 deletions

View File

@@ -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(