1529 lines
46 KiB
JavaScript
1529 lines
46 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 } = 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 agentVersionCollator = new Intl.Collator('en', {
|
|
numeric: true,
|
|
sensitivity: 'base'
|
|
});
|
|
|
|
fs.mkdirSync(uploadDir, { recursive: true });
|
|
fs.mkdirSync(agentPackageDir, { recursive: true });
|
|
|
|
const navItems = [
|
|
{ id: 'dashboard', label: 'Tổng quan', href: '/', icon: 'dashboard' },
|
|
{ id: 'packages', label: 'Packages', href: '/packages', icon: 'inventory_2' },
|
|
{ id: 'applications', label: 'Applications', href: '/applications', icon: 'apps' },
|
|
{ id: 'builder', label: 'Đóng gói App', href: '/builder', icon: 'deployed_code' },
|
|
{ id: 'agent', label: 'Agent', href: '/agent', icon: 'memory', adminOnly: true },
|
|
{ id: 'users', label: 'Users', href: '/users', icon: 'group', adminOnly: true }
|
|
];
|
|
|
|
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.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
|
|
const arch = normalizeAgentArch(req.query.arch);
|
|
const latestPackage = await findLatestAgentPackage(arch);
|
|
|
|
if (latestPackage) {
|
|
res.type('application/vnd.debian.binary-package');
|
|
res.setHeader('Content-Disposition', `attachment; filename="${latestPackage.fileName}"`);
|
|
res.setHeader('X-Agent-Version', latestPackage.version);
|
|
res.sendFile(latestPackage.filePath);
|
|
return;
|
|
}
|
|
|
|
const latestPackageRecord = await findLatestAgentPackageRecord(arch);
|
|
if (latestPackageRecord) {
|
|
res.setHeader('X-Agent-Version', latestPackageRecord.version);
|
|
res.redirect(latestPackageRecord.filePath);
|
|
return;
|
|
}
|
|
|
|
res.status(404).type('text/plain').send(
|
|
`No Local Installer Agent package found${arch ? ` for ${arch}` : ''}. ` +
|
|
'Upload local-installer-agent_<version>_<arch>.deb to web-server/uploads/packages/agent, ' +
|
|
'or upload it as package code local-installer-agent.'
|
|
);
|
|
}));
|
|
app.use('/uploads/packages', express.static(uploadDir));
|
|
app.use('/packages/agent', express.static(agentPackageDir));
|
|
app.use(loadCurrentUser);
|
|
|
|
function helpers() {
|
|
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 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;
|
|
|
|
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),
|
|
helpers: helpers(),
|
|
...extra
|
|
};
|
|
}
|
|
|
|
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: process.env.NODE_ENV === 'production'
|
|
});
|
|
}
|
|
|
|
function clearAuthCookie(res) {
|
|
res.clearCookie(authCookieName, {
|
|
httpOnly: true,
|
|
sameSite: 'lax',
|
|
secure: process.env.NODE_ENV === 'production'
|
|
});
|
|
}
|
|
|
|
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) {
|
|
if (process.env.APP_BASE_URL) {
|
|
return process.env.APP_BASE_URL.replace(/\/+$/, '');
|
|
}
|
|
|
|
const forwardedProtocol = req.headers['x-forwarded-proto'];
|
|
const protocol = forwardedProtocol
|
|
? String(forwardedProtocol).split(',')[0].trim()
|
|
: req.protocol;
|
|
|
|
return `${protocol}://${req.get('host')}`;
|
|
}
|
|
|
|
function normalizeAgentArch(value) {
|
|
const arch = String(value || '').trim().toLowerCase();
|
|
return /^[a-z0-9][a-z0-9._-]*$/.test(arch) ? arch : '';
|
|
}
|
|
|
|
function isValidAgentVersion(value) {
|
|
return /^[a-zA-Z0-9][a-zA-Z0-9._+~=-]*$/.test(String(value || '').trim());
|
|
}
|
|
|
|
function compareAgentPackages(first, second) {
|
|
const versionCompare = agentVersionCollator.compare(first.version, second.version);
|
|
|
|
if (versionCompare !== 0) return versionCompare;
|
|
return first.mtimeMs - second.mtimeMs;
|
|
}
|
|
|
|
function formatBytes(bytes) {
|
|
const value = Number(bytes || 0);
|
|
if (value < 1024) return `${value} B`;
|
|
|
|
const units = ['KB', 'MB', 'GB'];
|
|
let size = value / 1024;
|
|
let unitIndex = 0;
|
|
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024;
|
|
unitIndex += 1;
|
|
}
|
|
|
|
return `${size.toFixed(size >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
|
}
|
|
|
|
function formatLocalDateTime(value) {
|
|
return new Intl.DateTimeFormat('vi-VN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}).format(value);
|
|
}
|
|
|
|
function getAgentPackageDownloadPath(fileName) {
|
|
return `/packages/agent/${encodeURIComponent(fileName)}`;
|
|
}
|
|
|
|
async function getAgentPackageFromEntry(entry) {
|
|
const match = /^local-installer-agent_([^/\\]+)_([^/\\]+)\.deb$/.exec(entry.name);
|
|
if (!match) return null;
|
|
|
|
const [, version, packageArch] = match;
|
|
const filePath = path.join(agentPackageDir, entry.name);
|
|
const stat = await fsp.stat(filePath);
|
|
|
|
return {
|
|
fileName: entry.name,
|
|
filePath,
|
|
version,
|
|
arch: packageArch,
|
|
size: stat.size,
|
|
sizeLabel: formatBytes(stat.size),
|
|
uploadedAt: formatLocalDateTime(stat.mtime),
|
|
mtimeMs: stat.mtimeMs,
|
|
downloadPath: getAgentPackageDownloadPath(entry.name)
|
|
};
|
|
}
|
|
|
|
async function listAgentPackages(arch = '') {
|
|
let dirEntries;
|
|
try {
|
|
dirEntries = await fsp.readdir(agentPackageDir, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (error.code === 'ENOENT') return null;
|
|
throw error;
|
|
}
|
|
|
|
const packages = (await Promise.all(
|
|
dirEntries
|
|
.filter((entry) => entry.isFile())
|
|
.map(getAgentPackageFromEntry)
|
|
))
|
|
.filter(Boolean)
|
|
.filter((packageItem) => !arch || packageItem.arch.toLowerCase() === arch);
|
|
|
|
const latestByArch = new Map();
|
|
packages
|
|
.sort(compareAgentPackages);
|
|
|
|
packages.forEach((packageItem) => {
|
|
latestByArch.set(packageItem.arch.toLowerCase(), packageItem.fileName);
|
|
});
|
|
|
|
return packages
|
|
.sort((first, second) => compareAgentPackages(second, first))
|
|
.map((packageItem) => ({
|
|
...packageItem,
|
|
isLatestForArch: latestByArch.get(packageItem.arch.toLowerCase()) === packageItem.fileName
|
|
}));
|
|
}
|
|
|
|
async function findLatestAgentPackage(arch = '') {
|
|
const packages = await listAgentPackages(arch);
|
|
const sortedPackages = packages
|
|
.slice()
|
|
.sort(compareAgentPackages);
|
|
|
|
return sortedPackages[sortedPackages.length - 1] || null;
|
|
}
|
|
|
|
function packageVersionMatchesArch(version, arch) {
|
|
if (!arch) return true;
|
|
return String(version.filePath || '').toLowerCase().includes(`_${arch}.deb`);
|
|
}
|
|
|
|
async function findLatestAgentPackageRecord(arch = '') {
|
|
try {
|
|
const packageItem = await repository.getPackageById('local-installer-agent');
|
|
const latestVersion = packageItem?.versions
|
|
?.filter((version) => version.filePath && packageVersionMatchesArch(version, arch))
|
|
?.[0];
|
|
|
|
if (!latestVersion) return null;
|
|
|
|
return {
|
|
filePath: latestVersion.filePath,
|
|
version: latestVersion.version
|
|
};
|
|
} catch (error) {
|
|
console.warn('Cannot read local-installer-agent package from database:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadCurrentUser(req, res, next) {
|
|
try {
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
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 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 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.code,
|
|
appName: application.name,
|
|
version: application.version,
|
|
status: application.status,
|
|
packageCount: application.packageCount
|
|
}))
|
|
});
|
|
}));
|
|
|
|
app.get('/api/apps/:appCode', asyncRoute(async (req, res) => {
|
|
const application = await repository.getApplicationById(req.params.appCode);
|
|
if (!application || application.status !== 'Released') {
|
|
res.status(404).json({ error: 'Application not found' });
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
appId: application.code,
|
|
appName: application.name,
|
|
version: application.version,
|
|
status: application.status,
|
|
packageCount: application.packageCount,
|
|
packages: application.packages
|
|
});
|
|
}));
|
|
|
|
app.get('/api/apps/:appCode/versions/:version/manifest', asyncRoute(async (req, res) => {
|
|
const manifest = await repository.getApplicationManifest(
|
|
req.params.appCode,
|
|
req.params.version,
|
|
getBaseUrl(req)
|
|
);
|
|
|
|
if (!manifest) {
|
|
res.status(404).json({ error: 'Application manifest not found' });
|
|
return;
|
|
}
|
|
|
|
res.json(manifest);
|
|
}));
|
|
|
|
app.get('/install-agent.sh', (req, res) => {
|
|
const baseUrl = getBaseUrl(req);
|
|
const agentUrl = `${baseUrl}/packages/agent/latest.deb`;
|
|
res.type('text/x-shellscript').send(`#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ARCH="$(dpkg --print-architecture)"
|
|
AGENT_URL="${agentUrl}?arch=$ARCH"
|
|
TMP_DEB="/tmp/local-installer-agent.deb"
|
|
|
|
echo "Downloading Local Installer Agent..."
|
|
curl -fL "$AGENT_URL" -o "$TMP_DEB"
|
|
|
|
echo "Installing Local Installer Agent..."
|
|
apt install -y "$TMP_DEB"
|
|
|
|
echo "Starting Local Installer Agent..."
|
|
systemctl enable local-installer-agent
|
|
systemctl restart local-installer-agent
|
|
|
|
echo "Checking Agent..."
|
|
curl -fsSL http://127.0.0.1:5010/health
|
|
|
|
echo ""
|
|
echo "Local Installer Agent installed successfully."
|
|
`);
|
|
});
|
|
|
|
app.use(requireAuthenticated);
|
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
|
|
|
app.get('/agent', requireAdmin, asyncRoute(async (req, res) => {
|
|
const pageData = await repository.getPageData(req.currentUser);
|
|
const agentPackages = await listAgentPackages();
|
|
const preferredArch = normalizeAgentArch(req.query.arch) || 'amd64';
|
|
const latestAgentPackage = await findLatestAgentPackage(preferredArch);
|
|
const baseUrl = getBaseUrl(req);
|
|
|
|
res.render('agent', viewModel(req, 'agent', 'Agent packages', pageData, {
|
|
agentPackages,
|
|
latestAgentPackage,
|
|
agentPackageDir,
|
|
preferredArch,
|
|
installCommand: `curl -fsSL ${baseUrl}/install-agent.sh | sudo bash`,
|
|
latestAgentUrl: `${baseUrl}/packages/agent/latest.deb?arch=${preferredArch}`
|
|
}));
|
|
}));
|
|
|
|
app.post('/agent/packages', requireAdmin, agentUpload.single('agentFile'), asyncRoute(async (req, res) => {
|
|
const version = String(req.body.version || '').trim();
|
|
const arch = normalizeAgentArch(req.body.arch);
|
|
|
|
try {
|
|
if (!req.file || !version || !arch) {
|
|
await removeUploadedFile(req.file);
|
|
redirectWithNotice(res, '/agent', 'warning', 'Vui lòng chọn file .deb, nhập version và architecture.');
|
|
return;
|
|
}
|
|
|
|
if (!isValidAgentVersion(version)) {
|
|
await removeUploadedFile(req.file);
|
|
redirectWithNotice(res, '/agent', 'warning', 'Version chỉ nên chứa chữ, số, dấu chấm, gạch ngang hoặc gạch dưới.');
|
|
return;
|
|
}
|
|
|
|
if (path.extname(req.file.originalname).toLowerCase() !== '.deb') {
|
|
await removeUploadedFile(req.file);
|
|
redirectWithNotice(res, '/agent', 'warning', 'Agent package phải là file .deb.');
|
|
return;
|
|
}
|
|
|
|
const targetFileName = `local-installer-agent_${version}_${arch}.deb`;
|
|
const targetPath = path.join(agentPackageDir, targetFileName);
|
|
const isUpdate = fs.existsSync(targetPath);
|
|
|
|
await fsp.mkdir(agentPackageDir, { recursive: true });
|
|
await fsp.rename(req.file.path, targetPath);
|
|
|
|
redirectWithNotice(
|
|
res,
|
|
'/agent',
|
|
'success',
|
|
isUpdate ? 'Đã cập nhật Agent package.' : 'Đã upload Agent package mới.'
|
|
);
|
|
} catch (error) {
|
|
await removeUploadedFile(req.file);
|
|
throw error;
|
|
}
|
|
}));
|
|
|
|
app.post('/profile', asyncRoute(async (req, res) => {
|
|
const returnTo = sanitizeReturnTo(req.body.returnTo);
|
|
const fullName = String(req.body.fullName || '').trim();
|
|
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);
|
|
|
|
if (!req.body.packageCode || !req.body.packageName || !req.body.version) {
|
|
await removeUploadedFile(req.file);
|
|
redirectWithNotice(res, '/packages', 'warning', 'Vui lòng nhập Package code, Package name và Version.');
|
|
return;
|
|
}
|
|
|
|
await repository.createPackageWithVersion({
|
|
packageCode: req.body.packageCode.trim(),
|
|
packageName: req.body.packageName.trim(),
|
|
packageType,
|
|
description: req.body.description,
|
|
version: req.body.version.trim(),
|
|
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);
|
|
|
|
if (!req.body.packageId || !req.body.version) {
|
|
await removeUploadedFile(req.file);
|
|
redirectWithNotice(res, '/packages', 'warning', 'Vui lòng chọn package và nhập version mới.');
|
|
return;
|
|
}
|
|
|
|
await repository.addPackageVersion({
|
|
packageId: req.body.packageId,
|
|
version: req.body.version.trim(),
|
|
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) => {
|
|
if (!req.body.appCode || !req.body.appName || !req.body.appVersion) {
|
|
redirectWithNotice(res, '/builder', 'warning', 'Vui lòng nhập App code, App name và App version.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const applicationId = await repository.createApplication({
|
|
appCode: req.body.appCode.trim(),
|
|
appName: req.body.appName.trim(),
|
|
appVersion: req.body.appVersion.trim(),
|
|
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}`);
|
|
|
|
if (!req.body.appCode || !req.body.appName || !req.body.appVersion) {
|
|
redirectWithNotice(res, returnTo, 'warning', 'Vui lòng nhập App code, App name và App version.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const updatedApplication = await repository.updateApplication({
|
|
applicationId,
|
|
appCode: req.body.appCode.trim(),
|
|
appName: req.body.appName.trim(),
|
|
appVersion: req.body.appVersion.trim(),
|
|
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(
|
|
'not-found',
|
|
viewModel(req, 'dashboard', 'Có lỗi xảy ra', pageData, {
|
|
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);
|