1965 lines
59 KiB
JavaScript
1965 lines
59 KiB
JavaScript
require('dotenv').config({ quiet: true });
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const fsp = require('fs/promises');
|
|
const path = require('path');
|
|
const express = require('express');
|
|
const multer = require('multer');
|
|
const repository = require('./src/repository');
|
|
const mailer = require('./src/mailer');
|
|
const { closePool, 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 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://robot.installer',
|
|
'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'
|
|
});
|
|
|
|
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 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)}¬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)}`;
|
|
}
|
|
|
|
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);
|
|
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 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 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,
|
|
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
|
|
}))
|
|
});
|
|
}));
|
|
|
|
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,
|
|
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 package version.',
|
|
missingPackageFiles
|
|
});
|
|
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)"
|
|
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#')"
|
|
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 "$PACKAGE_BASE_URL,http://localhost:3000,http://127.0.0.1:3000"
|
|
set_agent_env ALLOWED_DOWNLOAD_HOSTS "$PACKAGE_HOST,localhost,127.0.0.1"
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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) => {
|
|
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;
|
|
}
|
|
|
|
await repository.addPackageVersion({
|
|
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
|
|
});
|
|
|
|
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.');
|
|
} catch (error) {
|
|
await removeUploadedFile(req.file);
|
|
if (error.code === 'DUPLICATE_PACKAGE') {
|
|
redirectWithNotice(res, `/packages/${req.body.packageId || ''}`, 'warning', 'Version này đã tồn tại trong package.');
|
|
return;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}));
|
|
|
|
app.post('/packages/:id/delete', asyncRoute(async (req, res) => {
|
|
const deleted = await repository.deletePackage(req.params.id);
|
|
|
|
redirectWithNotice(
|
|
res,
|
|
'/packages',
|
|
deleted ? 'success' : 'warning',
|
|
deleted ? 'Đã xóa package và các liên kết database.' : 'Không tìm thấy package cần xóa.'
|
|
);
|
|
}));
|
|
|
|
app.post('/package-versions/:id/latest', asyncRoute(async (req, res) => {
|
|
const returnTo = sanitizeReturnTo(req.body.returnTo || '/packages');
|
|
|
|
await repository.setLatestPackageVersion(req.params.id);
|
|
redirectWithNotice(res, returnTo, 'success', 'Đã đặt version là latest trong database.');
|
|
}));
|
|
|
|
app.post('/package-versions/:id/delete', asyncRoute(async (req, res) => {
|
|
const returnTo = sanitizeReturnTo(req.body.returnTo || '/packages');
|
|
const result = await repository.deletePackageVersion(req.params.id);
|
|
|
|
redirectWithNotice(
|
|
res,
|
|
result.packageId ? `/packages/${result.packageId}` : returnTo,
|
|
result.deleted ? 'success' : 'warning',
|
|
result.deleted ? 'Đã xóa version và các liên kết app đang dùng version đó.' : 'Không tìm thấy version cần xóa.'
|
|
);
|
|
}));
|
|
|
|
app.get('/packages/:id', asyncRoute(async (req, res) => {
|
|
const pageData = await repository.getPageData(req.currentUser);
|
|
const packageItem = await repository.getPackageById(req.params.id);
|
|
|
|
if (!packageItem) {
|
|
res.status(404).render('not-found', viewModel(req, 'packages', 'Không tìm thấy', pageData));
|
|
return;
|
|
}
|
|
|
|
res.render(
|
|
'package-detail',
|
|
viewModel(req, 'packages', packageItem.name, pageData, { packageItem })
|
|
);
|
|
}));
|
|
|
|
app.get('/applications', asyncRoute(async (req, res) => {
|
|
const pageData = await repository.getPageData(req.currentUser);
|
|
res.render('applications', viewModel(req, 'applications', 'Applications', pageData));
|
|
}));
|
|
|
|
app.get('/applications/export.csv', asyncRoute(async (req, res) => {
|
|
const applications = await repository.listApplications();
|
|
const rows = [
|
|
['AppCode', 'AppName', 'AppVersion', 'Status', 'CreatedAt', 'CreatedBy', 'PackageCount', 'Packages', 'Notes'],
|
|
...applications.map((application) => [
|
|
application.code,
|
|
application.name,
|
|
application.version,
|
|
application.status,
|
|
application.createdAt,
|
|
application.createdBy,
|
|
application.packageCount,
|
|
application.packages.map((packageItem) => `${packageItem.code}@${packageItem.selectedVersion}`).join('; '),
|
|
application.notes
|
|
])
|
|
];
|
|
|
|
sendCsv(res, 'applications.csv', rows);
|
|
}));
|
|
|
|
app.post('/applications', asyncRoute(async (req, res) => {
|
|
const appCode = String(req.body.appCode || '').trim();
|
|
const appName = String(req.body.appName || '').trim();
|
|
const appVersion = String(req.body.appVersion || '').trim();
|
|
|
|
if (!appCode || !appName || !appVersion) {
|
|
redirectWithNotice(res, '/builder', 'warning', 'Vui lòng nhập App code, App name và App version.');
|
|
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,
|
|
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();
|
|
|
|
if (!appCode || !appName || !appVersion) {
|
|
redirectWithNotice(res, returnTo, 'warning', 'Vui lòng nhập App code, App name và App version.');
|
|
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,
|
|
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);
|