Files
InstallerRobot/web-server/server.js
2026-05-22 16:47:51 +07:00

1529 lines
46 KiB
JavaScript

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_<version>_<arch>.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)}&notice=${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);