This commit is contained in:
2026-05-22 16:47:51 +07:00
parent 190d2418da
commit 582960cc32
39 changed files with 2307 additions and 2 deletions

View File

@@ -14,17 +14,24 @@ 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 }
];
@@ -57,6 +64,21 @@ const storage = multer.diskStorage({
}
});
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: {
@@ -64,12 +86,46 @@ const upload = multer({
}
});
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() {
@@ -281,6 +337,139 @@ function getBaseUrl(req) {
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);
@@ -652,9 +841,143 @@ app.post('/logout', (req, 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();