Files
InstallerRobot/web-server/server.js
2026-05-28 14:26:02 +07:00

2235 lines
68 KiB
JavaScript

require('dotenv').config({ quiet: true });
const crypto = require('crypto');
const { execFile } = require('child_process');
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, getPool } = 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 agentDebianPackageName = 'local-installer-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 secureSessionCookie = getBooleanEnv(process.env.SESSION_COOKIE_SECURE, process.env.NODE_ENV === 'production');
const publicApiCorsOrigins = getCsvEnv(process.env.WEB_CLIENT_ORIGINS || process.env.PUBLIC_API_CORS_ORIGINS, [
'https://app.pnkr.cloud',
'http://localhost:5173',
'http://localhost:4173'
]);
const installerIdentifierPattern = /^[a-zA-Z0-9._+-]+$/;
const installerVersionPattern = /^[a-zA-Z0-9._:+~=-]+$/;
const installerIdentifierHint = 'Code chi duoc dung chu, so, dau ., _, +, - va khong co khoang trang.';
const installerVersionHint = 'Version chi duoc dung chu, so va cac ky tu . _ : + ~ = -.';
const agentVersionCollator = new Intl.Collator('en', {
numeric: true,
sensitivity: 'base'
});
let didWarnAgentMetadataToolMissing = false;
app.get('/healthz', (req, res) => {
res.status(200).json({ status: 'ok' });
});
app.get('/readyz', asyncRoute(async (req, res) => {
const database = await checkDatabaseReadiness();
res.status(200).json({
status: 'ready',
database
});
}));
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.use(applyPublicApiCors);
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.get('/api/agent/latest', asyncRoute(async (req, res) => {
const arch = normalizeAgentArch(req.query.arch) || 'amd64';
const baseUrl = getBaseUrl(req);
const latestPackage = await findLatestAgentPackage(arch);
if (latestPackage) {
res.json({
version: latestPackage.version,
arch: latestPackage.arch,
fileName: latestPackage.fileName,
size: latestPackage.size,
sizeLabel: latestPackage.sizeLabel,
downloadUrl: `${baseUrl}${latestPackage.downloadPath}`,
installCommand: `curl -fsSL ${baseUrl}/install-agent.sh | sudo bash`
});
return;
}
const latestPackageRecord = await findLatestAgentPackageRecord(arch);
if (latestPackageRecord) {
const recordPath = latestPackageRecord.filePath || '';
const downloadUrl = /^https?:\/\//i.test(recordPath)
? recordPath
: `${baseUrl}${recordPath.startsWith('/') ? recordPath : `/${recordPath}`}`;
res.json({
version: latestPackageRecord.version,
arch,
fileName: '',
size: 0,
sizeLabel: '',
downloadUrl,
installCommand: `curl -fsSL ${baseUrl}/install-agent.sh | sudo bash`
});
return;
}
res.status(404).json({
error: `No Local Installer Agent package found for ${arch}`
});
}));
app.use('/packages/agent', express.static(agentPackageDir, { fallthrough: false }));
app.use('/packages/agent', staticFileNotFoundHandler('Agent package file'));
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 getCsvEnv(value, fallback) {
if (!value) return fallback;
return String(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function getBooleanEnv(value, fallback) {
if (value === undefined || value === null || value === '') return fallback;
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase());
}
function escapeShellDoubleQuoted(value) {
return String(value || '').replace(/(["\\$`])/g, '\\$1');
}
function isPublicApiCorsPath(pathname) {
return pathname === '/api/apps'
|| pathname.startsWith('/api/apps/')
|| pathname === '/api/agent/latest'
|| pathname.startsWith('/api/package-versions/')
|| pathname === '/install-agent.sh';
}
function applyPublicApiCors(req, res, next) {
if (!isPublicApiCorsPath(req.path)) {
next();
return;
}
const origin = req.headers.origin;
const allowAnyOrigin = publicApiCorsOrigins.includes('*');
if (origin && (allowAnyOrigin || publicApiCorsOrigins.includes(origin))) {
res.setHeader('Access-Control-Allow-Origin', allowAnyOrigin ? '*' : origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Accept, Content-Type');
}
if (req.method === 'OPTIONS') {
res.sendStatus(204);
return;
}
next();
}
function staticFileNotFoundHandler(label) {
return (error, req, res, next) => {
if (error.status === 404 || error.statusCode === 404 || error.code === 'ENOENT') {
res.status(404).type('text/plain').send(`${label} not found.`);
return;
}
next(error);
};
}
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 || {
name: 'Guest',
username: 'guest',
role: 'Guest',
email: ''
};
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),
databaseLabel: getSqlServerDisplayLabel(),
helpers: helpers(),
...extra
};
}
function getSqlServerDisplayLabel() {
const host = process.env.SQLSERVER_HOST || 'localhost';
const port = process.env.SQLSERVER_PORT || '1433';
const database = process.env.SQLSERVER_DATABASE || 'RobotInstaller';
return `${host}:${port}/${database}`;
}
async function checkDatabaseReadiness() {
const pool = await getPool();
const result = await pool.request().query(`
SELECT
DB_NAME() AS DatabaseName,
CASE WHEN OBJECT_ID(N'dbo.Users', N'U') IS NULL THEN 0 ELSE 1 END AS HasUsers,
CASE WHEN OBJECT_ID(N'dbo.vw_PackageList', N'V') IS NULL THEN 0 ELSE 1 END AS HasPackageList,
CASE WHEN OBJECT_ID(N'dbo.vw_ApplicationList', N'V') IS NULL THEN 0 ELSE 1 END AS HasApplicationList;
`);
const row = result.recordset[0] || {};
const missingObjects = [];
if (!row.HasUsers) missingObjects.push('dbo.Users');
if (!row.HasPackageList) missingObjects.push('dbo.vw_PackageList');
if (!row.HasApplicationList) missingObjects.push('dbo.vw_ApplicationList');
if (missingObjects.length > 0) {
const error = new Error(`Missing database objects: ${missingObjects.join(', ')}`);
error.code = 'DB_SCHEMA_NOT_READY';
throw error;
}
return {
server: process.env.SQLSERVER_HOST || 'localhost',
port: Number(process.env.SQLSERVER_PORT || 1433),
database: row.DatabaseName || process.env.SQLSERVER_DATABASE || 'RobotInstaller'
};
}
function getErrorDetails(error) {
if (!getBooleanEnv(process.env.APP_SHOW_ERROR_DETAILS, process.env.NODE_ENV !== 'production')) {
return null;
}
return {
code: error.code || error.name || 'ERROR',
message: error.message || 'Unexpected error'
};
}
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: secureSessionCookie
});
}
function clearAuthCookie(res) {
res.clearCookie(authCookieName, {
httpOnly: true,
sameSite: 'lax',
secure: secureSessionCookie
});
}
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) {
const requestBaseUrl = getRequestBaseUrl(req);
if (process.env.APP_BASE_URL) {
const configuredBaseUrl = process.env.APP_BASE_URL.replace(/\/+$/, '');
if (!isLoopbackBaseUrl(configuredBaseUrl) || isLoopbackBaseUrl(requestBaseUrl)) {
return configuredBaseUrl;
}
}
return requestBaseUrl;
}
function getRequestBaseUrl(req) {
const forwardedProtocol = req.headers['x-forwarded-proto'];
const forwardedHost = req.headers['x-forwarded-host'];
const forwardedPort = req.headers['x-forwarded-port'];
const protocol = forwardedProtocol
? String(forwardedProtocol).split(',')[0].trim()
: req.protocol;
let host = forwardedHost
? String(forwardedHost).split(',')[0].trim()
: req.get('host');
if (forwardedPort && host && !hostHasPort(host)) {
const port = String(forwardedPort).split(',')[0].trim();
if (port && !isDefaultPort(protocol, port)) {
host = `${host}:${port}`;
}
}
return `${protocol}://${host}`;
}
function isLoopbackBaseUrl(value) {
try {
const hostname = new URL(value).hostname.toLowerCase();
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
} catch {
return false;
}
}
function hostHasPort(host) {
return /:\d+$/.test(host) || /^\[[^\]]+\]:\d+$/.test(host);
}
function isDefaultPort(protocol, port) {
return (protocol === 'http' && port === '80') || (protocol === 'https' && port === '443');
}
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)}`;
}
function parseAgentPackageFileName(fileName) {
const match = /^local-installer-agent_([^/\\]+)_([^/\\]+)\.deb$/.exec(fileName);
if (!match) return null;
return {
version: match[1],
arch: match[2]
};
}
function parseDebControlOutput(output) {
return String(output || '')
.split(/\r?\n/)
.reduce((metadata, line) => {
const match = /^([^:]+):\s*(.*)$/.exec(line);
if (!match) return metadata;
const key = match[1].trim().toLowerCase();
const value = match[2].trim();
if (key === 'package') metadata.package = value;
if (key === 'version') metadata.version = value;
if (key === 'architecture') metadata.architecture = value;
return metadata;
}, {});
}
function isDebMetadataInspectionUnavailable(error) {
const message = String(error?.message || '');
return error?.code === 'ENOENT'
|| /unable to execute decompressing archive/i.test(message)
|| /member 'control\.tar' \([^)]+\): No such file or directory/i.test(message);
}
async function readDebPackageMetadata(filePath) {
return new Promise((resolve, reject) => {
execFile(
'dpkg-deb',
['-f', filePath, 'Package', 'Version', 'Architecture'],
{ windowsHide: true, timeout: 10000, maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
if (isDebMetadataInspectionUnavailable(error)) error.metadataToolMissing = true;
reject(error);
return;
}
const metadata = parseDebControlOutput(stdout);
if (!metadata.package || !metadata.version || !metadata.architecture) {
reject(new Error('Missing Package, Version, or Architecture in deb control metadata.'));
return;
}
resolve(metadata);
}
);
});
}
async function readDebPackageMetadataIfAvailable(filePath) {
try {
return await readDebPackageMetadata(filePath);
} catch (error) {
if (error.metadataToolMissing) {
if (!didWarnAgentMetadataToolMissing) {
console.warn('dpkg-deb metadata inspection is not available; using agent package filename metadata.');
didWarnAgentMetadataToolMissing = true;
}
return null;
}
console.warn(`Cannot inspect agent package metadata for ${path.basename(filePath)}:`, error.message);
return null;
}
}
async function getAgentPackageFromEntry(entry) {
const fileNameMetadata = parseAgentPackageFileName(entry.name);
if (!fileNameMetadata) return null;
const filePath = path.join(agentPackageDir, entry.name);
const stat = await fsp.stat(filePath);
const debMetadata = await readDebPackageMetadataIfAvailable(filePath);
if (debMetadata?.package && debMetadata.package !== agentDebianPackageName) {
console.warn(`Ignoring ${entry.name}: deb package is ${debMetadata.package}, expected ${agentDebianPackageName}.`);
return null;
}
if (debMetadata) {
const metadataArch = normalizeAgentArch(debMetadata.architecture);
const fileNameArch = normalizeAgentArch(fileNameMetadata.arch);
if (debMetadata.version !== fileNameMetadata.version || metadataArch !== fileNameArch) {
console.warn(
`Ignoring ${entry.name}: filename metadata does not match deb metadata ` +
`(deb version=${debMetadata.version}, deb arch=${debMetadata.architecture}).`
);
return null;
}
}
const version = fileNameMetadata.version;
const packageArch = fileNameMetadata.arch;
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);
if (!packages) return null;
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 normalizeApplicationOpenUrl(value) {
const text = String(value || '').trim();
if (!text) return '';
const candidate = /^https?:\/\//i.test(text) ? text : `http://${text}`;
try {
const parsed = new URL(candidate);
if (!['http:', 'https:'].includes(parsed.protocol) || !parsed.hostname) {
return null;
}
return parsed.href.replace(/\/+$/, '');
} catch {
return null;
}
}
function isInstallerIdentifier(value) {
return installerIdentifierPattern.test(String(value || '').trim());
}
function isInstallerVersion(value) {
return installerVersionPattern.test(String(value || '').trim());
}
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 getDebUploadMetadataValidationMessage(file, packageCode, version) {
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
let debMetadata = null;
try {
debMetadata = await readDebPackageMetadata(file.path);
} catch (error) {
if (error.metadataToolMissing) {
console.warn('dpkg-deb metadata inspection is not available; skipping package metadata validation.');
return null;
}
return `Khong doc duoc metadata trong file .deb: ${error.message}`;
}
const metadataErrors = [];
if (debMetadata.package !== packageCode) {
metadataErrors.push(`package=${debMetadata.package}`);
}
if (debMetadata.version !== version) {
metadataErrors.push(`version=${debMetadata.version}`);
}
return metadataErrors.length > 0
? `File .deb khong khop thong tin package (${metadataErrors.join(', ')}). Package code va version tren web phai khop Package/Version trong file .deb.`
: null;
}
async function listMissingManifestPackageFiles(manifest) {
const missingPackageFiles = [];
for (const component of manifest.components || []) {
if (component.type !== 'deb') continue;
const versionId = getPackageVersionIdFromDownloadUrl(component.downloadUrl);
const packageVersion = versionId
? await repository.getPackageVersionDownload(versionId)
: null;
const localPath = packageVersion
? getLocalPackageFilePath(packageVersion.filePath)
: null;
if (!localPath) continue;
try {
await fsp.access(localPath, fs.constants.R_OK);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
missingPackageFiles.push({
componentId: component.componentId,
packageName: component.packageName,
version: component.version,
filePath: packageVersion?.filePath || '',
downloadUrl: component.downloadUrl
});
}
}
return missingPackageFiles;
}
function getPackageVersionIdFromDownloadUrl(downloadUrl) {
let pathname;
try {
pathname = new URL(downloadUrl).pathname;
} catch {
return null;
}
const match = pathname.match(/^\/api\/package-versions\/([^/]+)\/download$/);
return match ? decodeURIComponent(match[1]) : null;
}
function getLocalPackageFilePath(filePath) {
let pathname = String(filePath || '').trim();
if (!pathname) return null;
if (/^https?:\/\//i.test(pathname)) {
try {
pathname = new URL(pathname).pathname;
} catch {
return null;
}
}
const prefix = '/uploads/packages/';
if (!pathname.startsWith(prefix)) return null;
const relativePath = decodeURIComponent(pathname.slice(prefix.length));
if (!relativePath || relativePath.includes('\0')) return null;
const uploadRoot = path.resolve(uploadDir);
const localPath = path.resolve(uploadRoot, relativePath);
const pathDelta = path.relative(uploadRoot, localPath);
if (!pathDelta || pathDelta.startsWith('..') || path.isAbsolute(pathDelta)) {
return null;
}
return localPath;
}
function getDownloadFileName(packageVersion, localPath) {
const storedName = path.basename(localPath || '');
if (storedName) return storedName.replace(/"/g, '');
const packageCode = String(packageVersion.packageCode || 'package').replace(/[^a-zA-Z0-9._+-]/g, '-');
const version = String(packageVersion.version || 'latest').replace(/[^a-zA-Z0-9._:+~=-]/g, '-');
return `${packageCode}_${version}.deb`;
}
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.id,
appCode: application.code,
appName: application.name,
version: application.version,
status: application.status,
packageCount: application.packageCount,
openUrl: application.openUrl
}))
});
}));
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.id,
appCode: application.code,
appName: application.name,
version: application.version,
status: application.status,
packageCount: application.packageCount,
openUrl: application.openUrl,
packages: application.packages
});
}));
app.get('/api/package-versions/:versionId/download', asyncRoute(async (req, res) => {
const packageVersion = await repository.getPackageVersionDownload(req.params.versionId);
if (!packageVersion || packageVersion.packageType !== 'deb') {
res.status(404).type('text/plain').send('Package version not found.');
return;
}
const localPath = getLocalPackageFilePath(packageVersion.filePath);
if (!localPath) {
res.status(409).type('text/plain').send('Package file path is not available on this package server.');
return;
}
try {
await fsp.access(localPath, fs.constants.R_OK);
} catch (error) {
if (error.code === 'ENOENT') {
res.status(404).type('text/plain').send('Package file not found.');
return;
}
throw error;
}
res.type('application/vnd.debian.binary-package');
res.setHeader('Content-Disposition', `attachment; filename="${getDownloadFileName(packageVersion, localPath)}"`);
if (packageVersion.checksum) {
res.setHeader('X-Package-Sha256', packageVersion.checksum);
}
res.sendFile(localPath);
}));
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;
}
const missingPackageFiles = await listMissingManifestPackageFiles(manifest);
if (missingPackageFiles.length > 0) {
res.status(409).json({
error: 'Package file is missing on the package server',
detail: 'Upload the package file to this web-server storage or re-upload the same package version from the Packages page.',
missingPackageFiles
});
return;
}
res.json(manifest);
}));
app.get('/install-agent.sh', (req, res) => {
const baseUrl = getBaseUrl(req);
const agentUrl = `${baseUrl}/packages/agent/latest.deb`;
const agentAllowedOrigins = Array.from(new Set([
baseUrl,
...publicApiCorsOrigins.filter((origin) => origin !== '*'),
'http://localhost:3000',
'http://127.0.0.1:3000'
])).join(',');
res.type('text/x-shellscript').send(`#!/usr/bin/env bash
set -euo pipefail
ARCH="$(dpkg --print-architecture)"
PACKAGE_BASE_URL="${baseUrl}"
AGENT_URL="${agentUrl}?arch=$ARCH"
AGENT_ENV="/etc/local-installer-agent/agent.env"
PACKAGE_HOST="$(printf '%s' "$PACKAGE_BASE_URL" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/:]+).*$#\\1#')"
PACKAGE_REGISTRY="$(printf '%s' "$PACKAGE_BASE_URL" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/]+).*$#\\1#')"
TMP_DEB="/tmp/local-installer-agent.deb"
set_agent_env() {
KEY="$1"
VALUE="$2"
if grep -q "^$KEY=" "$AGENT_ENV"; then
sed -i "s|^$KEY=.*|$KEY=$VALUE|" "$AGENT_ENV"
else
echo "$KEY=$VALUE" >> "$AGENT_ENV"
fi
}
echo "Downloading Local Installer Agent..."
curl -fL "$AGENT_URL" -o "$TMP_DEB"
echo "Installing Local Installer Agent..."
apt install -y "$TMP_DEB"
echo "Configuring Local Installer Agent..."
mkdir -p /etc/local-installer-agent
touch "$AGENT_ENV"
set_agent_env ROBOT_PACKAGE_BASE_URL "$PACKAGE_BASE_URL"
set_agent_env ALLOWED_ORIGINS "${escapeShellDoubleQuoted(agentAllowedOrigins)}"
set_agent_env ALLOWED_DOWNLOAD_HOSTS "$PACKAGE_HOST,localhost,127.0.0.1"
set_agent_env ALLOWED_DOCKER_REGISTRIES "$PACKAGE_REGISTRY,$PACKAGE_HOST,localhost,127.0.0.1,registry.robot.package,docker.io"
set_agent_env ALLOW_DOCKER true
set_agent_env AUTO_INSTALL_DOCKER true
echo "Starting Local Installer Agent..."
systemctl enable local-installer-agent
systemctl restart local-installer-agent
echo "Checking Agent..."
for attempt in $(seq 1 20); do
if curl -fsSL http://127.0.0.1:5010/health; then
echo ""
echo "Local Installer Agent installed successfully."
exit 0
fi
sleep 1
done
echo ""
echo "Local Installer Agent did not become healthy. Recent service logs:"
journalctl -u local-installer-agent -n 80 --no-pager || true
exit 1
`);
});
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;
}
let debMetadata = null;
try {
debMetadata = await readDebPackageMetadata(req.file.path);
} catch (error) {
if (!error.metadataToolMissing) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/agent', 'warning', `Khong doc duoc metadata trong file .deb: ${error.message}`);
return;
}
console.warn('dpkg-deb metadata inspection is not available; skipping uploaded agent package metadata validation.');
}
if (debMetadata) {
const metadataArch = normalizeAgentArch(debMetadata.architecture);
const metadataErrors = [];
if (debMetadata.package !== agentDebianPackageName) {
metadataErrors.push(`package=${debMetadata.package}`);
}
if (debMetadata.version !== version) {
metadataErrors.push(`version=${debMetadata.version}`);
}
if (metadataArch !== arch) {
metadataErrors.push(`arch=${debMetadata.architecture}`);
}
if (metadataErrors.length > 0) {
await removeUploadedFile(req.file);
redirectWithNotice(
res,
'/agent',
'warning',
`File .deb khong khop thong tin upload (${metadataErrors.join(', ')}).`
);
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);
const packageCode = String(req.body.packageCode || '').trim();
const packageName = String(req.body.packageName || '').trim();
const version = String(req.body.version || '').trim();
if (!packageCode || !packageName || !version) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/packages', 'warning', 'Vui lòng nhập Package code, Package name và Version.');
return;
}
if (!isInstallerIdentifier(packageCode)) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/packages', 'warning', `Package code khong hop le. ${installerIdentifierHint}`);
return;
}
if (!isInstallerVersion(version)) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/packages', 'warning', `Package version khong hop le. ${installerVersionHint}`);
return;
}
if (packageType === 'deb') {
const metadataMessage = await getDebUploadMetadataValidationMessage(req.file, packageCode, version);
if (metadataMessage) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/packages', 'warning', metadataMessage);
return;
}
}
await repository.createPackageWithVersion({
packageCode,
packageName,
packageType,
description: req.body.description,
version,
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) => {
let versionInput = null;
try {
const artifact = await getArtifactFromUpload(req.file);
const version = String(req.body.version || '').trim();
if (!req.body.packageId || !version) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/packages', 'warning', 'Vui lòng chọn package và nhập version mới.');
return;
}
if (!isInstallerVersion(version)) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/packages', 'warning', `Package version khong hop le. ${installerVersionHint}`);
return;
}
const packageItem = await repository.getPackageById(req.body.packageId);
if (!packageItem) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/packages', 'warning', 'Khong tim thay package can cap nhat.');
return;
}
if (packageItem.type === 'deb') {
const metadataMessage = await getDebUploadMetadataValidationMessage(req.file, packageItem.code, version);
if (metadataMessage) {
await removeUploadedFile(req.file);
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'warning', metadataMessage);
return;
}
}
versionInput = {
packageId: req.body.packageId,
version,
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
};
await repository.addPackageVersion(versionInput);
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.');
} catch (error) {
if (error.code === 'DUPLICATE_PACKAGE') {
if (versionInput && (versionInput.filePath || versionInput.dockerImage)) {
try {
const replacedVersionId = await repository.replacePackageVersionArtifact(versionInput);
if (replacedVersionId) {
redirectWithNotice(
res,
`/packages/${versionInput.packageId}`,
'success',
'Da re-upload package version hien co va cap nhat artifact.'
);
return;
}
} catch (replaceError) {
await removeUploadedFile(req.file);
throw replaceError;
}
}
await removeUploadedFile(req.file);
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', 'OpenUrl', '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.openUrl,
application.notes
])
];
sendCsv(res, 'applications.csv', rows);
}));
app.post('/applications', asyncRoute(async (req, res) => {
const appCode = String(req.body.appCode || '').trim();
const appName = String(req.body.appName || '').trim();
const appVersion = String(req.body.appVersion || '').trim();
const openUrl = normalizeApplicationOpenUrl(req.body.openUrl);
if (!appCode || !appName || !appVersion) {
redirectWithNotice(res, '/builder', 'warning', 'Vui lòng nhập App code, App name và App version.');
return;
}
if (openUrl === null) {
redirectWithNotice(res, '/builder', 'warning', 'Open URL khong hop le. Hay nhap URL http/https, vi du http://127.0.0.1:5000.');
return;
}
if (!isInstallerIdentifier(appCode)) {
redirectWithNotice(res, '/builder', 'warning', `App code khong hop le. ${installerIdentifierHint}`);
return;
}
if (!isInstallerVersion(appVersion)) {
redirectWithNotice(res, '/builder', 'warning', `App version khong hop le. ${installerVersionHint}`);
return;
}
try {
const applicationId = await repository.createApplication({
appCode,
appName,
appVersion,
notes: req.body.notes,
openUrl,
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}`);
const appCode = String(req.body.appCode || '').trim();
const appName = String(req.body.appName || '').trim();
const appVersion = String(req.body.appVersion || '').trim();
const openUrl = normalizeApplicationOpenUrl(req.body.openUrl);
if (!appCode || !appName || !appVersion) {
redirectWithNotice(res, returnTo, 'warning', 'Vui lòng nhập App code, App name và App version.');
return;
}
if (openUrl === null) {
redirectWithNotice(res, returnTo, 'warning', 'Open URL khong hop le. Hay nhap URL http/https, vi du http://127.0.0.1:5000.');
return;
}
if (!isInstallerIdentifier(appCode)) {
redirectWithNotice(res, returnTo, 'warning', `App code khong hop le. ${installerIdentifierHint}`);
return;
}
if (!isInstallerVersion(appVersion)) {
redirectWithNotice(res, returnTo, 'warning', `App version khong hop le. ${installerVersionHint}`);
return;
}
try {
const updatedApplication = await repository.updateApplication({
applicationId,
appCode,
appName,
appVersion,
notes: req.body.notes,
openUrl,
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(
'error',
viewModel(req, 'dashboard', 'Có lỗi xảy ra', pageData, {
errorTitle: 'Không thể tải dữ liệu',
errorMessage: 'Web server không đọc được dữ liệu từ SQL Server. Kiểm tra env kết nối, network tới SQL Server và schema/views trong database.',
errorDetails: getErrorDetails(error),
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);