laster 0.0.2

This commit is contained in:
2026-05-26 15:43:56 +07:00
parent e2c4881bb7
commit 8ceb1bb1df
24 changed files with 583 additions and 40 deletions

View File

@@ -4,6 +4,7 @@ WEB_SERVER_CONTAINER_NAME=robot-installer-web-server
WEB_SERVER_PORT=3000
IMAGE_TAG=1.0.1
DOCKER_NETWORK=robot-installer-net
WEB_SERVER_UPLOADS_DIR=./uploads
SQLSERVER_HOST=172.20.235.176
SQLSERVER_PORT=1433
SQLSERVER_DATABASE=RobotInstaller

View File

@@ -12,7 +12,7 @@ ENV PORT=3000
WORKDIR /app
RUN apk add --no-cache su-exec
RUN apk add --no-cache bzip2 dpkg su-exec xz zstd
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .

View File

@@ -15,7 +15,7 @@ services:
ports:
- "${WEB_SERVER_PORT:-3000}:3000"
volumes:
- web_server_uploads:/app/uploads
- ${WEB_SERVER_UPLOADS_DIR:-./uploads}:/app/uploads
networks:
- robot-installer
restart: unless-stopped
@@ -24,7 +24,3 @@ networks:
robot-installer:
name: ${DOCKER_NETWORK:-robot-installer-net}
external: true
volumes:
web_server_uploads:
name: ${WEB_SERVER_UPLOADS_VOLUME:-robot-installer-web-server-uploads}

View File

@@ -1,12 +1,12 @@
{
"name": "robot-installer-web-server",
"version": "0.1.0",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "robot-installer-web-server",
"version": "0.1.0",
"version": "1.0.0",
"dependencies": {
"dotenv": "^17.4.2",
"ejs": "^3.1.10",

View File

@@ -1,6 +1,6 @@
{
"name": "robot-installer-web-server",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"description": "Robot Installer package management web server UI",
"main": "server.js",

View File

@@ -1044,6 +1044,8 @@ tbody tr:hover td.action-col {
border: 1px solid rgba(226, 232, 240, 0.9);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
display: flex;
flex-direction: column;
max-height: min(86vh, 720px);
overflow: hidden;
transform: scale(0.97);
@@ -1064,6 +1066,7 @@ tbody tr:hover td.action-col {
background: #f8fafc;
border-bottom: 1px solid #eef2f7;
display: flex;
flex-shrink: 0;
justify-content: space-between;
padding: 14px 18px;
}
@@ -1075,16 +1078,23 @@ tbody tr:hover td.action-col {
}
.modal-form {
max-height: calc(86vh - 60px);
flex: 1;
min-height: 0;
overflow: auto;
padding: 18px;
scrollbar-gutter: stable;
}
.modal-actions {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), #ffffff 30%);
border-top: 1px solid #eef2f7;
display: flex;
gap: 10px;
justify-content: flex-end;
padding-top: 18px;
margin: 18px -18px -18px;
padding: 14px 18px 18px;
position: sticky;
bottom: 0;
}
.modal-actions .btn {

View File

@@ -1,6 +1,7 @@
require('dotenv').config({ quiet: true });
const crypto = require('crypto');
const { execFile } = require('child_process');
const fs = require('fs');
const fsp = require('fs/promises');
const path = require('path');
@@ -15,6 +16,7 @@ const app = express();
const port = Number(process.env.PORT || 3000);
const uploadDir = path.join(__dirname, 'uploads', 'packages');
const agentPackageDir = path.resolve(process.env.AGENT_PACKAGE_DIR || path.join(uploadDir, 'agent'));
const agentDebianPackageName = 'local-installer-agent';
const authCookieName = 'robot_installer_session';
const sessionMaxAgeMs = Number(process.env.SESSION_MAX_AGE_MS || 1000 * 60 * 60 * 8);
const authSecret = process.env.AUTH_SECRET || process.env.SESSION_SECRET || 'robot-installer-dev-secret';
@@ -32,6 +34,7 @@ const agentVersionCollator = new Intl.Collator('en', {
numeric: true,
sensitivity: 'base'
});
let didWarnAgentMetadataToolMissing = false;
app.get('/healthz', (req, res) => {
res.status(200).json({ status: 'ok' });
@@ -598,13 +601,112 @@ function getAgentPackageDownloadPath(fileName) {
return `/packages/agent/${encodeURIComponent(fileName)}`;
}
async function getAgentPackageFromEntry(entry) {
const match = /^local-installer-agent_([^/\\]+)_([^/\\]+)\.deb$/.exec(entry.name);
function parseAgentPackageFileName(fileName) {
const match = /^local-installer-agent_([^/\\]+)_([^/\\]+)\.deb$/.exec(fileName);
if (!match) return null;
const [, version, packageArch] = match;
return {
version: match[1],
arch: match[2]
};
}
function parseDebControlOutput(output) {
return String(output || '')
.split(/\r?\n/)
.reduce((metadata, line) => {
const match = /^([^:]+):\s*(.*)$/.exec(line);
if (!match) return metadata;
const key = match[1].trim().toLowerCase();
const value = match[2].trim();
if (key === 'package') metadata.package = value;
if (key === 'version') metadata.version = value;
if (key === 'architecture') metadata.architecture = value;
return metadata;
}, {});
}
function isDebMetadataInspectionUnavailable(error) {
const message = String(error?.message || '');
return error?.code === 'ENOENT'
|| /unable to execute decompressing archive/i.test(message)
|| /member 'control\.tar' \([^)]+\): No such file or directory/i.test(message);
}
async function readDebPackageMetadata(filePath) {
return new Promise((resolve, reject) => {
execFile(
'dpkg-deb',
['-f', filePath, 'Package', 'Version', 'Architecture'],
{ windowsHide: true, timeout: 10000, maxBuffer: 1024 * 1024 },
(error, stdout) => {
if (error) {
if (isDebMetadataInspectionUnavailable(error)) error.metadataToolMissing = true;
reject(error);
return;
}
const metadata = parseDebControlOutput(stdout);
if (!metadata.package || !metadata.version || !metadata.architecture) {
reject(new Error('Missing Package, Version, or Architecture in deb control metadata.'));
return;
}
resolve(metadata);
}
);
});
}
async function readDebPackageMetadataIfAvailable(filePath) {
try {
return await readDebPackageMetadata(filePath);
} catch (error) {
if (error.metadataToolMissing) {
if (!didWarnAgentMetadataToolMissing) {
console.warn('dpkg-deb metadata inspection is not available; using agent package filename metadata.');
didWarnAgentMetadataToolMissing = true;
}
return null;
}
console.warn(`Cannot inspect agent package metadata for ${path.basename(filePath)}:`, error.message);
return null;
}
}
async function getAgentPackageFromEntry(entry) {
const fileNameMetadata = parseAgentPackageFileName(entry.name);
if (!fileNameMetadata) return null;
const filePath = path.join(agentPackageDir, entry.name);
const stat = await fsp.stat(filePath);
const debMetadata = await readDebPackageMetadataIfAvailable(filePath);
if (debMetadata?.package && debMetadata.package !== agentDebianPackageName) {
console.warn(`Ignoring ${entry.name}: deb package is ${debMetadata.package}, expected ${agentDebianPackageName}.`);
return null;
}
if (debMetadata) {
const metadataArch = normalizeAgentArch(debMetadata.architecture);
const fileNameArch = normalizeAgentArch(fileNameMetadata.arch);
if (debMetadata.version !== fileNameMetadata.version || metadataArch !== fileNameArch) {
console.warn(
`Ignoring ${entry.name}: filename metadata does not match deb metadata ` +
`(deb version=${debMetadata.version}, deb arch=${debMetadata.architecture}).`
);
return null;
}
}
const version = fileNameMetadata.version;
const packageArch = fileNameMetadata.arch;
return {
fileName: entry.name,
@@ -824,6 +926,35 @@ async function getArtifactFromUpload(file) {
};
}
async function getDebUploadMetadataValidationMessage(file, packageCode, version) {
if (!file || path.extname(file.originalname).toLowerCase() !== '.deb') return null;
let debMetadata = null;
try {
debMetadata = await readDebPackageMetadata(file.path);
} catch (error) {
if (error.metadataToolMissing) {
console.warn('dpkg-deb metadata inspection is not available; skipping package metadata validation.');
return null;
}
return `Khong doc duoc metadata trong file .deb: ${error.message}`;
}
const metadataErrors = [];
if (debMetadata.package !== packageCode) {
metadataErrors.push(`package=${debMetadata.package}`);
}
if (debMetadata.version !== version) {
metadataErrors.push(`version=${debMetadata.version}`);
}
return metadataErrors.length > 0
? `File .deb khong khop thong tin package (${metadataErrors.join(', ')}). Package code va version tren web phai khop Package/Version trong file .deb.`
: null;
}
async function listMissingManifestPackageFiles(manifest) {
const missingPackageFiles = [];
@@ -849,6 +980,7 @@ async function listMissingManifestPackageFiles(manifest) {
componentId: component.componentId,
packageName: component.packageName,
version: component.version,
filePath: packageVersion?.filePath || '',
downloadUrl: component.downloadUrl
});
}
@@ -1233,7 +1365,7 @@ app.get('/api/apps/:appCode/versions/:version/manifest', asyncRoute(async (req,
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.',
detail: 'Upload the package file to this web-server storage or re-upload the same package version from the Packages page.',
missingPackageFiles
});
return;
@@ -1343,6 +1475,47 @@ app.post('/agent/packages', requireAdmin, agentUpload.single('agentFile'), async
return;
}
let debMetadata = null;
try {
debMetadata = await readDebPackageMetadata(req.file.path);
} catch (error) {
if (!error.metadataToolMissing) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/agent', 'warning', `Khong doc duoc metadata trong file .deb: ${error.message}`);
return;
}
console.warn('dpkg-deb metadata inspection is not available; skipping uploaded agent package metadata validation.');
}
if (debMetadata) {
const metadataArch = normalizeAgentArch(debMetadata.architecture);
const metadataErrors = [];
if (debMetadata.package !== agentDebianPackageName) {
metadataErrors.push(`package=${debMetadata.package}`);
}
if (debMetadata.version !== version) {
metadataErrors.push(`version=${debMetadata.version}`);
}
if (metadataArch !== arch) {
metadataErrors.push(`arch=${debMetadata.architecture}`);
}
if (metadataErrors.length > 0) {
await removeUploadedFile(req.file);
redirectWithNotice(
res,
'/agent',
'warning',
`File .deb khong khop thong tin upload (${metadataErrors.join(', ')}).`
);
return;
}
}
const targetFileName = `local-installer-agent_${version}_${arch}.deb`;
const targetPath = path.join(agentPackageDir, targetFileName);
const isUpdate = fs.existsSync(targetPath);
@@ -1488,6 +1661,15 @@ app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res)
return;
}
if (packageType === 'deb') {
const metadataMessage = await getDebUploadMetadataValidationMessage(req.file, packageCode, version);
if (metadataMessage) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/packages', 'warning', metadataMessage);
return;
}
}
await repository.createPackageWithVersion({
packageCode,
packageName,
@@ -1516,6 +1698,8 @@ app.post('/packages', upload.single('packageFile'), asyncRoute(async (req, res)
}));
app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (req, res) => {
let versionInput = null;
try {
const artifact = await getArtifactFromUpload(req.file);
const version = String(req.body.version || '').trim();
@@ -1532,7 +1716,23 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
return;
}
await repository.addPackageVersion({
const packageItem = await repository.getPackageById(req.body.packageId);
if (!packageItem) {
await removeUploadedFile(req.file);
redirectWithNotice(res, '/packages', 'warning', 'Khong tim thay package can cap nhat.');
return;
}
if (packageItem.type === 'deb') {
const metadataMessage = await getDebUploadMetadataValidationMessage(req.file, packageItem.code, version);
if (metadataMessage) {
await removeUploadedFile(req.file);
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'warning', metadataMessage);
return;
}
}
versionInput = {
packageId: req.body.packageId,
version,
releaseDate: req.body.releaseDate,
@@ -1541,12 +1741,33 @@ app.post('/package-versions', upload.single('packageFile'), asyncRoute(async (re
fileSizeBytes: artifact.fileSizeBytes,
checksum: artifact.checksum,
changeLog: req.body.changeLog
});
};
await repository.addPackageVersion(versionInput);
redirectWithNotice(res, `/packages/${req.body.packageId}`, 'success', 'Đã cập nhật version mới và đặt làm latest.');
} catch (error) {
await removeUploadedFile(req.file);
if (error.code === 'DUPLICATE_PACKAGE') {
if (versionInput && (versionInput.filePath || versionInput.dockerImage)) {
try {
const replacedVersionId = await repository.replacePackageVersionArtifact(versionInput);
if (replacedVersionId) {
redirectWithNotice(
res,
`/packages/${versionInput.packageId}`,
'success',
'Da re-upload package version hien co va cap nhat artifact.'
);
return;
}
} catch (replaceError) {
await removeUploadedFile(req.file);
throw replaceError;
}
}
await removeUploadedFile(req.file);
redirectWithNotice(res, `/packages/${req.body.packageId || ''}`, 'warning', 'Version này đã tồn tại trong package.');
return;
}

View File

@@ -1096,6 +1096,43 @@ async function addPackageVersion(input) {
return versionId;
}
async function replacePackageVersionArtifact(input) {
const pool = await getPool();
const result = await pool.request()
.input('PackageId', sql.UniqueIdentifier, input.packageId)
.input('Version', sql.NVarChar(50), input.version)
.input('FilePath', sql.NVarChar(1000), input.filePath || null)
.input('DockerImage', sql.NVarChar(500), input.dockerImage || null)
.input('FileChecksumSha256', sql.Char(64), input.checksum || null)
.input('FileSizeBytes', sql.BigInt, input.fileSizeBytes || null)
.input('ChangeLog', sql.NVarChar(sql.MAX), input.changeLog || null)
.input('ReleaseDate', sql.DateTime2, input.releaseDate ? new Date(input.releaseDate) : new Date())
.query(`
UPDATE dbo.PackageVersions
SET FilePath = @FilePath,
DockerImage = @DockerImage,
FileChecksumSha256 = @FileChecksumSha256,
FileSizeBytes = @FileSizeBytes,
ChangeLog = COALESCE(@ChangeLog, ChangeLog),
ReleaseDate = @ReleaseDate,
UploadedAt = SYSUTCDATETIME(),
IsDeprecated = 0
OUTPUT inserted.Id
WHERE PackageId = @PackageId
AND Version = @Version;
`);
const row = result.recordset[0];
if (!row) return null;
const versionId = String(row.Id);
await pool.request()
.input('PackageVersionId', sql.UniqueIdentifier, versionId)
.execute('dbo.SetLatestPackageVersion');
return versionId;
}
async function deletePackage(packageId) {
const pool = await getPool();
const transaction = new sql.Transaction(pool);
@@ -1369,6 +1406,7 @@ module.exports = {
getApplicationById,
createPackageWithVersion,
addPackageVersion,
replacePackageVersionArtifact,
deletePackage,
setLatestPackageVersion,
deletePackageVersion,

View File

@@ -17,8 +17,9 @@
</select>
</label>
<label class="form-field">
<span>New version</span>
<span>Version</span>
<input type="text" name="version" placeholder="2.5.0" pattern="[A-Za-z0-9._:+~=-]+" title="Only letters, numbers and . _ : + ~ = - characters." required>
<small>Nhap version moi de them, hoac version da co de re-upload artifact.</small>
</label>
<label class="form-field">
<span>Release date</span>