agent
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user