laster 0.0.1

This commit is contained in:
2026-05-25 15:49:42 +07:00
parent 14d3a3152a
commit e2c4881bb7
22 changed files with 1139 additions and 158 deletions

View File

@@ -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

View File

@@ -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"]

View File

@@ -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}

View 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 "$@"

View File

@@ -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;

View File

@@ -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.'

View File

@@ -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,

View File

@@ -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>

View 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') %>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>