laster 0.0.1
This commit is contained in:
@@ -16,6 +16,8 @@ SESSION_MAX_AGE_MS=28800000
|
||||
SESSION_COOKIE_SECURE=false
|
||||
EMAIL_CONFIRMATION_EXPIRES_MS=86400000
|
||||
APP_BASE_URL=http://localhost:3000
|
||||
APP_SHOW_ERROR_DETAILS=false
|
||||
WEB_CLIENT_ORIGINS=http://localhost:8080,http://localhost:5173,http://localhost:4173,http://localhost:3000,http://127.0.0.1:3000
|
||||
|
||||
# Mail chính dùng để gửi email xác nhận tới các tài khoản đăng ký
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
|
||||
@@ -12,17 +12,20 @@ ENV PORT=3000
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache su-exec
|
||||
|
||||
COPY --from=dependencies /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
COPY docker-entrypoint.sh ./docker-entrypoint.sh
|
||||
|
||||
RUN mkdir -p uploads/packages/agent \
|
||||
&& chown -R node:node uploads
|
||||
|
||||
USER node
|
||||
&& chown -R node:node uploads \
|
||||
&& chmod +x docker-entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 3000) + '/healthz').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["./docker-entrypoint.sh"]
|
||||
CMD ["npm", "start"]
|
||||
|
||||
@@ -11,7 +11,7 @@ services:
|
||||
PORT: 3000
|
||||
APP_BASE_URL: ${APP_BASE_URL:-http://localhost:8080}
|
||||
SESSION_COOKIE_SECURE: ${SESSION_COOKIE_SECURE:-false}
|
||||
WEB_CLIENT_ORIGINS: ${WEB_CLIENT_ORIGINS:-http://localhost:8080,http://localhost:5173,http://localhost:4173}
|
||||
WEB_CLIENT_ORIGINS: ${WEB_CLIENT_ORIGINS:-http://localhost:8080,http://localhost:5173,http://localhost:4173,http://localhost:3000,http://127.0.0.1:3000}
|
||||
ports:
|
||||
- "${WEB_SERVER_PORT:-3000}:3000"
|
||||
volumes:
|
||||
@@ -27,3 +27,4 @@ networks:
|
||||
|
||||
volumes:
|
||||
web_server_uploads:
|
||||
name: ${WEB_SERVER_UPLOADS_VOLUME:-robot-installer-web-server-uploads}
|
||||
|
||||
7
web-server/docker-entrypoint.sh
Normal file
7
web-server/docker-entrypoint.sh
Normal file
@@ -0,0 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
mkdir -p /app/uploads/packages/agent
|
||||
chown -R node:node /app/uploads
|
||||
|
||||
exec su-exec node "$@"
|
||||
@@ -1209,6 +1209,23 @@ tbody tr:hover td.action-col {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.error-state .material-symbols-outlined {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.error-detail {
|
||||
background: #fff7ed;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #9a3412;
|
||||
display: block;
|
||||
line-height: 1.45;
|
||||
max-width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.center-page {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -8,7 +8,7 @@ const express = require('express');
|
||||
const multer = require('multer');
|
||||
const repository = require('./src/repository');
|
||||
const mailer = require('./src/mailer');
|
||||
const { closePool } = require('./src/db');
|
||||
const { closePool, getPool } = require('./src/db');
|
||||
const notiflixVersion = require('notiflix/package.json').version;
|
||||
|
||||
const app = express();
|
||||
@@ -24,6 +24,10 @@ const publicApiCorsOrigins = getCsvEnv(process.env.WEB_CLIENT_ORIGINS || process
|
||||
'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'
|
||||
@@ -33,6 +37,15 @@ 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 });
|
||||
|
||||
@@ -135,8 +148,51 @@ app.get('/packages/agent/latest.deb', asyncRoute(async (req, res) => {
|
||||
'or upload it as package code local-installer-agent.'
|
||||
);
|
||||
}));
|
||||
app.use('/uploads/packages', express.static(uploadDir));
|
||||
app.use('/packages/agent', express.static(agentPackageDir));
|
||||
|
||||
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() {
|
||||
@@ -169,6 +225,8 @@ function getBooleanEnv(value, fallback) {
|
||||
function isPublicApiCorsPath(pathname) {
|
||||
return pathname === '/api/apps'
|
||||
|| pathname.startsWith('/api/apps/')
|
||||
|| pathname === '/api/agent/latest'
|
||||
|| pathname.startsWith('/api/package-versions/')
|
||||
|| pathname === '/install-agent.sh';
|
||||
}
|
||||
|
||||
@@ -196,6 +254,17 @@ function applyPublicApiCors(req, res, next) {
|
||||
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'));
|
||||
}
|
||||
@@ -218,7 +287,12 @@ function getCurrentPath(req) {
|
||||
}
|
||||
|
||||
function viewModel(req, active, title, pageData, extra = {}) {
|
||||
const currentUser = pageData.currentUser || req.currentUser;
|
||||
const currentUser = pageData.currentUser || req.currentUser || {
|
||||
name: 'Guest',
|
||||
username: 'guest',
|
||||
role: 'Guest',
|
||||
email: ''
|
||||
};
|
||||
|
||||
return {
|
||||
active,
|
||||
@@ -232,11 +306,61 @@ function viewModel(req, active, title, pageData, extra = {}) {
|
||||
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'
|
||||
? {
|
||||
@@ -379,16 +503,53 @@ function sanitizeReturnTo(value) {
|
||||
}
|
||||
|
||||
function getBaseUrl(req) {
|
||||
const requestBaseUrl = getRequestBaseUrl(req);
|
||||
if (process.env.APP_BASE_URL) {
|
||||
return process.env.APP_BASE_URL.replace(/\/+$/, '');
|
||||
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');
|
||||
|
||||
return `${protocol}://${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) {
|
||||
@@ -493,6 +654,7 @@ async function listAgentPackages(arch = '') {
|
||||
|
||||
async function findLatestAgentPackage(arch = '') {
|
||||
const packages = await listAgentPackages(arch);
|
||||
if (!packages) return null;
|
||||
const sortedPackages = packages
|
||||
.slice()
|
||||
.sort(compareAgentPackages);
|
||||
@@ -576,6 +738,14 @@ 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];
|
||||
@@ -654,6 +824,91 @@ async function getArtifactFromUpload(file) {
|
||||
};
|
||||
}
|
||||
|
||||
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)}`;
|
||||
@@ -901,7 +1156,8 @@ app.get('/api/apps', asyncRoute(async (req, res) => {
|
||||
apps: applications
|
||||
.filter((application) => application.status === 'Released')
|
||||
.map((application) => ({
|
||||
appId: application.code,
|
||||
appId: application.id,
|
||||
appCode: application.code,
|
||||
appName: application.name,
|
||||
version: application.version,
|
||||
status: application.status,
|
||||
@@ -918,7 +1174,8 @@ app.get('/api/apps/:appCode', asyncRoute(async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({
|
||||
appId: application.code,
|
||||
appId: application.id,
|
||||
appCode: application.code,
|
||||
appName: application.name,
|
||||
version: application.version,
|
||||
status: application.status,
|
||||
@@ -927,6 +1184,39 @@ app.get('/api/apps/:appCode', asyncRoute(async (req, res) => {
|
||||
});
|
||||
}));
|
||||
|
||||
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,
|
||||
@@ -939,6 +1229,16 @@ app.get('/api/apps/:appCode/versions/:version/manifest', asyncRoute(async (req,
|
||||
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);
|
||||
}));
|
||||
|
||||
@@ -949,24 +1249,54 @@ app.get('/install-agent.sh', (req, res) => {
|
||||
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..."
|
||||
curl -fsSL http://127.0.0.1:5010/health
|
||||
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 installed successfully."
|
||||
echo "Local Installer Agent did not become healthy. Recent service logs:"
|
||||
journalctl -u local-installer-agent -n 80 --no-pager || true
|
||||
exit 1
|
||||
`);
|
||||
});
|
||||
|
||||
@@ -1136,19 +1466,34 @@ 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 (!req.body.packageCode || !req.body.packageName || !req.body.version) {
|
||||
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: req.body.packageCode.trim(),
|
||||
packageName: req.body.packageName.trim(),
|
||||
packageCode,
|
||||
packageName,
|
||||
packageType,
|
||||
description: req.body.description,
|
||||
version: req.body.version.trim(),
|
||||
version,
|
||||
releaseDate: req.body.releaseDate,
|
||||
filePath: artifact.filePath,
|
||||
dockerImage: req.body.dockerImage && req.body.dockerImage.trim(),
|
||||
@@ -1173,16 +1518,23 @@ app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res)
|
||||
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 || !req.body.version) {
|
||||
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: req.body.version.trim(),
|
||||
version,
|
||||
releaseDate: req.body.releaseDate,
|
||||
filePath: artifact.filePath,
|
||||
dockerImage: req.body.dockerImage && req.body.dockerImage.trim(),
|
||||
@@ -1274,16 +1626,30 @@ app.get('/applications/export.csv', asyncRoute(async (req, res) => {
|
||||
}));
|
||||
|
||||
app.post('/applications', asyncRoute(async (req, res) => {
|
||||
if (!req.body.appCode || !req.body.appName || !req.body.appVersion) {
|
||||
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: req.body.appCode.trim(),
|
||||
appName: req.body.appName.trim(),
|
||||
appVersion: req.body.appVersion.trim(),
|
||||
appCode,
|
||||
appName,
|
||||
appVersion,
|
||||
notes: req.body.notes,
|
||||
status: normalizeApplicationStatus(req.body.status),
|
||||
createdByUserId: req.currentUser.id,
|
||||
@@ -1304,18 +1670,31 @@ app.post('/applications', asyncRoute(async (req, res) => {
|
||||
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 (!req.body.appCode || !req.body.appName || !req.body.appVersion) {
|
||||
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: req.body.appCode.trim(),
|
||||
appName: req.body.appName.trim(),
|
||||
appVersion: req.body.appVersion.trim(),
|
||||
appCode,
|
||||
appName,
|
||||
appVersion,
|
||||
notes: req.body.notes,
|
||||
status: normalizeApplicationStatus(req.body.status),
|
||||
packages: getSelectedApplicationPackages(req.body)
|
||||
@@ -1557,8 +1936,11 @@ app.use(async (error, req, res, next) => {
|
||||
}));
|
||||
|
||||
res.status(500).render(
|
||||
'not-found',
|
||||
'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.'
|
||||
|
||||
@@ -262,11 +262,28 @@ function mapApplicationPackageRow(row) {
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname) {
|
||||
const host = String(hostname || '').toLowerCase();
|
||||
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
||||
}
|
||||
|
||||
function toAbsoluteUrl(baseUrl, filePath) {
|
||||
if (!filePath) return '';
|
||||
if (/^https?:\/\//i.test(filePath)) return filePath;
|
||||
|
||||
const normalizedBaseUrl = String(baseUrl || '').replace(/\/+$/, '');
|
||||
|
||||
if (/^https?:\/\//i.test(filePath)) {
|
||||
try {
|
||||
const parsed = new URL(filePath);
|
||||
if (isLoopbackHost(parsed.hostname) && parsed.pathname.startsWith('/uploads/')) {
|
||||
return `${normalizedBaseUrl}${parsed.pathname}${parsed.search}`;
|
||||
}
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
|
||||
return filePath;
|
||||
}
|
||||
|
||||
const normalizedPath = String(filePath).startsWith('/') ? filePath : `/${filePath}`;
|
||||
|
||||
return `${normalizedBaseUrl}${normalizedPath}`;
|
||||
@@ -824,7 +841,7 @@ async function getApplicationManifest(appCode, version, baseUrl) {
|
||||
.query(`
|
||||
SELECT TOP (1) Id, AppCode, AppName, AppVersion
|
||||
FROM dbo.Applications
|
||||
WHERE AppCode = @AppCode
|
||||
WHERE (CONVERT(NVARCHAR(36), Id) = @AppCode OR AppCode = @AppCode)
|
||||
AND AppVersion = @AppVersion
|
||||
AND Status = N'Released';
|
||||
`);
|
||||
@@ -840,6 +857,7 @@ async function getApplicationManifest(appCode, version, baseUrl) {
|
||||
p.PackageCode,
|
||||
p.PackageName,
|
||||
p.PackageType,
|
||||
COALESCE(selected_version.Id, latest_version.Id) AS PackageVersionId,
|
||||
COALESCE(selected_version.Version, latest_version.Version) AS Version,
|
||||
COALESCE(selected_version.FilePath, latest_version.FilePath) AS FilePath,
|
||||
COALESCE(selected_version.DockerImage, latest_version.DockerImage) AS DockerImage,
|
||||
@@ -883,14 +901,14 @@ async function getApplicationManifest(appCode, version, baseUrl) {
|
||||
required: true,
|
||||
packageName: row.PackageCode,
|
||||
version: row.Version || '',
|
||||
downloadUrl: toAbsoluteUrl(baseUrl, row.FilePath),
|
||||
downloadUrl: `${String(baseUrl || '').replace(/\/+$/, '')}/api/package-versions/${encodeURIComponent(String(row.PackageVersionId))}/download`,
|
||||
sha256: row.FileChecksumSha256 || ''
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
schemaVersion: '1.0',
|
||||
appId: appRow.AppCode,
|
||||
appId: String(appRow.Id),
|
||||
appName: appRow.AppName,
|
||||
version: appRow.AppVersion,
|
||||
architecture: 'amd64',
|
||||
@@ -943,6 +961,43 @@ async function getActivity() {
|
||||
}));
|
||||
}
|
||||
|
||||
async function getPackageVersionDownload(packageVersionId) {
|
||||
const pool = await getPool();
|
||||
const result = await pool.request()
|
||||
.input('Id', sql.NVarChar(100), String(packageVersionId || '').trim())
|
||||
.query(`
|
||||
SELECT TOP (1)
|
||||
pv.Id,
|
||||
pv.PackageId,
|
||||
pv.Version,
|
||||
pv.FilePath,
|
||||
pv.FileChecksumSha256,
|
||||
pv.FileSizeBytes,
|
||||
p.PackageCode,
|
||||
p.PackageName,
|
||||
p.PackageType
|
||||
FROM dbo.PackageVersions AS pv
|
||||
INNER JOIN dbo.Packages AS p
|
||||
ON p.Id = pv.PackageId
|
||||
WHERE CONVERT(NVARCHAR(36), pv.Id) = @Id;
|
||||
`);
|
||||
|
||||
const row = result.recordset[0];
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: String(row.Id),
|
||||
packageId: String(row.PackageId),
|
||||
packageCode: row.PackageCode,
|
||||
packageName: row.PackageName,
|
||||
packageType: row.PackageType,
|
||||
version: row.Version,
|
||||
filePath: row.FilePath || '',
|
||||
checksum: row.FileChecksumSha256 || '',
|
||||
fileSizeBytes: Number(row.FileSizeBytes || 0)
|
||||
};
|
||||
}
|
||||
|
||||
async function createPackageWithVersion(input) {
|
||||
const pool = await getPool();
|
||||
const transaction = new sql.Transaction(pool);
|
||||
@@ -1309,6 +1364,7 @@ module.exports = {
|
||||
listPackages,
|
||||
listApplications,
|
||||
getApplicationManifest,
|
||||
getPackageVersionDownload,
|
||||
getPackageById,
|
||||
getApplicationById,
|
||||
createPackageWithVersion,
|
||||
|
||||
@@ -28,11 +28,11 @@
|
||||
<form id="builderForm" class="form-stack" action="/applications" method="post">
|
||||
<label class="form-field">
|
||||
<span>App code</span>
|
||||
<input type="text" name="appCode" required>
|
||||
<input type="text" name="appCode" pattern="[A-Za-z0-9._+-]+" title="Only letters, numbers, dot, underscore, plus and hyphen. No spaces." required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>App version</span>
|
||||
<input type="text" name="appVersion" required>
|
||||
<input type="text" name="appVersion" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
|
||||
</label>
|
||||
<label class="form-field full">
|
||||
<span>App name</span>
|
||||
|
||||
18
web-server/views/error.ejs
Normal file
18
web-server/views/error.ejs
Normal file
@@ -0,0 +1,18 @@
|
||||
<%- include('partials/page-start') %>
|
||||
|
||||
<section class="page center-page">
|
||||
<div class="empty-state error-state">
|
||||
<span class="material-symbols-outlined">database_off</span>
|
||||
<h1><%= errorTitle || 'Không thể tải dữ liệu' %></h1>
|
||||
<p><%= errorMessage || 'Web server đang gặp lỗi khi đọc dữ liệu. Kiểm tra log container để xem nguyên nhân chi tiết.' %></p>
|
||||
<% if (errorDetails) { %>
|
||||
<code class="error-detail"><%= errorDetails.code %>: <%= errorDetails.message %></code>
|
||||
<% } %>
|
||||
<a class="btn btn-primary" href="/">
|
||||
<span class="material-symbols-outlined">sync</span>
|
||||
Thử lại
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<%- include('partials/page-end') %>
|
||||
@@ -11,11 +11,11 @@
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>App code</span>
|
||||
<input type="text" name="appCode" required data-edit-app-field="appCode">
|
||||
<input type="text" name="appCode" pattern="[A-Za-z0-9._+-]+" title="Only letters, numbers, dot, underscore, plus and hyphen. No spaces." required data-edit-app-field="appCode">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Version</span>
|
||||
<input type="text" name="appVersion" required data-edit-app-field="appVersion">
|
||||
<input type="text" name="appVersion" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required data-edit-app-field="appVersion">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Status</span>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div class="form-grid">
|
||||
<label class="form-field">
|
||||
<span>Package code</span>
|
||||
<input type="text" name="packageCode" placeholder="NAV-STACK" required>
|
||||
<input type="text" name="packageCode" placeholder="NAV-STACK" pattern="[A-Za-z0-9._+-]+" title="Only letters, numbers, dot, underscore, plus and hyphen. No spaces." required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Package type</span>
|
||||
@@ -29,7 +29,7 @@
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Version</span>
|
||||
<input type="text" name="version" placeholder="1.0.0" required>
|
||||
<input type="text" name="version" placeholder="1.0.0" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Release date</span>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<span class="status-dot"></span>
|
||||
<div>
|
||||
<strong>SQL Server</strong>
|
||||
<span>172.20.235.176</span>
|
||||
<span><%= databaseLabel %></span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>New version</span>
|
||||
<input type="text" name="version" placeholder="2.5.0" required>
|
||||
<input type="text" name="version" placeholder="2.5.0" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Release date</span>
|
||||
|
||||
Reference in New Issue
Block a user