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__.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 toDateInputValue(value = new Date()) { const date = value instanceof Date ? value : new Date(value); const safeDate = Number.isNaN(date.getTime()) ? new Date() : date; const year = safeDate.getFullYear(); const month = String(safeDate.getMonth() + 1).padStart(2, '0'); const day = String(safeDate.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; } 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(), todayDate: toDateInputValue(), 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)}¬ice=${encodeURIComponent(message)}`); } function parseCookies(cookieHeader) { if (!cookieHeader) return {}; return cookieHeader.split(';').reduce((cookies, pair) => { const index = pair.indexOf('='); if (index === -1) return cookies; const key = pair.slice(0, index).trim(); const value = pair.slice(index + 1).trim(); try { cookies[key] = decodeURIComponent(value); } catch (error) { cookies[key] = value; } return cookies; }, {}); } function signSessionPayload(payload) { return crypto .createHmac('sha256', authSecret) .update(payload) .digest('base64url'); } function createSessionToken(user) { const payload = Buffer .from(JSON.stringify({ sub: user.id, exp: Date.now() + sessionMaxAgeMs })) .toString('base64url'); const signature = signSessionPayload(payload); return `${payload}.${signature}`; } function verifySessionToken(token) { if (!token || !token.includes('.')) return null; const [payload, signature] = token.split('.'); const expectedSignature = signSessionPayload(payload); const signatureBuffer = Buffer.from(signature); const expectedSignatureBuffer = Buffer.from(expectedSignature); if ( signatureBuffer.length !== expectedSignatureBuffer.length || !crypto.timingSafeEqual(signatureBuffer, expectedSignatureBuffer) ) { return null; } try { const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); if (!session.sub || Number(session.exp) < Date.now()) return null; return session; } catch (error) { return null; } } function setAuthCookie(res, user) { res.cookie(authCookieName, createSessionToken(user), { httpOnly: true, maxAge: sessionMaxAgeMs, sameSite: 'lax', secure: 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);