require('dotenv').config({ quiet: true }); const crypto = require('crypto'); const fs = require('fs'); const fsp = require('fs/promises'); const path = require('path'); const express = require('express'); const multer = require('multer'); const repository = require('./src/repository'); const mailer = require('./src/mailer'); const { closePool } = require('./src/db'); 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 agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent')); const authCookieName = 'robot_installer_session'; const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS || 1000 * 60 * 60 * 8); const authSecret = process.env.AUTH_SECRET || process.env.SESSION_SECRET || 'robot-installer-dev-secret'; const agentVersionCollator = new Intl.Collator('en', { numeric: true, sensitivity: 'base' }); fs.mkdirSync(uploadDir, { 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: '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 } ]; const statusClassMap = { Active: 'badge-success', Latest: 'badge-primary', Stable: 'badge-info', Testing: 'badge-warning', Draft: 'badge-warning', Released: 'badge-success', Archived: 'badge-muted', Deprecated: 'badge-danger', Inactive: 'badge-muted', Admin: 'badge-primary', User: 'badge-info' }; const storage = multer.diskStorage({ destination: uploadDir, filename(req, file, callback) { const safeName = file.originalname .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9._-]/g, '-') .replace(/-+/g, '-') .toLowerCase(); const suffix = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; callback(null, `${suffix}-${safeName}`); } }); const agentStorage = multer.diskStorage({ destination: agentPackageDir, filename(req, file, callback) { const safeName = file.originalname .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') .replace(/[^a-zA-Z0-9._-]/g, '-') .replace(/-+/g, '-') .toLowerCase(); const suffix = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}`; callback(null, `${suffix}-${safeName}`); } }); const upload = multer({ storage, limits: { fileSize: Number(process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024) } }); const agentUpload = multer({ storage: agentStorage, limits: { fileSize: Number(process.env.AGENT_MAX_UPLOAD_BYTES || process.env.MAX_UPLOAD_BYTES || 1024 * 1024 * 1024) } }); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); app.use(express.urlencoded({ extended: true })); app.use(express.static(path.join(__dirname, 'public'))); app.use('/vendor/notiflix', express.static(path.join(__dirname, 'node_modules/notiflix/dist'))); app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => { const arch = normalizeAgentArch(req.query.arch); const latestPackage = await findLatestAgentPackage(arch); if (latestPackage) { res.type('application/vnd.debian.binary-package'); res.setHeader('Content-Disposition', `attachment; filename="${latestPackage.fileName}"`); res.setHeader('X-Agent-Version', latestPackage.version); res.sendFile(latestPackage.filePath); return; } const latestPackageRecord = await findLatestAgentPackageRecord(arch); if (latestPackageRecord) { res.setHeader('X-Agent-Version', latestPackageRecord.version); res.redirect(latestPackageRecord.filePath); return; } res.status(404).type('text/plain').send( `No Local Installer Agent package found${arch ? ` for ${arch}` : ''}. ` + 'Upload local-installer-agent__.deb to web-server/uploads/packages/agent, ' + 'or upload it as package code local-installer-agent.' ); })); app.use('/uploads/packages', express.static(uploadDir)); app.use('/packages/agent', express.static(agentPackageDir)); app.use(loadCurrentUser); function helpers() { return { statusClass(status) { return statusClassMap[status] || 'badge-muted'; }, packageTypeLabel(type) { return type === 'docker' ? 'Docker' : '.deb'; }, packageTypeClass(type) { return type === 'docker' ? 'badge-info' : 'badge-primary'; } }; } function getVisibleNavItems(user) { return navItems.filter((item) => !item.adminOnly || (user && user.role === 'Admin')); } function getNotice(req) { if (!req.query.notice) return null; return { type: req.query.noticeType || 'info', message: req.query.notice }; } function getCurrentPath(req) { const url = new URL(req.originalUrl || '/', 'http://robot-installer.local'); url.searchParams.delete('notice'); url.searchParams.delete('noticeType'); return `${url.pathname}${url.search}` || '/'; } function viewModel(req, active, title, pageData, extra = {}) { const currentUser = pageData.currentUser || req.currentUser; return { active, title, navItems: getVisibleNavItems(currentUser), currentUser, stats: pageData.stats, packages: pageData.packages, applications: pageData.applications, activity: pageData.activity, notiflixVersion, notice: getNotice(req), currentPath: getCurrentPath(req), helpers: helpers(), ...extra }; } function authViewModel(req, mode, values = {}) { const formValues = mode === 'register' ? { username: values.username || String(req.query.username || ''), email: values.email || String(req.query.email || ''), fullName: values.fullName || String(req.query.fullName || ''), notice: values.notice } : values; return { title: mode === 'register' ? 'Đăng ký' : 'Đăng nhập', mode, notiflixVersion, notice: values.notice || getNotice(req), values: formValues, returnTo: sanitizeReturnTo(req.query.returnTo) }; } function confirmEmailViewModel(req, email = '') { return { title: 'Xác nhận email', notiflixVersion, notice: getNotice(req), email }; } function renderRegisterWithNotice(req, res, type, message) { const query = new URLSearchParams(); const body = req.body || {}; if (body.username) query.set('username', String(body.username).trim()); if (body.email) query.set('email', String(body.email).trim()); if (body.fullName) query.set('fullName', String(body.fullName).trim()); const url = query.toString() ? `/register?${query.toString()}` : '/register'; redirectWithNotice(res, url, type, message); } function asyncRoute(handler) { return (req, res, next) => { Promise.resolve(handler(req, res, next)).catch(next); }; } function redirectWithNotice(res, url, type, message) { const separator = url.includes('?') ? '&' : '?'; res.redirect(`${url}${separator}noticeType=${encodeURIComponent(type)}¬ice=${encodeURIComponent(message)}`); } function parseCookies(cookieHeader) { if (!cookieHeader) return {}; return cookieHeader.split(';').reduce((cookies, pair) => { const index = pair.indexOf('='); if (index === -1) return cookies; const key = pair.slice(0, index).trim(); const value = pair.slice(index + 1).trim(); try { cookies[key] = decodeURIComponent(value); } catch (error) { cookies[key] = value; } return cookies; }, {}); } function signSessionPayload(payload) { return crypto .createHmac('sha256', authSecret) .update(payload) .digest('base64url'); } function createSessionToken(user) { const payload = Buffer .from(JSON.stringify({ sub: user.id, exp: Date.now() + sessionMaxAgeMs })) .toString('base64url'); const signature = signSessionPayload(payload); return `${payload}.${signature}`; } function verifySessionToken(token) { if (!token || !token.includes('.')) return null; const [payload, signature] = token.split('.'); const expectedSignature = signSessionPayload(payload); const signatureBuffer = Buffer.from(signature); const expectedSignatureBuffer = Buffer.from(expectedSignature); if ( signatureBuffer.length !== expectedSignatureBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer) ) { return null; } try { const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); if (!session.sub || Number(session.exp) < Date.now()) return null; return session; } catch (error) { return null; } } function setAuthCookie(res, user) { res.cookie(authCookieName, createSessionToken(user), { httpOnly: true, maxAge: sessionMaxAgeMs, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }); } function clearAuthCookie(res) { res.clearCookie(authCookieName, { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' }); } function sanitizeReturnTo(value) { const fallback = '/'; if (!value || typeof value !== 'string') return fallback; if (!value.startsWith('/') || value.startsWith('//')) return fallback; const [pathname] = value.split('?'); if (pathname === '/login' || pathname === '/register') return fallback; return value; } function getBaseUrl(req) { if (process.env.APP_BASE_URL) { return process.env.APP_BASE_URL.replace(/\/+$/, ''); } const forwardedProtocol = req.headers['x-forwarded-proto']; const protocol = forwardedProtocol ? String(forwardedProtocol).split(',')[0].trim() : req.protocol; return `${protocol}://${req.get('host')}`; } function normalizeAgentArch(value) { const arch = String(value || '').trim().toLowerCase(); return /^[a-z0-9][a-z0-9._-]*$/.test(arch) ? arch : ''; } function isValidAgentVersion(value) { return /^[a-zA-Z0-9][a-zA-Z0-9._+~=-]*$/.test(String(value || '').trim()); } function compareAgentPackages(first, second) { const versionCompare = agentVersionCollator.compare(first.version, second.version); if (versionCompare !== 0) return versionCompare; return first.mtimeMs - second.mtimeMs; } function formatBytes(bytes) { const value = Number(bytes || 0); if (value < 1024) return `${value} B`; const units = ['KB', 'MB', 'GB']; let size = value / 1024; let unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex += 1; } return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`; } function formatLocalDateTime(value) { return new Intl.DateTimeFormat('vi-VN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).format(value); } function getAgentPackageDownloadPath(fileName) { return `/packages/agent/${encodeURIComponent(fileName)}`; } async function getAgentPackageFromEntry(entry) { const match = /^local-installer-agent_([^/\\]+)_([^/\\]+)\.deb$/.exec(entry.name); if (!match) return null; const [, version, packageArch] = match; const filePath = path.join(agentPackageDir, entry.name); const stat = await fsp.stat(filePath); return { fileName: entry.name, filePath, version, arch: packageArch, size: stat.size, sizeLabel: formatBytes(stat.size), uploadedAt: formatLocalDateTime(stat.mtime), mtimeMs: stat.mtimeMs, downloadPath: getAgentPackageDownloadPath(entry.name) }; } async function listAgentPackages(arch = '') { let dirEntries; try { dirEntries = await fsp.readdir(agentPackageDir, { withFileTypes: true }); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } const packages = (await Promise.all( dirEntries .filter((entry) => entry.isFile()) .map(getAgentPackageFromEntry) )) .filter(Boolean) .filter((packageItem) => !arch || packageItem.arch.toLowerCase() === arch); const latestByArch = new Map(); packages .sort(compareAgentPackages); packages.forEach((packageItem) => { latestByArch.set(packageItem.arch.toLowerCase(), packageItem.fileName); }); return packages .sort((first, second) => compareAgentPackages(second, first)) .map((packageItem) => ({ ...packageItem, isLatestForArch: latestByArch.get(packageItem.arch.toLowerCase()) === packageItem.fileName })); } async function findLatestAgentPackage(arch = '') { const packages = await listAgentPackages(arch); const sortedPackages = packages .slice() .sort(compareAgentPackages); return sortedPackages[sortedPackages.length - 1] || null; } function packageVersionMatchesArch(version, arch) { if (!arch) return true; return String(version.filePath || '').toLowerCase().includes(`_${arch}.deb`); } async function findLatestAgentPackageRecord(arch = '') { try { const packageItem = await repository.getPackageById('local-installer-agent'); const latestVersion = packageItem?.versions ?.filter((version) => version.filePath && packageVersionMatchesArch(version, arch)) ?.[0]; if (!latestVersion) return null; return { filePath: latestVersion.filePath, version: latestVersion.version }; } catch (error) { console.warn('Cannot read local-installer-agent package from database:', error.message); return null; } } async function loadCurrentUser(req, res, next) { try { const cookies = parseCookies(req.headers.cookie); const session = verifySessionToken(cookies[authCookieName]); if (session) { const user = await repository.getUserById(session.sub); if (user && user.isActive) { req.currentUser = user; res.locals.currentUser = user; } else { clearAuthCookie(res); } } next(); } catch (error) { next(error); } } function requireAuthenticated(req, res, next) { if (req.currentUser) { next(); return; } redirectWithNotice( res, `/login?returnTo=${encodeURIComponent(req.originalUrl || '/')}`, 'warning', 'Vui lòng đăng nhập để tiếp tục.' ); } function requireAdmin(req, res, next) { if (req.currentUser && req.currentUser.role === 'Admin') { next(); return; } redirectWithNotice(res, '/', 'failure', 'Bạn cần quyền Admin để quản lý user.'); } function normalizePackageType(value) { return String(value || 'deb').toLowerCase() === 'docker' ? 'docker' : 'deb'; } function normalizeApplicationStatus(value) { return ['Draft', 'Released', 'Archived'].includes(value) ? value : 'Draft'; } function getArrayField(value) { if (Array.isArray(value)) return value; if (value) return [value]; return []; } function getSelectedApplicationPackages(body) { const seenPackageIds = new Set(); return getArrayField(body.packageIds) .map((packageId) => String(packageId || '').trim()) .filter(Boolean) .filter((packageId) => { if (seenPackageIds.has(packageId)) return false; seenPackageIds.add(packageId); return true; }) .map((packageId) => ({ packageId, selectedVersionId: String(body[`version_${packageId}`] || '').trim() || null })); } function csvCell(value) { const text = String(value ?? ''); if (!/[",\r\n]/.test(text)) return text; return `"${text.replace(/"/g, '""')}"`; } function sendCsv(res, filename, rows) { const csv = rows .map((row) => row.map(csvCell).join(',')) .join('\r\n'); res.setHeader('Content-Type', 'text/csv; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.send(`\uFEFF${csv}\r\n`); } async function checksumFile(filePath) { const hash = crypto.createHash('sha256'); const stream = fs.createReadStream(filePath); for await (const chunk of stream) { hash.update(chunk); } return hash.digest('hex'); } async function removeUploadedFile(file) { if (!file) return; try { await fsp.unlink(file.path); } catch (error) { if (error.code !== 'ENOENT') { console.warn(`Cannot remove uploaded file ${file.path}:`, error.message); } } } async function getArtifactFromUpload(file) { if (!file) { return { filePath: null, checksum: null, fileSizeBytes: null }; } return { filePath: `/uploads/packages/${file.filename}`, checksum: await checksumFile(file.path), fileSizeBytes: file.size }; } async function sendEmailConfirmation(req, user) { const token = await repository.createEmailConfirmationToken(user.id); const confirmUrl = `${getBaseUrl(req)}/confirm-email?token=${encodeURIComponent(token)}`; try { return await mailer.sendConfirmationEmail({ to: user.email, name: user.name, confirmUrl }); } catch (emailError) { console.error('Cannot send confirmation email:', emailError); console.warn(`Email confirmation link for ${user.email}: ${confirmUrl}`); return { sent: false, reason: 'SMTP_SEND_FAILED' }; } } app.get('/login', (req, res) => { if (req.currentUser) { res.redirect(sanitizeReturnTo(req.query.returnTo)); return; } res.render('auth', authViewModel(req, 'login')); }); app.post('/login', asyncRoute(async (req, res) => { const returnTo = sanitizeReturnTo(req.body.returnTo); const identifier = String(req.body.identifier || '').trim(); const password = String(req.body.password || ''); if (!identifier || !password) { redirectWithNotice(res, `/login?returnTo=${encodeURIComponent(returnTo)}`, 'warning', 'Vui lòng nhập tài khoản và mật khẩu.'); return; } const user = await repository.authenticateUser(identifier, password); if (!user) { redirectWithNotice(res, `/login?returnTo=${encodeURIComponent(returnTo)}`, 'failure', 'Tài khoản hoặc mật khẩu không đúng.'); return; } setAuthCookie(res, user); res.redirect(returnTo); })); app.get('/register', (req, res) => { if (req.currentUser) { res.redirect('/'); return; } res.render('auth', authViewModel(req, 'register')); }); app.get('/register/check', asyncRoute(async (req, res) => { const field = String(req.query.field || '').trim(); const value = String(req.query.value || '').trim(); if (!['username', 'email'].includes(field) || !value) { res.json({ available: true, status: 'empty', message: '' }); return; } const conflict = await repository.getRegistrationConflict({ username: field === 'username' ? value : '', email: field === 'email' ? value : '' }); if (!conflict) { res.json({ available: true, status: 'available', message: field === 'email' ? 'Email này có thể sử dụng.' : 'Username này có thể sử dụng.' }); return; } if (field === 'email' && !conflict.user.isActive) { res.json({ available: false, status: 'pending', message: 'Email này đã đăng ký nhưng chưa xác nhận.', resendUrl: `/confirm-email-sent?email=${encodeURIComponent(conflict.user.email)}` }); return; } res.json({ available: false, status: 'taken', message: field === 'email' ? 'Email này đã được sử dụng.' : 'Username này đã được sử dụng.' }); })); app.post('/register', asyncRoute(async (req, res) => { if (req.currentUser) { res.redirect('/'); return; } const username = String(req.body.username || '').trim(); const email = String(req.body.email || '').trim(); const fullName = String(req.body.fullName || '').trim(); const password = String(req.body.password || ''); const confirmPassword = String(req.body.confirmPassword || ''); if (!username || !email || !password) { renderRegisterWithNotice(req, res, 'warning', 'Vui lòng nhập username, email và mật khẩu.'); return; } if (password.length < 8) { renderRegisterWithNotice(req, res, 'warning', 'Mật khẩu cần tối thiểu 8 ký tự.'); return; } if (password !== confirmPassword) { renderRegisterWithNotice(req, res, 'warning', 'Xác nhận mật khẩu chưa khớp.'); return; } try { const conflict = await repository.getRegistrationConflict({ username, email }); if (conflict) { if (conflict.field === 'email' && !conflict.user.isActive) { redirectWithNotice( res, `/confirm-email-sent?email=${encodeURIComponent(conflict.user.email)}`, 'warning', 'Email này đã đăng ký nhưng chưa xác nhận. Bạn có thể gửi lại email confirm.' ); return; } renderRegisterWithNotice( req, res, 'warning', conflict.field === 'email' ? 'Email này đã được sử dụng. Vui lòng dùng email khác.' : 'Username này đã được sử dụng. Vui lòng chọn username khác.' ); return; } const user = await repository.createUser({ username, email, fullName, password, isActive: false }); const emailResult = await sendEmailConfirmation(req, user); const sentUrl = `/confirm-email-sent?email=${encodeURIComponent(user.email)}`; if (!emailResult.sent) { redirectWithNotice(res, sentUrl, 'warning', 'Đã tạo tài khoản nhưng chưa gửi được email xác nhận. Bạn có thể gửi lại email hoặc kiểm tra log server.'); return; } redirectWithNotice(res, sentUrl, 'success', 'Đăng ký thành công. Vui lòng kiểm tra email để xác nhận tài khoản.'); } catch (error) { if (error.code === 'DUPLICATE_USER') { renderRegisterWithNotice(req, res, 'warning', 'Username hoặc email đã tồn tại.'); return; } throw error; } })); app.get('/confirm-email-sent', (req, res) => { if (req.currentUser) { res.redirect('/'); return; } res.render('confirm-email-sent', confirmEmailViewModel(req, String(req.query.email || '').trim())); }); app.post('/resend-confirmation', asyncRoute(async (req, res) => { if (req.currentUser) { res.redirect('/'); return; } const email = String(req.body.email || '').trim(); const sentUrl = `/confirm-email-sent?email=${encodeURIComponent(email)}`; if (!email) { redirectWithNotice(res, '/confirm-email-sent', 'warning', 'Vui lòng nhập email đã đăng ký.'); return; } const pendingUser = await repository.getPendingUserByEmail(email); if (!pendingUser) { redirectWithNotice(res, sentUrl, 'warning', 'Email này không có tài khoản đang chờ xác nhận hoặc tài khoản đã active.'); return; } const emailResult = await sendEmailConfirmation(req, pendingUser); if (!emailResult.sent) { redirectWithNotice(res, sentUrl, 'warning', 'Chưa gửi được email xác nhận. Vui lòng thử lại sau.'); return; } redirectWithNotice(res, sentUrl, 'success', 'Đã gửi lại email xác nhận. Vui lòng kiểm tra Inbox hoặc Spam.'); })); app.get('/confirm-email', asyncRoute(async (req, res) => { const token = String(req.query.token || '').trim(); if (!token) { redirectWithNotice(res, '/login', 'warning', 'Link xác nhận email không hợp lệ.'); return; } const user = await repository.confirmEmailToken(token); if (!user) { redirectWithNotice(res, '/login', 'failure', 'Link xác nhận email không hợp lệ hoặc đã hết hạn.'); return; } setAuthCookie(res, user); redirectWithNotice(res, '/', 'success', 'Email đã được xác nhận. Tài khoản đã được kích hoạt.'); })); app.post('/logout', (req, res) => { clearAuthCookie(res); redirectWithNotice(res, '/login', 'success', 'Đã đăng xuất.'); }); app.get('/api/apps', asyncRoute(async (req, res) => { const applications = await repository.listApplications(); res.json({ apps: applications .filter((application) => application.status === 'Released') .map((application) => ({ appId: application.code, appName: application.name, version: application.version, status: application.status, packageCount: application.packageCount })) }); })); app.get('/api/apps/:appCode', asyncRoute(async (req, res) => { const application = await repository.getApplicationById(req.params.appCode); if (!application || application.status !== 'Released') { res.status(404).json({ error: 'Application not found' }); return; } res.json({ appId: application.code, appName: application.name, version: application.version, status: application.status, packageCount: application.packageCount, packages: application.packages }); })); app.get('/api/apps/:appCode/versions/:version/manifest', asyncRoute(async (req, res) => { const manifest = await repository.getApplicationManifest( req.params.appCode, req.params.version, getBaseUrl(req) ); if (!manifest) { res.status(404).json({ error: 'Application manifest not found' }); return; } res.json(manifest); })); app.get('/install-agent.sh', (req, res) => { const baseUrl = getBaseUrl(req); const agentUrl = `${baseUrl}/packages/agent/latest.deb`; res.type('text/x-shellscript').send(`#!/usr/bin/env bash set -euo pipefail ARCH="$(dpkg --print-architecture)" AGENT_URL="${agentUrl}?arch=$ARCH" TMP_DEB="/tmp/local-installer-agent.deb" echo "Downloading Local Installer Agent..." curl -fL "$AGENT_URL" -o "$TMP_DEB" echo "Installing Local Installer Agent..." apt install -y "$TMP_DEB" echo "Starting Local Installer Agent..." systemctl enable local-installer-agent systemctl restart local-installer-agent echo "Checking Agent..." curl -fsSL http://127.0.0.1:5010/health echo "" echo "Local Installer Agent installed successfully." `); }); app.use(requireAuthenticated); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.get('/agent', requireAdmin, asyncRoute(async (req, res) => { const pageData = await repository.getPageData(req.currentUser); const agentPackages = await listAgentPackages(); const preferredArch = normalizeAgentArch(req.query.arch) || 'amd64'; const latestAgentPackage = await findLatestAgentPackage(preferredArch); const baseUrl = getBaseUrl(req); res.render('agent', viewModel(req, 'agent', 'Agent packages', pageData, { agentPackages, latestAgentPackage, agentPackageDir, preferredArch, installCommand: `curl -fsSL ${baseUrl}/install-agent.sh | sudo bash`, latestAgentUrl: `${baseUrl}/packages/agent/latest.deb?arch=${preferredArch}` })); })); app.post('/agent/packages', requireAdmin, agentUpload.single('agentFile'), asyncRoute(async (req, res) => { const version = String(req.body.version || '').trim(); const arch = normalizeAgentArch(req.body.arch); try { if (!req.file || !version || !arch) { await removeUploadedFile(req.file); redirectWithNotice(res, '/agent', 'warning', 'Vui lòng chọn file .deb, nhập version và architecture.'); return; } if (!isValidAgentVersion(version)) { await removeUploadedFile(req.file); redirectWithNotice(res, '/agent', 'warning', 'Version chỉ nên chứa chữ, số, dấu chấm, gạch ngang hoặc gạch dưới.'); return; } if (path.extname(req.file.originalname).toLowerCase() !== '.deb') { await removeUploadedFile(req.file); redirectWithNotice(res, '/agent', 'warning', 'Agent package phải là file .deb.'); return; } const targetFileName = `local-installer-agent_${version}_${arch}.deb`; const targetPath = path.join(agentPackageDir, targetFileName); const isUpdate = fs.existsSync(targetPath); await fsp.mkdir(agentPackageDir, { recursive: true }); await fsp.rename(req.file.path, targetPath); redirectWithNotice( res, '/agent', 'success', isUpdate ? 'Đã cập nhật Agent package.' : 'Đã upload Agent package mới.' ); } catch (error) { await removeUploadedFile(req.file); throw error; } })); app.post('/profile', asyncRoute(async (req, res) => { const returnTo = sanitizeReturnTo(req.body.returnTo); const fullName = String(req.body.fullName || '').trim(); const email = String(req.body.email || '').trim(); const confirmEmail = String(req.body.confirmEmail || '').trim(); const newPassword = String(req.body.newPassword || ''); const confirmPassword = String(req.body.confirmPassword || ''); const emailChanged = email.toLowerCase() !== String(req.currentUser.email || '').trim().toLowerCase(); if (!email) { redirectWithNotice(res, returnTo, 'warning', 'Vui lòng nhập email mới.'); return; } if (email.toLowerCase() !== confirmEmail.toLowerCase()) { redirectWithNotice(res, returnTo, 'warning', 'Confirm email mới chưa khớp.'); return; } if ((newPassword || confirmPassword) && newPassword !== confirmPassword) { redirectWithNotice(res, returnTo, 'warning', 'Xác nhận mật khẩu mới chưa khớp.'); return; } if (newPassword && newPassword.length < 8) { redirectWithNotice(res, returnTo, 'warning', 'Mật khẩu mới cần tối thiểu 8 ký tự.'); return; } try { const updatedUser = await repository.updateUser({ userId: req.currentUser.id, username: req.currentUser.username, email, fullName, role: req.currentUser.role, isActive: emailChanged ? false : req.currentUser.isActive, password: newPassword || null }); if (emailChanged) { const sentUrl = `/confirm-email-sent?email=${encodeURIComponent(updatedUser.email)}`; const emailResult = await sendEmailConfirmation(req, updatedUser); clearAuthCookie(res); if (!emailResult.sent) { redirectWithNotice(res, sentUrl, 'warning', 'Đã cập nhật email mới nhưng chưa gửi được email xác nhận. Bạn có thể gửi lại email hoặc kiểm tra log server.'); return; } redirectWithNotice(res, sentUrl, 'success', 'Đã cập nhật email mới. Vui lòng kiểm tra email để xác nhận lại tài khoản.'); return; } redirectWithNotice( res, returnTo, 'success', newPassword ? 'Đã cập nhật thông tin cá nhân và mật khẩu.' : 'Đã cập nhật thông tin cá nhân.' ); } catch (error) { if (error.code === 'DUPLICATE_USER') { redirectWithNotice(res, returnTo, 'warning', 'Email này đã được sử dụng. Vui lòng dùng email khác.'); return; } throw error; } })); app.get('/', asyncRoute(async (req, res) => { const pageData = await repository.getPageData(req.currentUser); res.render('dashboard', viewModel(req, 'dashboard', 'Tổng quan', pageData)); })); app.get('/packages', asyncRoute(async (req, res) => { const pageData = await repository.getPageData(req.currentUser); res.render('packages', viewModel(req, 'packages', 'Packages', pageData)); })); app.get('/packages/export.csv', asyncRoute(async (req, res) => { const packages = await repository.listPackages(); const rows = [ ['PackageCode', 'PackageName', 'PackageType', 'LatestVersion', 'LatestReleaseDate', 'Status', 'Owner', 'Description', 'Artifact'], ...packages.map((packageItem) => [ packageItem.code, packageItem.name, packageItem.type, packageItem.latestVersion, packageItem.latestReleaseDate, packageItem.status, packageItem.owner, packageItem.description, packageItem.artifact ]) ]; sendCsv(res, 'packages.csv', rows); })); app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res) => { try { const artifact = await getArtifactFromUpload(req.file); const packageType = normalizePackageType(req.body.packageType); if (!req.body.packageCode || !req.body.packageName || !req.body.version) { await removeUploadedFile(req.file); redirectWithNotice(res, '/packages', 'warning', 'Vui lòng nhập Package code, Package name và Version.'); return; } await repository.createPackageWithVersion({ packageCode: req.body.packageCode.trim(), packageName: req.body.packageName.trim(), packageType, description: req.body.description, version: req.body.version.trim(), releaseDate: req.body.releaseDate, filePath: artifact.filePath, dockerImage: req.body.dockerImage && req.body.dockerImage.trim(), fileSizeBytes: artifact.fileSizeBytes, checksum: artifact.checksum, changeLog: req.body.changeLog, createdByUserId: req.currentUser.id }); redirectWithNotice(res, '/packages', 'success', 'Đã upload package và lưu vào database.'); } catch (error) { await removeUploadedFile(req.file); if (error.code === 'DUPLICATE_PACKAGE') { redirectWithNotice(res, '/packages', 'warning', 'Package code hoặc version đã tồn tại.'); return; } throw error; } })); app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (req, res) => { try { const artifact = await getArtifactFromUpload(req.file); if (!req.body.packageId || !req.body.version) { await removeUploadedFile(req.file); redirectWithNotice(res, '/packages', 'warning', 'Vui lòng chọn package và nhập version mới.'); return; } await repository.addPackageVersion({ packageId: req.body.packageId, version: req.body.version.trim(), releaseDate: req.body.releaseDate, filePath: artifact.filePath, dockerImage: req.body.dockerImage && req.body.dockerImage.trim(), fileSizeBytes: artifact.fileSizeBytes, checksum: artifact.checksum, changeLog: req.body.changeLog }); redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.'); } catch (error) { await removeUploadedFile(req.file); if (error.code === 'DUPLICATE_PACKAGE') { redirectWithNotice(res, `/packages/${req.body.packageId || ''}`, 'warning', 'Version này đã tồn tại trong package.'); return; } throw error; } })); app.post('/packages/:id/delete', asyncRoute(async (req, res) => { const deleted = await repository.deletePackage(req.params.id); redirectWithNotice( res, '/packages', deleted ? 'success' : 'warning', deleted ? 'Đã xóa package và các liên kết database.' : 'Không tìm thấy package cần xóa.' ); })); app.post('/package-versions/:id/latest', asyncRoute(async (req, res) => { const returnTo = sanitizeReturnTo(req.body.returnTo || '/packages'); await repository.setLatestPackageVersion(req.params.id); redirectWithNotice(res, returnTo, 'success', 'Đã đặt version là latest trong database.'); })); app.post('/package-versions/:id/delete', asyncRoute(async (req, res) => { const returnTo = sanitizeReturnTo(req.body.returnTo || '/packages'); const result = await repository.deletePackageVersion(req.params.id); redirectWithNotice( res, result.packageId ? `/packages/${result.packageId}` : returnTo, result.deleted ? 'success' : 'warning', result.deleted ? 'Đã xóa version và các liên kết app đang dùng version đó.' : 'Không tìm thấy version cần xóa.' ); })); app.get('/packages/:id', asyncRoute(async (req, res) => { const pageData = await repository.getPageData(req.currentUser); const packageItem = await repository.getPackageById(req.params.id); if (!packageItem) { res.status(404).render('not-found', viewModel(req, 'packages', 'Không tìm thấy', pageData)); return; } res.render( 'package-detail', viewModel(req, 'packages', packageItem.name, pageData, { packageItem }) ); })); app.get('/applications', asyncRoute(async (req, res) => { const pageData = await repository.getPageData(req.currentUser); res.render('applications', viewModel(req, 'applications', 'Applications', pageData)); })); app.get('/applications/export.csv', asyncRoute(async (req, res) => { const applications = await repository.listApplications(); const rows = [ ['AppCode', 'AppName', 'AppVersion', 'Status', 'CreatedAt', 'CreatedBy', 'PackageCount', 'Packages', 'Notes'], ...applications.map((application) => [ application.code, application.name, application.version, application.status, application.createdAt, application.createdBy, application.packageCount, application.packages.map((packageItem) => `${packageItem.code}@${packageItem.selectedVersion}`).join('; '), application.notes ]) ]; sendCsv(res, 'applications.csv', rows); })); app.post('/applications', asyncRoute(async (req, res) => { if (!req.body.appCode || !req.body.appName || !req.body.appVersion) { redirectWithNotice(res, '/builder', 'warning', 'Vui lòng nhập App code, App name và App version.'); return; } try { const applicationId = await repository.createApplication({ appCode: req.body.appCode.trim(), appName: req.body.appName.trim(), appVersion: req.body.appVersion.trim(), notes: req.body.notes, status: normalizeApplicationStatus(req.body.status), createdByUserId: req.currentUser.id, packages: getSelectedApplicationPackages(req.body) }); redirectWithNotice(res, `/applications/${applicationId}`, 'success', 'Đã tạo app và lưu package đã chọn vào database.'); } catch (error) { if (error.code === 'DUPLICATE_APPLICATION') { redirectWithNotice(res, '/builder', 'warning', 'App code đã tồn tại.'); return; } throw error; } })); app.post('/applications/:id/edit', asyncRoute(async (req, res) => { const applicationId = String(req.params.id || '').trim(); const returnTo = sanitizeReturnTo(req.body.returnTo || `/applications/${applicationId}`); if (!req.body.appCode || !req.body.appName || !req.body.appVersion) { redirectWithNotice(res, returnTo, 'warning', 'Vui lòng nhập App code, App name và App version.'); return; } try { const updatedApplication = await repository.updateApplication({ applicationId, appCode: req.body.appCode.trim(), appName: req.body.appName.trim(), appVersion: req.body.appVersion.trim(), notes: req.body.notes, status: normalizeApplicationStatus(req.body.status), packages: getSelectedApplicationPackages(req.body) }); redirectWithNotice( res, updatedApplication ? returnTo : '/applications', updatedApplication ? 'success' : 'warning', updatedApplication ? 'Đã cập nhật application trong database.' : 'Không tìm thấy app cần cập nhật.' ); } catch (error) { if (error.code === 'DUPLICATE_APPLICATION') { redirectWithNotice(res, returnTo, 'warning', 'App code đã tồn tại.'); return; } throw error; } })); app.post('/applications/:id/release', asyncRoute(async (req, res) => { const applicationId = String(req.params.id || '').trim(); const returnTo = sanitizeReturnTo(req.body.returnTo || `/applications/${applicationId}`); const updated = await repository.updateApplicationStatus(applicationId, 'Released'); redirectWithNotice( res, returnTo, updated ? 'success' : 'warning', updated ? 'Đã chuyển application sang Released trong database.' : 'Không tìm thấy app cần release.' ); })); app.post('/applications/:id/delete', asyncRoute(async (req, res) => { const deleted = await repository.deleteApplication(req.params.id); redirectWithNotice( res, '/applications', deleted ? 'success' : 'warning', deleted ? 'Đã xóa application và các package link.' : 'Không tìm thấy app cần xóa.' ); })); app.post('/applications/:id/packages/:packageId/delete', asyncRoute(async (req, res) => { const applicationId = String(req.params.id || '').trim(); const removed = await repository.removeApplicationPackage(applicationId, req.params.packageId); redirectWithNotice( res, `/applications/${applicationId}`, removed ? 'success' : 'warning', removed ? 'Đã gỡ package khỏi application.' : 'Không tìm thấy package link cần gỡ.' ); })); app.get('/applications/:id', asyncRoute(async (req, res) => { const pageData = await repository.getPageData(req.currentUser); const application = await repository.getApplicationById(req.params.id); if (!application) { res.status(404).render('not-found', viewModel(req, 'applications', 'Không tìm thấy', pageData)); return; } res.render( 'application-detail', viewModel(req, 'applications', application.name, pageData, { application }) ); })); app.get('/users', requireAdmin, asyncRoute(async (req, res) => { const [pageData, users] = await Promise.all([ repository.getPageData(req.currentUser), repository.listUsers() ]); res.render('users', viewModel(req, 'users', 'Users', pageData, { users })); })); app.post('/users', requireAdmin, asyncRoute(async (req, res) => { const username = String(req.body.username || '').trim(); const email = String(req.body.email || '').trim(); const fullName = String(req.body.fullName || '').trim(); const password = String(req.body.password || ''); const role = String(req.body.role || 'User') === 'Admin' ? 'Admin' : 'User'; if (!username || !email || !password) { redirectWithNotice(res, '/users', 'warning', 'Vui lòng nhập username, email và mật khẩu.'); return; } if (password.length < 8) { redirectWithNotice(res, '/users', 'warning', 'Mật khẩu cần tối thiểu 8 ký tự.'); return; } try { await repository.createUser({ username, email, fullName, password, role }); redirectWithNotice(res, '/users', 'success', 'Đã tạo user mới.'); } catch (error) { if (error.code === 'DUPLICATE_USER') { redirectWithNotice(res, '/users', 'warning', 'Username hoặc email đã tồn tại.'); return; } throw error; } })); app.post('/users/:id', requireAdmin, asyncRoute(async (req, res) => { const userId = String(req.params.id || '').trim(); const role = String(req.body.role || 'User') === 'Admin' ? 'Admin' : 'User'; const isActive = req.body.isActive === 'on'; if (userId === req.currentUser.id && (!isActive || role !== 'Admin')) { redirectWithNotice(res, '/users', 'warning', 'Bạn không thể tự hạ quyền hoặc khóa tài khoản Admin đang đăng nhập.'); return; } await repository.updateUserAccess({ userId, role, isActive }); redirectWithNotice(res, '/users', 'success', 'Đã cập nhật quyền user.'); })); app.post('/users/:id/edit', requireAdmin, asyncRoute(async (req, res) => { const userId = String(req.params.id || '').trim(); const role = String(req.body.role || 'User') === 'Admin' ? 'Admin' : 'User'; const isActive = req.body.isActive === 'on'; const newPassword = String(req.body.newPassword || ''); const confirmPassword = String(req.body.confirmPassword || ''); if (!req.body.username || !req.body.email) { redirectWithNotice(res, '/users', 'warning', 'Vui lòng nhập username và email.'); return; } if (userId === req.currentUser.id && (!isActive || role !== 'Admin')) { redirectWithNotice(res, '/users', 'warning', 'Bạn không thể tự hạ quyền hoặc khóa tài khoản Admin đang đăng nhập.'); return; } if ((newPassword || confirmPassword) && newPassword !== confirmPassword) { redirectWithNotice(res, '/users', 'warning', 'Xác nhận mật khẩu mới chưa khớp.'); return; } if (newPassword && newPassword.length < 8) { redirectWithNotice(res, '/users', 'warning', 'Mật khẩu mới cần tối thiểu 8 ký tự.'); return; } try { await repository.updateUser({ userId, username: req.body.username, email: req.body.email, fullName: req.body.fullName, role, isActive, password: newPassword || null }); redirectWithNotice( res, '/users', 'success', newPassword ? 'Đã cập nhật thông tin user và mật khẩu.' : 'Đã cập nhật thông tin user.' ); } catch (error) { if (error.code === 'DUPLICATE_USER') { redirectWithNotice(res, '/users', 'warning', 'Username hoặc email đã tồn tại.'); return; } throw error; } })); app.post('/users/:id/delete', requireAdmin, asyncRoute(async (req, res) => { const userId = String(req.params.id || '').trim(); if (userId === req.currentUser.id) { redirectWithNotice(res, '/users', 'warning', 'Bạn không thể xóa tài khoản đang đăng nhập.'); return; } try { const deleted = await repository.deleteUser(userId); redirectWithNotice( res, '/users', deleted ? 'success' : 'warning', deleted ? 'Đã xóa user.' : 'Không tìm thấy user cần xóa.' ); } 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.'); return; } throw error; } })); app.get('/builder', asyncRoute(async (req, res) => { const pageData = await repository.getPageData(req.currentUser); res.render('builder', viewModel(req, 'builder', 'Đóng gói App', pageData)); })); app.use(async (error, req, res, next) => { console.error(error); const pageData = await repository.getPageData(req.currentUser).catch(() => ({ currentUser: req.currentUser || { name: 'Guest', role: 'Guest', email: '' }, stats: { totalPackages: 0, activePackages: 0, totalVersions: 0, totalApplications: 0, releasedApplications: 0 }, packages: [], applications: [], activity: [] })); res.status(500).render( 'not-found', viewModel(req, 'dashboard', 'Có lỗi xảy ra', pageData, { notice: { type: 'failure', message: 'Không thể xử lý thao tác. Kiểm tra log server để xem chi tiết.' } }) ); }); const server = app.listen(port, () => { console.log(`Robot Installer UI is running at http://localhost:${port}`); }); async function shutdown() { server.close(async () => { await closePool(); process.exit(0); }); } process.on('SIGINT', shutdown); process.on('SIGTERM', shutdown);