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 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'; fs.mkdirSync(uploadDir, { 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: '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 upload = multer({ storage, limits: { fileSize: Number(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.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')}`; } 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.use(requireAuthenticated); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); 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);